0

by tcdos 2026-06-09

02 | 硬骨头 - 自研 TCP 通信协议,与充电宝机柜的「对话」方式

一、先聊聊为什么走这条路

充电宝机柜本质上是一个嵌入式设备,主板上跑着 C 固件,通过 4G 模块连上互联网。想让服务器跟机柜「说话」,最直接的方式就是 TCP 长连接。

但当时面临一个选择:用现成的 IoT 平台(比如阿里云 IoT、EMQ X),还是自己写 Socket 服务?

现成方案的好处很明显:不用操心连接管理、消息路由、设备影子这些东西。但代价也不小:

  1. 机柜固件端已经基于自定义 TCP 协议做了大量开发,换协议栈意味着改固件,硬件厂家不支持
  2. 多引入一个 Broker 节点,就多一层延迟和运维成本
  3. 自定义协议足够简单,没有 MQTT 那样重的 topic 路由模型,更适合我们这种指令式交互场景

所以最终的决定是——自己写。一个 SocketServer 单例类,随 Web 应用一起启动、一起停止。不另起进程,不引入额外中间件。


二、协议设计:简单到只有包头、包体、包尾

设备和服务器的通信格式极其简单:

#*{json 报文}*#
  • 包头:#*
  • 包尾:*#
  • 包体:JSON 格式

而 JSON 报文有一个统一的模型:

public class IotBasic
{
    public int? Msg { get; set; }       // 消息流水号(时间戳)
    public int? Aims { get; set; }      // 主板在设备中的编号
    public string SN { get; set; }      // 设备序列号
    public string Cmd { get; set; }     // 指令
}

所有业务数据放在 data 字段中。一台机柜不管发什么指令,都遵循这个结构。

比如一条心跳报文长这样:

#*{
  "msg": 1718691245,
  "aims": 0,
  "sn": "DS24060001",
  "cmd": "heart",
  "data": {
    "int": 45,
    "csq": 18
  }
}*#

csq 是信号强度,int 是期望的心跳间隔。服务器收到后也会用同样的结构回复。


三、指令全集:13 种指令覆盖全部交互场景

所有指令定义在一个静态类里:

public static class IotCmds
{
    public const string Login = "login";       // 设备登录
    public const string Heart = "heart";       // 心跳
    public const string Detail = "detail";     // 查询设备明细
    public const string Detailup = "detailup"; // 主动上报电池信息
    public const string Rent = "rent";         // 租借
    public const string Return = "return";     // 归还
    public const string Force = "force";       // 强制弹出
    public const string Updata = "updata";     // FOTA 升级
    public const string Reboot = "reboot";     // 重启
    public const string Vol = "vol";           // 查询音量
}

这些指令分两类:

方向指令说明
设备 → 服务器login、heart、detailup、return设备主动上报
服务器 → 设备detail、rent、force、reboot、updata服务器下发,设备执行后回复

关键设计原则:一条指令发出去,必须等到设备回复才算完成。后面会讲这个确认机制怎么实现的。


四、SocketServer:单例服务,随应用启动

SocketServer 注册为单例,在 Program.cs 中启动:

/* SocketServer 注册 */
builder.Services.AddSingleton<SocketServer>();

/* 应用启动后启动 Socket 服务 */
var socketServer = app.Services.GetService<SocketServer>();
socketServer?.Start();

启动的核心逻辑很简单:

public async Task<ResultModel> Start()
{
    serverSocket.Bind(new IPEndPoint(IPAddress.Parse(ip), port));
    serverSocket.Listen(maxListen);

    // 异步接受客户端连接
    ThreadPool.QueueUserWorkItem(async state => await AcceptAsync());

    // 启动心跳检测
    await Task.Run(async () => await CheckHeartbeats(cts.Token));

    return new ResultModel(true);
}

两个后台任务:一个负责接受新连接,一个负责检查心跳超时。


五、在线设备管理:ConcurrentDictionary + SocketClient

每台设备连上来,我们用一个 SocketClient 对象记录它的状态:

public class SocketClient
{
    public string ConnectId { get; set; }    // 连接 ID
    public string SN { get; set; }           // 设备序列号
    public string Address { get; set; }      // 远程地址
    public DateTime? LoginTime { get; set; } // 登录时间
    public DateTime? HeartTime { get; set; } // 最后一次心跳时间
    public Socket SockeClient { get; set; }  // Socket 引用
}

所有在线设备存在一个 ConcurrentDictionary 中:

public ConcurrentDictionary<Socket, SocketClient> OnlineSocketClients
    = new ConcurrentDictionary<Socket, SocketClient>();

ConcurrentDictionary 是 .NET 提供的线程安全字典,在高并发场景下不需要自己加锁。


六、设备生命周期:从登录到离线

第一步:登录

设备连上来之后的第一条指令就是 login

if (cmd == IotCmds.Login)
{
    OnlineSocketClients.TryAdd(clientSocket,
        new SocketClient(clientSocket, connectId, sn, address,
            DateTime.Now, DateTime.Now));
}

服务端收到 login 后,会验证设备身份(可选的 MD5 校验),更新设备表的在线状态、固件版本、硬件信息:

public static async Task<bool> CmdLogin(IotReqLogin model)
{
    var device = await efDbContext.ZnDevice
        .FirstOrDefaultAsync(x => x.SN == model.SN);
    if (device != null)
    {
        device = device.MapperToModel(model.Data);
        device.Status = 1;           // 标记在线
        device.LoginTime = DateTime.Now;
        efDbContext.ZnDevice.Update(device);
        await efDbContext.SaveChangesAsync();
    }
}

