一、先聊聊为什么走这条路
充电宝机柜本质上是一个嵌入式设备,主板上跑着 C 固件,通过 4G 模块连上互联网。想让服务器跟机柜「说话」,最直接的方式就是 TCP 长连接。
但当时面临一个选择:用现成的 IoT 平台(比如阿里云 IoT、EMQ X),还是自己写 Socket 服务?
现成方案的好处很明显:不用操心连接管理、消息路由、设备影子这些东西。但代价也不小:
- 机柜固件端已经基于自定义 TCP 协议做了大量开发,换协议栈意味着改固件,硬件厂家不支持
- 多引入一个 Broker 节点,就多一层延迟和运维成本
- 自定义协议足够简单,没有 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();
}
}第二步:心跳保活
登录成功后,设备按固定间隔发送心跳。服务器收到心跳后做两件事:
- 更新
SocketClient.HeartTime为当前时间 - 将信号质量(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 方法发送。
八、租借与归还——核心业务指令流
租借流程(服务器 → 设备):
- 用户在微信小程序扫码
- 后端创建订单
- 后端通过
IotService.Send()下发rent指令 - 设备收到指令后弹出一个充电宝
- 设备回复执行结果
- 后端更新订单状态
归还流程(设备 → 服务器):
- 用户把充电宝插回机柜
- 机柜检测到插回,发送
return指令(附带电量和电池 SN) - 后端调用
CmdReturn处理 - 停止计费,计算订单金额
- 触发微信支付分扣款
- 更新设备电池槽位状态
- 服务器回复确认
归还的数据模型:
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 表:
| 字段 | 含义 |
|---|---|
| TypeId | 0=下发,1=接收 |
| Cmd | 指令类型 |
| SN | 设备编号 |
| N | 插槽编号 |
| Result | 0=成功,1=失败 |
| Msg | 完整报文 |
这意味着每一台设备的每一次指令交互都可以追溯,不管是排查问题还是分析设备行为都很有用。
十一、小结
自研 TCP 通信协议这件事,说难不难,说简单也不简单。它不需要高深的理论,但需要在细节上做到位——连接管理、心跳保活、指令确认、异常恢复、日志追溯。
如果你也在做类似的 IoT 项目,我个人的建议是:
- 如果设备量大(万台以上),用成熟的 IoT 平台,省心
- 如果设备量不大(几百台)且协议定制程度高,自己写一个 Socket 服务完全可行
下一篇会讲租借和归还的核心业务流程,包括订单状态机、计费规则、以及微信支付分免押金的对接细节。
有疑问或者想讨论的点,留言区见。
欢迎关注 天草设计 公众号,领取 源代码 福利礼包!
