📘 TEP 从端
命名空间: Snet.TEP.slave | 类: TepSlaveOperate | 配置: TepSlaveData.Basics | 包: Snet.TEP
从端(Slave / 客户端)是 TEP 的数据采集端(数据采集软件)。它通过 TCP 连接主端,进行身份认证,上传采集数据,应答状态查询,并接收服务端下发的数据写入。
对接从端有两种方式:
| 方式 | 说明 | 适用场景 |
|---|---|---|
C# 动态库(TepSlaveOperate) |
身份认证、会话心跳包、自动重连均已内置;只需传入状态获取方法与数据存储方法,再上传数据即可。 | .NET 客户端 |
| SocketTcp 原始协议 | 纯 TCP 字节协议;从端可用任意语言实现,对接非常流畅。 | C++ / Python / Java 等客户端 |
协议中,接入方为 SocketTcpClient,简称 C(0x43);我方(承载方)为 SocketTcpService,简称 S(0x53)。
C# 动态库接入(推荐 .NET 客户端)
快速开始
using Snet.TEP.slave;
using Snet.TEP.core;
using Snet.Model.data;
// 1. 创建从端。UserName/Password 是主端验证的凭据(密码不经网络发送——库只发出
// HMAC-SHA256 证明);DevID 必须唯一。
var client = await TepSlaveOperate.InstanceAsync(new TepSlaveData.Basics
{
IpAddress = "127.0.0.1",
Port = 6688,
DevName = "F100透气度仪",
DevID = "0001",
UserName = "test",
Password = "test"
});
// 2. 注册两个回调(OnAsync 之前必须注册)。
// 主端查询客户端状态(0x33)时调用。
client.SetBaseStateFunc(() =>
{
return true; // true = 状态正常, false = 状态异常
});
// 主端向本客户端写入点位(0x35)时调用。
client.SetBaseWriteFunc(keyValues =>
{
// keyValues 即 AddressValue 列表;应用到设备后返回结果即可。
// State 由库按 Status 自动映射;第三参数为运行时间。
return new OperateResult(true, "写入成功", 1);
});
// 3. (可选)信息事件:认证结果、心跳结果、状态消息。
// 先绑定事件再 OnAsync,避免错过连接/认证期间的事件。
client.OnInfoEventAsync += async (sender, e) =>
{
Console.WriteLine($"[{e.Status}] {e.Message}");
};
// 4. 打开连接——此后身份认证、会话心跳、自动重连均由内部处理。
OperateResult result = await client.OnAsync();
if (!result.Status) throw new Exception(result.Message);
// 5. 上传采集数据(0x34)。Key 不能包含 '.'(用别的符号代替)。
result = await client.DataUploadAsync(new List<CoreBasicsData.KeyValue>
{
new() { Key = "温度", Value = 26.5 },
new() { Key = "状态", Value = "正常" }
});
if (result.Status) Console.WriteLine("数据上传成功");
// 6. 关闭。
await client.OffAsync();
await client.DisposeAsync();
InstanceAsync是继承自CoreUnify的单例工厂——再次以等价配置调用会返回同一实例。
配置表 —— TepSlaveData.Basics
继承 TcpClientData.Basics(命名空间 Snet.Core.communication.net.tcp.client)。
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
DevName |
string | snet |
设备名称 |
DevID |
string | 10001 |
设备 ID,标识符,必须唯一 |
UserName |
string | ""(空) |
用户名(身份认证时校验)——必须显式配置 |
Password |
string | ""(空) |
共享密码,主端据此校验 HMAC-SHA256 证明(密码)——必须显式配置;从不经网络发送 |
SN |
string | Guid.NewGuid().ToUpperNString() |
唯一标识符(继承) |
IpAddress |
string | 127.0.0.1 |
主端 IP 地址 |
Port |
int | 6688 |
主端端口 |
InterruptReconnection |
bool | true |
断开后自动重连(继承) |
ReconnectionInterval |
int | 1000 |
重连间隔(毫秒,继承) |
Timeout |
int | 1000 |
超时时间(毫秒,继承) |
SendWaitInterval |
int | 5000 |
发送等待间隔(毫秒,继承自 TcpClientData.Basics) |
MaxChunkSize |
int | 261120 |
最大块大小(继承自 TcpClientData.Basics) |
RetrySendCount |
int | 5 |
重试发送次数(继承自 TcpClientData.Basics) |
BufferSize |
int | 1048576 |
数据缓冲区大小(继承自 TcpClientData.Basics) |
API 参考
public class TepSlaveOperate : CoreUnify<TepSlaveOperate, TepSlaveData.Basics>, IOn, IOff, IStatus, IDisposable, IAsyncDisposable
| 成员 | 签名 | 描述 |
|---|---|---|
SetBaseStateFunc |
public void SetBaseStateFunc(Func<bool> func) |
注册状态函数;服务端查询客户端状态(0x33)时调用。返回 true=正常,false=异常。 |
SetBaseWriteFunc |
public void SetBaseWriteFunc(Func<List<CoreBasicsData.KeyValue>, OperateResult> func) |
注册写入函数;服务端写入点位(0x35)时调用。收到的 KeyValue 列表需应用到设备并返回结果。 |
On / OnAsync |
public OperateResult On() / public async Task<OperateResult> OnAsync(CancellationToken token = default) |
打开连接。未注册上述两个回调会失败。 |
Off / OffAsync |
public OperateResult Off(bool hardClose = false) / public async Task<OperateResult> OffAsync(bool hardClose = false, CancellationToken token = default) |
关闭连接。 |
GetStatus / GetStatusAsync |
public OperateResult GetStatus() / public async Task<OperateResult> GetStatusAsync(CancellationToken token = default) |
查询连接状态。 |
DataUpload / DataUploadAsync |
public OperateResult DataUpload(List<CoreBasicsData.KeyValue> keyValues) / public async Task<OperateResult> DataUploadAsync(List<CoreBasicsData.KeyValue> keyValues, CancellationToken token = default) |
上传采集数据(0x34)。阻塞等待服务端响应(5 秒超时)。 |
Dispose / DisposeAsync |
public override void Dispose() / public override async ValueTask DisposeAsync() |
关闭并释放资源。 |
| 事件 | OnInfoEventAsync / OnDataEventAsync / OnLanguageEventAsync |
EventHandlerAsync<EventInfoResult> / EventHandlerAsync<EventDataResult> / EventHandlerAsync<EventLanguageResult>(继承自 CoreUnify);用 e.Status / e.Message(数据事件还有 e.ResultData)。 |
CoreBasicsData.KeyValue(命名空间 Snet.TEP.core)为 public string Key + public object? Value。Key 不能包含 .(见常见问题)。
CoreBasicsData 还提供库内部使用的协议辅助方法(均为 public static):
| 辅助方法 | 签名 | 说明 |
|---|---|---|
SerializePayload<T> |
static byte[] SerializePayload<T>(T value) |
将协议对象直接序列化为 UTF-8 JSON 载荷(不经过中间 JSON 字符串) |
TryDeserializePayload<T> |
static bool TryDeserializePayload<T>(ReadOnlySpan<byte> payload, out T? value) |
直接从 UTF-8 载荷反序列化;JSON 格式错误返回 false 而非抛异常 |
ComputeAuthenticationProof |
static string ComputeAuthenticationProof(string userName, string password, string devName, string devId, string challenge) |
计算 0x31 的 PasswordProof:HMAC-SHA256(password, "{userName}\n{devName}\n{devId}\n{challenge}") 的大写十六进制 |
AuthenticationProofEquals |
static bool AuthenticationProofEquals(string expected, string actual) |
对两个十六进制证明做固定时间比较 |
内部 PayloadJsonOptions(私有)使用 IncludeFields + AllowNamedFloatingPointLiterals + UnsafeRelaxedJsonEscaping,字段名与 Value 对象的行为与库自身 JSON 管线一致。
内置能力说明
- 身份认证 —— 收到 0x30 握手挑战后,库用
ComputeAuthenticationProof(UserName, Password, DevName, DevID, challenge)计算证明,并自动发送 0x31 身份验证请求(UserName/PasswordProof/DevName/DevID),最多等待 5 秒响应(timeOutMS = 5000);响应State == 1时启动会话心跳任务。 - 会话心跳 —— 每 5 秒发送一次 0x32 心跳并等待最多 5 秒响应;超时通过
OnInfoEventAsync上报(如响应超时)。若不发送心跳,服务端会强制踢出客户端。 - 自动重连 —— 底层
TcpClientOperate自动重连(InterruptReconnection = true,间隔ReconnectionInterval);重连成功后握手 → 认证 → 心跳流程自动重新执行。 - 信息事件 ——
OnInfoEventAsync上报认证结果、心跳结果、连接状态(已连接/未连接)与内部异常,通过e.Status/e.Message获取。 - 注册顺序 ——
SetBaseStateFunc、SetBaseWriteFunc两个回调必须在OnAsync之前注册,否则OnAsync会返回对应的错误信息。
性能
从端 → 主端的数据上传经内置压力测试实测验证(Snet.Tep.Client.Samples 菜单 0. 压力测试,对 Snet.Tep.Service.Samples 压测):3000+ 次上传请求/秒、0 失败,每次请求携带 36 个点位——即 每秒 10 万+ 个数据点(100,000+ data points per second),约 4 MB/s。吞吐量随电脑性能提升而提升,速度上不封顶(电脑性能越好,速度就越快)。这两个示例项目已在 2026-08 重构中移除,由源码仓库 Snet/docs 目录下的官方参考客户端(TepClient.java、tep_client.cpp、tep_client.py)取代——见下方对接代码示例。
吞吐量由以下设计点支撑:
| 设计点 | 来源 | 作用 |
|---|---|---|
| 批量上传 | DataUpload / DataUploadAsync 接收 List<KeyValue> |
一次请求携带多个点位;推荐批量上传而非逐点调用 |
| 有界通道 | CoreBasicsData.channelOptions = BoundedChannelOptions(ushort.MaxValue)(容量 65535,FullMode = Wait) |
缓冲突发流量不丢数据;发送方等待而非丢弃 |
| 大数据包支持 | bigDataPackLength = 1 MB |
超大上传安全分包/流转;多点位批次不受小包限制 |
| 复用分配 | 方向数组以 static readonly 缓存 |
热路径避免逐包分配数组 |
追求峰值吞吐时,在设备端汇总点位,按周期用一次 DataUploadAsync(List<CoreBasicsData.KeyValue>) 批量上传,而不是逐点调用。
SocketTcp 原始协议(任意语言对接)
包体结构
发送的包体结构:
| 字段 | 固定字节 | 长度 | 说明 |
|---|---|---|---|
| 针头 | 0x7b 0x5b 0x28 0x3c |
4 | 解包封包用 |
| 命令 | — | 1 | 0x30~0x35 |
| 方向 | C>S:0x43 0x53 / S>C:0x53 0x43 |
2 | 客户端→服务端 / 服务端→客户端 |
| 数据包长度 | — | 4 | 数据包(JSON)字节长度,小端序(BitConverter.GetBytes) |
| 数据包 | — | N | JSON 数据,UTF8 编码——0x30 除外,其为 ASCII 挑战串(64 位大写十六进制) |
| CRC | — | 2 | 对数据包计算 CRC16,低位字节在前(GetCRC16(pack, false))——见 CRC16 校验规则 |
| 针尾 | 0x3e 0x29 0x5d 0x7d |
4 | 解包封包用 |
- 最小包 = 4 + 1 + 2 + 4 + 2 + 4 = 17 字节(
CoreBasicsData.minLength = 17)。 - 数据包从偏移 11 开始(
dataStartBit = 11);CRC(2)+ 针尾(4)占最后 6 字节(dataEndBit = 6)。 - 数据包长度上限 1 MB(
bigDataPackLength = 1024 * 1024)。 - 收包时进行针头、命令、方向、针尾判断与 CRC 校验 + 数据长度判断,确保数据的完整性与正确性。如果请求无返回,说明包体存在问题——先检查以上五个验证点。
命令表
| 命令 | 名称 | 模式 | 描述 |
|---|---|---|---|
0x30 |
握手 | 被动 | S→C:下发一次性认证挑战;客户端随后应答 0x31 |
0x31 |
身份验证 | 主动 | C→S:握手后 5 秒内必须发送,否则被服务端强制踢出 |
0x32 |
会话心跳包 | 主动 | C→S:认证后 5 秒内必须主动请求一次,否则被服务端强制踢出 |
0x33 |
获取客户端状态 | 被动 | S→C:服务端通过设备名、设备 ID 获取设备状态 |
0x34 |
数据上传 | 主动 | C→S:客户端主动上传设备采集的数据 |
0x35 |
数据写入 | 被动 | S→C:服务端下发至客户端写入数据,无法写入直接在 Message 中表现 |
字段详情
| 名称 | 描述 |
|---|---|
DevName |
设备名称 |
DevID |
设备 ID,此字段属于标识符,必须唯一 |
Message |
消息 |
State |
状态;-1 身份尚未认证;0 未响应;1 成功/正常;2 异常 |
AddressValue |
地址键值集合。注意 Key 不要存在 [ . ] 点,用别的符号代替 |
UserName |
用户名 |
PasswordProof |
HMAC-SHA256 认证证明——HMAC-SHA256(password, "{UserName}\n{DevName}\n{DevID}\n{challenge}") 的大写十六进制;共享密码从不传输 |
各命令详解(0x30~0x35)
以下每个命令中,数据内容均通过 UTF8 编码格式转换成字节数组;响应时反转方向字节即可。
0x30 握手【被动】
| 类型 | 命令 | 方向 |
|---|---|---|
| 被动 | 0x30 |
请求:S>C 0x53 0x43 |
数据内容:一次性认证挑战——32 个随机字节渲染为 64 位大写十六进制字符串(UTF-8/ASCII,非 JSON)。主端为每次连接生成新挑战,截获的证明无法重放(Convert.ToHexString(RandomNumberGenerator.GetBytes(32)))。
挑战是协议中唯一非 JSON 的数据包:客户端把它传给 ComputeAuthenticationProof,并在 5 秒(timeOutMS)内发送 0x31 身份验证请求,否则被服务端强制踢出。
0x31 身份验证【主动】
| 类型 | 命令 | 方向 |
|---|---|---|
| 主动 | 0x31 |
请求:C>S 0x43 0x53 / 响应:S>C 0x53 0x43 |
请求(C>S)——PasswordProof 基于 0x30 挑战计算(Password 从不发送):
{
"UserName": "test",
"PasswordProof": "5A9B6C0D3E4F1E2D3C4B5A6978877665544332211AABBCCDDEEFF0011223344",
"DevName": "F100透气度仪",
"DevID": "0001"
}
响应(S>C)成功(State 为 1):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "身份验证成功",
"State": 1
}
响应(S>C)失败(State 为 2):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "身份验证失败,账号或密码错误",
"State": 2
}
计算方式:message = UserName + "\n" + DevName + "\n" + DevID + "\n" + Challenge(LF 是单个 0x0A 字节,不能替换为 CRLF),然后 PasswordProof = UPPER_HEX(HMAC-SHA256(key = UTF8(Password), data = UTF8(message)))。挑战按收到的 64 字符字符串参与计算,不先转换回 32 个随机字节。
0x32 会话心跳包【主动】,间隔 5 秒一次
| 类型 | 命令 | 方向 |
|---|---|---|
| 主动 | 0x32 |
请求:C>S 0x43 0x53 / 响应:S>C 0x53 0x43 |
请求(C>S):
{
"DevName": "F100透气度仪",
"DevID": "0001"
}
响应(S>C)成功(State 为 1):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "会话心跳包正常",
"State": 1
}
响应(S>C)失败(State 为 2):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "会话心跳包异常,设备信息与已认证身份不一致",
"State": 2
}
认证之前(State 为 -1):0x32 与 0x34 只能在 0x31 响应 State == 1 之后发送——提前发送,服务端可能返回 State = -1(未认证)。
无响应:请求 5 秒(timeOutMS)内未得到响应即视为超时。由于协议没有请求编号,迟到的响应可能被误认为是下一条同命令请求的响应——超时后应关闭/重建连接并重新认证,而不是直接重发。S 约 10 秒(timeOutMS×2,每 5 秒检查一次)没有收到 C 的请求,是 C 的问题,S 强制关闭 C 的连接。
0x33 获取客户端状态【被动】
| 类型 | 命令 | 方向 |
|---|---|---|
| 被动 | 0x33 |
请求:S>C 0x53 0x43 / 响应:C>S 0x43 0x53 |
请求(S>C):
{
"DevName": "F100透气度仪",
"DevID": "0001"
}
响应(C>S)正常(State 为 1):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"State": 1,
"Message": "获取客户端状态成功,状态正常"
}
响应(C>S)异常(State 为 2):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"State": 2,
"Message": "获取客户端状态失败,状态异常"
}
0x34 数据上传【主动】
| 类型 | 命令 | 方向 |
|---|---|---|
| 主动 | 0x34 |
请求:C>S 0x43 0x53 / 响应:S>C 0x53 0x43 |
请求(C>S)——下方示例中的键(a.a.a)仅为示意占位,真实 Key 不能包含 .:
{
"DevName": "F100透气度仪",
"DevID": "0001",
"AddressValue": [
{ "Key": "a.a.a", "Value": 6.6 },
{ "Key": "a.a.b", "Value": "hello" }
]
}
响应(S>C)成功(State 为 1):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "数据上传成功",
"State": 1
}
响应(S>C)失败(State 为 2):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "数据上传失败,原因说明",
"State": 2
}
State = 1 表示服务端成功解析请求、确认 DevName/DevID 与已认证身份一致并处理了本次 AddressValue。协议没有请求编号——同一连接上不得同时保留多个未完成的 0x34(或 0x32)请求;必须等上一个响应或超时后再发送下一条(见 0x32 的超时规则)。
0x35 数据写入【被动】
| 类型 | 命令 | 方向 |
|---|---|---|
| 被动 | 0x35 |
请求:S>C 0x53 0x43 / 响应:C>S 0x43 0x53 |
请求(S>C)——占位说明同 0x34:
{
"DevName": "F100透气度仪",
"DevID": "0001",
"AddressValue": [
{ "Key": "a.a.a", "Value": 6.6 },
{ "Key": "a.a.b", "Value": "hello" }
]
}
响应(C>S)成功(State 为 1):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "数据写入成功,{写入函数返回的 Message}",
"State": 1
}
Message = "数据写入成功" + 写入函数返回的 Message(上方示例假设写入函数返回了 "写入成功")。
响应(C>S)失败(State 为 2):
{
"DevName": "F100透气度仪",
"DevID": "0001",
"Message": "数据写入失败,****",
"State": 2
}
注意:如果响应 0x35 后被服务端关闭连接,说明你返回的数据存在问题(服务端在返回数据有问题时会关闭客户端)。
CRC16 校验规则
CRC 只对数据包(JSON 字节)计算。线上字节序为低位字节在前——与官方参考客户端一致(先 crc & 0xFF,再 crc >> 8):
- C# 库通过
GetCRC16(pack, false)输出——已验证:对"123456789"结果为[0x37, 0x4B],即标准 CRC-16/MODBUS 结果0x4B37低位字节在前写入。 - 原因:C# 查表实现的寄存器对
crc_Low/crc_High(均初始化为0xFF)持有的是标准按位 CRC-16/MODBUS(多项式0xA001,初值0xFFFF)结果(crc_Low= 低位0x37,crc_High= 高位0x4B);默认调用GetCRC16(pack, false)返回{crc_Low, crc_High}——即低位字节在前。(注意参数isHighBefore的语义实际是反的:false输出低位在前,true输出高位在前。) - 任意语言换算规则:按位计算 CRC-16/MODBUS 后,先写
(crc & 0xFF),再写(crc >> 8)。长度字段保持 4 字节小端序。
完整参考实现(摘自官方《TEP接口接入规范(SocketTcp)》,逐字转录):
#region CRC16查表法
#region 高位表
/// <summary>
/// CRC高位校验码表
/// </summary>
readonly static byte[] cRCHighArray =
{
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40,
0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1,
0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40
};
#endregion 高位表
#region 低位表
/// <summary>
/// CRC低位校验码表
/// </summary>
readonly static byte[] cRCLowArray =
{
0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06,
0x07, 0xC7, 0x05, 0xC5, 0xC4, 0x04, 0xCC, 0x0C, 0x0D, 0xCD,
0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09,
0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A,
0x1E, 0xDE, 0xDF, 0x1F, 0xDD, 0x1D, 0x1C, 0xDC, 0x14, 0xD4,
0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3,
0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3,
0xF2, 0x32, 0x36, 0xF6, 0xF7, 0x37, 0xF5, 0x35, 0x34, 0xF4,
0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A,
0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29,
0xEB, 0x2B, 0x2A, 0xEA, 0xEE, 0x2E, 0x2F, 0xEF, 0x2D, 0xED,
0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26,
0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60,
0x61, 0xA1, 0x63, 0xA3, 0xA2, 0x62, 0x66, 0xA6, 0xA7, 0x67,
0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F,
0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68,
0x78, 0xB8, 0xB9, 0x79, 0xBB, 0x7B, 0x7A, 0xBA, 0xBE, 0x7E,
0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5,
0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71,
0x70, 0xB0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92,
0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C,
0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B,
0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, 0x4B, 0x8B,
0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C,
0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42,
0x43, 0x83, 0x41, 0x81, 0x80, 0x40
};
#endregion 低位表
/// <summary>
/// 计算CRC16循环校验码
/// ① 支持按位异或校验(XOR)
/// ② 支持CRC16查表法校验
/// ③ 支持CRC16带多项式计算法校验
/// </summary>
/// <param name="cmd">字节数据</param>
/// <param name="isHighBefore">是否高位在前,默认false</param>
/// <returns></returns>
public static byte[] GetCRC16(byte[] cmd, bool isHighBefore)
{
int index;
int crc_Low = 0xFF;
int crc_High = 0xFF;
for (int i = 0; i < cmd.Length; i++)
{
index = crc_High ^ (char)cmd[i];
crc_High = crc_Low ^ cRCHighArray[index];
crc_Low = (byte)cRCLowArray[index];
}
if (isHighBefore == true)
{
return new byte[2] { (byte)crc_High, (byte)crc_Low };
}
else
{
return new byte[2] { (byte)crc_Low, (byte)crc_High };
}
}
#endregion CRC16查表法
对接代码示例(C++ / Python / Java)
以下三个示例均基于 SocketTcp 原始协议实现完整的从端行为(官方参考客户端,来自 Snet/docs):
- TCP 连接主端。
- 收包解析:针头 / 命令 / 方向 / 长度(4 字节小端)/ CRC(低位在前)/ 针尾。
- 等待 0x30 握手(64 位大写十六进制挑战)→ 计算
PasswordProof→ 发送 0x31 身份验证 → 每 5 秒发送 0x32 心跳(断线后重连并重新走完整的挑战/认证循环——旧挑战与旧认证状态全部失效)。 - 应答 0x33 状态查询与 0x35 数据写入。
- 0x34 数据上传,阻塞等待响应、5 秒超时。
C++(C++17、nlohmann/json + OpenSSL)
// Snet.TEP SocketTcp V1 reference client core (C++17).
// Dependency: nlohmann/json. Socket transport must call send_all/recv into FrameParser.
#include <algorithm>
#include <array>
#include <cstdint>
#include <deque>
#include <iomanip>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include <openssl/evp.h>
#include <openssl/hmac.h>
namespace tep {
constexpr std::array<std::uint8_t, 4> header{0x7B, 0x5B, 0x28, 0x3C};
constexpr std::array<std::uint8_t, 4> footer{0x3E, 0x29, 0x5D, 0x7D};
constexpr std::uint8_t client = 0x43, server = 0x53;
constexpr std::size_t max_payload = 1024 * 1024;
std::array<std::uint8_t, 2> crc16_modbus(const std::vector<std::uint8_t>& data) {
std::uint16_t crc = 0xFFFF;
for (auto value : data) {
crc ^= value;
for (int i = 0; i < 8; ++i) crc = (crc & 1) ? static_cast<std::uint16_t>((crc >> 1) ^ 0xA001) : static_cast<std::uint16_t>(crc >> 1);
}
return {static_cast<std::uint8_t>(crc), static_cast<std::uint8_t>(crc >> 8)};
}
std::vector<std::uint8_t> frame(std::uint8_t command, std::uint8_t source,
std::uint8_t destination, const std::string& utf8) {
if (utf8.size() > max_payload) throw std::length_error("TEP payload exceeds 1 MiB");
std::vector<std::uint8_t> payload(utf8.begin(), utf8.end()), out;
out.reserve(17 + payload.size());
out.insert(out.end(), header.begin(), header.end());
out.insert(out.end(), {command, source, destination});
const auto n = static_cast<std::uint32_t>(payload.size());
for (int shift = 0; shift < 32; shift += 8) out.push_back(static_cast<std::uint8_t>(n >> shift));
out.insert(out.end(), payload.begin(), payload.end());
const auto crc = crc16_modbus(payload);
out.insert(out.end(), crc.begin(), crc.end());
out.insert(out.end(), footer.begin(), footer.end());
return out;
}
struct Frame { std::uint8_t command, source, destination; std::string payload; };
class FrameParser {
std::vector<std::uint8_t> data_;
public:
void append(const std::uint8_t* data, std::size_t size) { data_.insert(data_.end(), data, data + size); }
bool next(Frame& result) {
for (;;) {
auto start = std::search(data_.begin(), data_.end(), header.begin(), header.end());
if (start == data_.end()) {
if (data_.size() > 3) data_.erase(data_.begin(), data_.end() - 3);
return false;
}
data_.erase(data_.begin(), start);
if (data_.size() < 11) return false;
std::uint32_t length = 0;
for (int i = 0; i < 4; ++i) length |= static_cast<std::uint32_t>(data_[7 + i]) << (8 * i);
if (length > max_payload) { data_.erase(data_.begin()); continue; }
const std::size_t total = 17 + length;
if (data_.size() < total) return false;
const bool footer_ok = std::equal(footer.begin(), footer.end(), data_.begin() + total - 4);
std::vector<std::uint8_t> payload(data_.begin() + 11, data_.begin() + 11 + length);
const auto crc = crc16_modbus(payload);
const bool crc_ok = data_[total - 6] == crc[0] && data_[total - 5] == crc[1];
if (!footer_ok || !crc_ok) { data_.erase(data_.begin()); continue; }
result = {data_[4], data_[5], data_[6], std::string(payload.begin(), payload.end())};
data_.erase(data_.begin(), data_.begin() + total);
return true;
}
}
};
std::string authentication_proof(const std::string& user, const std::string& password,
const std::string& dev_name, const std::string& dev_id,
const std::string& challenge) {
const std::string message = user + "\n" + dev_name + "\n" + dev_id + "\n" + challenge;
unsigned int length = EVP_MAX_MD_SIZE;
unsigned char digest[EVP_MAX_MD_SIZE];
HMAC(EVP_sha256(), password.data(), static_cast<int>(password.size()),
reinterpret_cast<const unsigned char*>(message.data()), message.size(), digest, &length);
std::ostringstream out;
out << std::uppercase << std::hex << std::setfill('0');
for (unsigned int i = 0; i < length; ++i) out << std::setw(2) << static_cast<unsigned int>(digest[i]);
return out.str();
}
nlohmann::json authentication_request(const std::string& user, const std::string& password,
const std::string& dev_name, const std::string& dev_id,
const std::string& challenge) {
return {{"DevName", dev_name}, {"DevID", dev_id}, {"UserName", user},
{"PasswordProof", authentication_proof(user, password, dev_name, dev_id, challenge)}};
}
nlohmann::json heartbeat_request(const std::string& dev_name, const std::string& dev_id) {
return {{"DevName", dev_name}, {"DevID", dev_id}};
}
nlohmann::json upload_request(const std::string& dev_name, const std::string& dev_id,
const nlohmann::json& address_values) {
return {{"DevName", dev_name}, {"DevID", dev_id}, {"AddressValue", address_values}};
}
} // namespace tep
// Integration sequence:
// 1. Feed every recv fragment (including <17 bytes) to FrameParser::append.
// 2. Require command 0x30, direction 53 43, and a 64-character uppercase-hex challenge.
// 3. send_all(frame(0x31, client, server, authentication_request(...).dump())).
// 4. Require State==1, then serialize heartbeat 0x32 and upload 0x34 requests.
// 5. While waiting, answer server 0x33/0x35 requests and verify DevName/DevID on every message.
Python(标准库)
"""Snet.TEP SocketTcp V1 reference client (Python 3.10+, standard library only)."""
from __future__ import annotations
import hashlib
import hmac
import json
import socket
import struct
import threading
from dataclasses import dataclass
from typing import Any, Callable
HEADER = b"{[(<"
FOOTER = b">)]}"
CLIENT, SERVER = 0x43, 0x53
MAX_PAYLOAD = 1024 * 1024
def crc16_modbus(data: bytes) -> bytes:
crc = 0xFFFF
for value in data:
crc ^= value
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return struct.pack("<H", crc)
def make_frame(command: int, source: int, destination: int, payload: bytes) -> bytes:
if len(payload) > MAX_PAYLOAD:
raise ValueError("payload exceeds 1 MiB")
return HEADER + bytes((command, source, destination)) + struct.pack("<i", len(payload)) + payload + crc16_modbus(payload) + FOOTER
@dataclass(frozen=True)
class Frame:
command: int
source: int
destination: int
payload: bytes
class FrameReader:
def __init__(self, sock: socket.socket):
self.sock = sock
self.buffer = bytearray()
def read(self) -> Frame:
while True:
start = self.buffer.find(HEADER)
if start < 0:
del self.buffer[:-3]
elif start:
del self.buffer[:start]
if len(self.buffer) >= 11:
length = struct.unpack_from("<i", self.buffer, 7)[0]
if length < 0 or length > MAX_PAYLOAD:
del self.buffer[0]
continue
total = 17 + length
if len(self.buffer) >= total:
raw = bytes(self.buffer[:total])
del self.buffer[:total]
payload = raw[11 : 11 + length]
if raw[-4:] != FOOTER or raw[-6:-4] != crc16_modbus(payload):
continue
return Frame(raw[4], raw[5], raw[6], payload)
chunk = self.sock.recv(64 * 1024)
if not chunk:
raise ConnectionError("TEP socket closed")
self.buffer.extend(chunk)
class TepClient:
def __init__(self, host: str, port: int, username: str, password: str, dev_name: str, dev_id: str):
self.sock = socket.create_connection((host, port), timeout=5)
self.sock.settimeout(5)
self.reader = FrameReader(self.sock)
self.username, self.password = username, password
self.dev_name, self.dev_id = dev_name, dev_id
self.write_handler: Callable[[list[dict[str, Any]]], tuple[bool, str]] = lambda values: (False, "write handler not configured")
self.state_handler: Callable[[], bool] = lambda: True
self._send_lock = threading.Lock()
def _send_json(self, command: int, body: dict[str, Any]) -> None:
payload = json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
with self._send_lock:
self.sock.sendall(make_frame(command, CLIENT, SERVER, payload))
def _matches_device(self, body: dict[str, Any]) -> bool:
return body.get("DevName") == self.dev_name and body.get("DevID") == self.dev_id
def _handle_server_request(self, frame: Frame) -> bool:
if frame.command not in (0x33, 0x35):
return False
body = json.loads(frame.payload.decode("utf-8"))
valid = self._matches_device(body)
if frame.command == 0x33:
ok = valid and self.state_handler()
message = "状态正常" if ok else ("设备信息不一致" if not valid else "状态异常")
else:
ok, message = self.write_handler(body.get("AddressValue", [])) if valid else (False, "设备信息不一致")
self._send_json(frame.command, {"DevName": body.get("DevName", ""), "DevID": body.get("DevID", ""), "State": 1 if ok else 2, "Message": message})
return True
def _wait_response(self, command: int) -> dict[str, Any]:
while True:
frame = self.reader.read()
if frame.source != SERVER or frame.destination != CLIENT:
continue
if self._handle_server_request(frame):
continue
if frame.command == command:
body = json.loads(frame.payload.decode("utf-8"))
if not self._matches_device(body):
raise ValueError("response device identity mismatch")
return body
def authenticate(self) -> None:
challenge_frame = self.reader.read()
if (challenge_frame.command, challenge_frame.source, challenge_frame.destination) != (0x30, SERVER, CLIENT):
raise ValueError("expected TEP handshake challenge")
challenge = challenge_frame.payload.decode("ascii")
if len(challenge) != 64 or any(c not in "0123456789ABCDEF" for c in challenge):
raise ValueError("invalid handshake challenge")
message = f"{self.username}\n{self.dev_name}\n{self.dev_id}\n{challenge}".encode("utf-8")
proof = hmac.new(self.password.encode("utf-8"), message, hashlib.sha256).hexdigest().upper()
self._send_json(0x31, {"DevName": self.dev_name, "DevID": self.dev_id, "UserName": self.username, "PasswordProof": proof})
if self._wait_response(0x31).get("State") != 1:
raise PermissionError("TEP authentication failed")
def heartbeat(self) -> dict[str, Any]:
self._send_json(0x32, {"DevName": self.dev_name, "DevID": self.dev_id})
return self._wait_response(0x32)
def upload(self, values: list[dict[str, Any]]) -> dict[str, Any]:
self._send_json(0x34, {"DevName": self.dev_name, "DevID": self.dev_id, "AddressValue": values})
return self._wait_response(0x34)
def close(self) -> None:
self.sock.close()
if __name__ == "__main__":
client = TepClient("127.0.0.1", 6688, "test", "test", "F100透气度仪", "0001")
try:
client.authenticate()
print(client.upload([{"Key": "Area/A/Temperature", "Value": 6.6}]))
print(client.heartbeat())
finally:
client.close()
Java(Java 17+、Jackson)
// Snet.TEP SocketTcp V1 reference client core (Java 17+).
// JSON dependency: com.fasterxml.jackson.core:jackson-databind:2.20.0
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public final class TepClient {
public static final byte[] HEADER = {0x7B, 0x5B, 0x28, 0x3C};
public static final byte[] FOOTER = {0x3E, 0x29, 0x5D, 0x7D};
public static final byte CLIENT = 0x43, SERVER = 0x53;
public static final int MAX_PAYLOAD = 1024 * 1024;
private static final ObjectMapper JSON = new ObjectMapper();
public record Frame(int command, int source, int destination, byte[] payload) {}
public static byte[] crc16Modbus(byte[] data) {
int crc = 0xFFFF;
for (byte item : data) {
crc ^= item & 0xFF;
for (int i = 0; i < 8; i++) crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
}
return new byte[]{(byte) crc, (byte) (crc >>> 8)};
}
public static byte[] frame(int command, int source, int destination, byte[] payload) {
if (payload.length > MAX_PAYLOAD) throw new IllegalArgumentException("TEP payload exceeds 1 MiB");
ByteBuffer out = ByteBuffer.allocate(17 + payload.length).order(ByteOrder.LITTLE_ENDIAN);
out.put(HEADER).put((byte) command).put((byte) source).put((byte) destination).putInt(payload.length).put(payload);
out.put(crc16Modbus(payload)).put(FOOTER);
return out.array();
}
public static final class FrameParser {
private byte[] data = new byte[0];
public void append(byte[] fragment, int count) {
int oldLength = data.length;
data = Arrays.copyOf(data, oldLength + count);
System.arraycopy(fragment, 0, data, oldLength, count);
}
public List<Frame> drain() {
List<Frame> result = new ArrayList<>();
while (true) {
int start = indexOf(data, HEADER);
if (start < 0) {
data = data.length <= 3 ? data : Arrays.copyOfRange(data, data.length - 3, data.length);
return result;
}
if (start > 0) data = Arrays.copyOfRange(data, start, data.length);
if (data.length < 11) return result;
int length = ByteBuffer.wrap(data, 7, 4).order(ByteOrder.LITTLE_ENDIAN).getInt();
if (length < 0 || length > MAX_PAYLOAD) { data = Arrays.copyOfRange(data, 1, data.length); continue; }
int total = 17 + length;
if (data.length < total) return result;
byte[] payload = Arrays.copyOfRange(data, 11, 11 + length);
boolean footerOk = Arrays.equals(Arrays.copyOfRange(data, total - 4, total), FOOTER);
boolean crcOk = Arrays.equals(Arrays.copyOfRange(data, total - 6, total - 4), crc16Modbus(payload));
if (!footerOk || !crcOk) { data = Arrays.copyOfRange(data, 1, data.length); continue; }
result.add(new Frame(data[4] & 0xFF, data[5] & 0xFF, data[6] & 0xFF, payload));
data = Arrays.copyOfRange(data, total, data.length);
}
}
private static int indexOf(byte[] data, byte[] pattern) {
outer: for (int i = 0; i <= data.length - pattern.length; i++) {
for (int j = 0; j < pattern.length; j++) if (data[i + j] != pattern[j]) continue outer;
return i;
}
return -1;
}
}
public static String authenticationProof(String user, String password, String devName,
String devId, String challenge) throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(password.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal((user + "\n" + devName + "\n" + devId + "\n" + challenge).getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().withUpperCase().formatHex(digest);
}
public static byte[] authenticationFrame(String user, String password, String devName,
String devId, String challenge) throws Exception {
Map<String, Object> body = Map.of("DevName", devName, "DevID", devId, "UserName", user,
"PasswordProof", authenticationProof(user, password, devName, devId, challenge));
return frame(0x31, CLIENT, SERVER, JSON.writeValueAsBytes(body));
}
public static byte[] heartbeatFrame(String devName, String devId) throws Exception {
return frame(0x32, CLIENT, SERVER, JSON.writeValueAsBytes(Map.of("DevName", devName, "DevID", devId)));
}
public static byte[] uploadFrame(String devName, String devId, List<Map<String, Object>> values) throws Exception {
return frame(0x34, CLIENT, SERVER,
JSON.writeValueAsBytes(Map.of("DevName", devName, "DevID", devId, "AddressValue", values)));
}
public static void requireMatchingIdentity(JsonNode body, String devName, String devId) {
if (!devName.equals(body.path("DevName").asText()) || !devId.equals(body.path("DevID").asText()))
throw new SecurityException("TEP message device identity mismatch");
}
private TepClient() {}
}
/* Transport sequence:
* - Feed every Socket InputStream.read fragment, including fragments shorter than 17 bytes, to FrameParser.
* - Validate 0x30 direction SERVER->CLIENT and its 64-character uppercase-hex challenge.
* - OutputStream.write(authenticationFrame(...)); require the 0x31 response State == 1.
* - Send 0x32 about every five seconds and 0x34 serially; never keep two same-command requests pending.
* - Continuously answer server 0x33/0x35 messages and verify DevName/DevID on every request/response.
*/
常见问题
- 请求无返回 → 包体存在问题。检查五个验证点:针头、命令、方向、针尾、CRC 校验 + 数据长度。客户端正确时联系服务端部署者查看详细原因。
- Key 为什么不能包含点 → 主端按
DevName.DevID.Key拼接完整点位地址,并按.拆分路由;Key 中的点会破坏路由——请用其他符号(如_或-)代替。 - 被服务端强制踢出 → 两个时限:握手(0x30)后 5 秒内必须发送身份验证(0x31);认证后每 5 秒必须发送一次心跳(0x32)。C# 动态库均已自动处理。
- 为什么协议里没有
Password? → 认证采用挑战-应答:主端在 0x30 中发送一次性 64 位十六进制挑战,客户端在 0x31 中回复PasswordProof=HMAC-SHA256(password, "{UserName}\n{DevName}\n{DevID}\n{challenge}")的大写十六进制。共享密码始终不离开两端。C# 用CoreBasicsData.ComputeAuthenticationProof/AuthenticationProofEquals;其他语言用上方对接示例中的 C++ / Python / Java 参考客户端。 - 响应 0x35(数据写入)后服务端关闭连接 → 返回的数据存在问题(服务端在返回数据有问题时会关闭客户端)。
- State 各取值含义 →
-1身份尚未认证;0未响应;1成功/正常;2异常。