第二步:心跳保活

登录成功后,设备按固定间隔发送心跳。服务器收到心跳后做两件事:

  1. 更新 SocketClient.HeartTime 为当前时间
  2. 将信号质量(csq)写入数据库,更新设备表的心跳时间
// 更新心跳
if (OnlineSocketClients.TryGetValue(clientSocket, out SocketClient client))
{
    OnlineSocketClients.TryUpdate(clientSocket,
        new SocketClient(..., DateTime.Now),  // HeartTime = now
        client);
}

同时写入心跳数据表,后续可以在后台查看设备信号变化趋势。

第三步:心跳超时检测

后台有一个独立的 CheckHeartbeats 循环在跑,每隔 45 秒检查一次:

foreach (var client in OnlineSocketClients)
{
    if ((DateTime.Now - client.Value.HeartTime)?.TotalMilliseconds
        > heartbeatTimeout * 1000)
    {
        // 超时,移除并断开连接
        OnlineSocketClients.TryRemove(client.Value.SockeClient, out _);
        client.Value.SockeClient?.Close();
    }
}

心跳超时时间在配置文件中可配(默认 360 秒),超过该时间未收到心跳,服务器会强制断开连接。设备端 4G 模块会自动重连,整个流程不需要人工介入。


七、消息处理:一个 switch 分发所有指令

ReceiveAsync 中解析完报文后,进入 ProcessMessage 方法,根据 cmd 字段分发:

switch (cmd)
{
    case IotCmds.Login:
        // 登录处理 + 回复
        break;
    case IotCmds.Heart:
        // 心跳处理 + 回复
        break;
    case IotCmds.Detailup:
        // 设备主动上报明细 + 回复
        break;
    case IotCmds.Return:
        // 归还处理 + 回复
        break;
    case IotCmds.Rent:
        // 租借处理
        break;
    case IotCmds.Force:
        // 强制弹出处理
        break;
    case IotCmds.Reboot:
        // 重启处理
        break;
}

每种指令的处理都遵循收到→处理→回复三步。回复报文同样遵循 #*{json}*# 格式,通过 ReplyMessage 方法发送。


八、租借与归还——核心业务指令流

租借流程(服务器 → 设备):

  1. 用户在微信小程序扫码
  2. 后端创建订单
  3. 后端通过 IotService.Send() 下发 rent 指令
  4. 设备收到指令后弹出一个充电宝
  5. 设备回复执行结果
  6. 后端更新订单状态

归还流程(设备 → 服务器):

  1. 用户把充电宝插回机柜
  2. 机柜检测到插回,发送 return 指令(附带电量和电池 SN)
  3. 后端调用 CmdReturn 处理
  4. 停止计费,计算订单金额
  5. 触发微信支付分扣款
  6. 更新设备电池槽位状态
  7. 服务器回复确认

归还的数据模型:

public class IotReqReturnData
{
    public int? ST { get; set; }     // 设备库存
    public int? N { get; set; }      // 归还的充电口编号
    public string SN { get; set; }   // 充电宝 SN
    public int? E { get; set; }      // 电量(0-9%:0, 10-19%:1 ...)
    public List<IotBattery> D { get; set; }  // 所有槽位详情
}

九、指令确认机制:发了不等于到了

TCP 是可靠传输,但「服务器发了」不等于「设备收到了并执行了」。为了确保关键指令(租借、强制弹出、重启)被设备正确执行,做了一个轮询确认机制,这也是一个妥协的设计——因为硬件厂家不支持携带业务Id的消息接收与回复

// 下发指令
client.Send(Encoding.UTF8.GetBytes(message));

// 设备回复轮询
for (int i = 1; i <= interval; i++)
{
    await Task.Delay(intervalTime);  // 默认每次 800ms

    var strWhere = PredicateBuilder.True<ZnIotEntity>();
    strWhere = strWhere.And(x =>
        x.SN == sn && x.Cmd == cmd && x.TypeId == 1 && x.Timestamp >= mts);

    result = currentRepository.GetQueryAsNoTracking(strWhere).Any();
    if (result) break;
}

简单说:下发指令后,在数据库里等设备回复。如果规定次数内没等到,就认为指令执行失败。轮询间隔和次数都在配置文件中可调:

"TCP": {
    "Interval": 45,      // 最多查45次
    "IntervalTime": 800  // 每次间隔800ms
}

这样既保证了可靠性,又不会无限阻塞。


十、消息日志:全量记录,有迹可循

所有经过 Socket 的指令(下发和接收)都写入 ZnIot 表:

字段含义
TypeId0=下发,1=接收
Cmd指令类型
SN设备编号
N插槽编号
Result0=成功,1=失败
Msg完整报文

这意味着每一台设备的每一次指令交互都可以追溯,不管是排查问题还是分析设备行为都很有用。


十一、小结

自研 TCP 通信协议这件事,说难不难,说简单也不简单。它不需要高深的理论,但需要在细节上做到位——连接管理、心跳保活、指令确认、异常恢复、日志追溯。

如果你也在做类似的 IoT 项目,我个人的建议是:

  • 如果设备量大(万台以上),用成熟的 IoT 平台,省心
  • 如果设备量不大(几百台)且协议定制程度高,自己写一个 Socket 服务完全可行

下一篇会讲租借和归还的核心业务流程,包括订单状态机、计费规则、以及微信支付分免押金的对接细节。

有疑问或者想讨论的点,留言区见。

欢迎关注 天草设计 公众号,领取 源代码 福利礼包!

系列文章:共享充电宝

围观:109| 收获点赞:0

评论 0

暂无评论,来说点什么吧

?