TEP 从端 - Snet Docs

📘 TEP 从端

命名空间: Snet.TEP.slave | 类: TepSlaveOperate | 配置: TepSlaveData.Basics | 包: Snet.TEP

从端(Slave / 客户端)是 TEP 的数据采集端(数据采集软件)。它通过 TCP 连接主端,进行身份认证,上传采集数据,应答状态查询,并接收服务端下发的数据写入。

对接从端有两种方式:

方式 说明 适用场景
C# 动态库TepSlaveOperate 身份认证、会话心跳包、自动重连均已内置;只需传入状态获取方法与数据存储方法,再上传数据即可。 .NET 客户端
SocketTcp 原始协议 纯 TCP 字节协议;从端可用任意语言实现,对接非常流畅。 C++ / Python / Java 等客户端

协议中,接入方为 SocketTcpClient,简称 C0x43);我方(承载方)为 SocketTcpService,简称 S0x53)。

参见:TEP 协议概览TEP 主端

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? ValueKey 不能包含 .(见常见问题)。

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 的 PasswordProofHMAC-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 获取。
  • 注册顺序 —— SetBaseStateFuncSetBaseWriteFunc 两个回调必须在 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.javatep_client.cpptep_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 = 低位 0x37crc_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):

  1. TCP 连接主端。
  2. 收包解析:针头 / 命令 / 方向 / 长度(4 字节小端)/ CRC(低位在前)/ 针尾。
  3. 等待 0x30 握手(64 位大写十六进制挑战)→ 计算 PasswordProof → 发送 0x31 身份验证 → 每 5 秒发送 0x32 心跳(断线后重连并重新走完整的挑战/认证循环——旧挑战与旧认证状态全部失效)。
  4. 应答 0x33 状态查询与 0x35 数据写入。
  5. 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 异常。

相关链接