我用 Netty TCP 搭建物联网云平台,并对接车辆电池信息解析

从 0 到 1:我用 Netty TCP 搭建物联网云平台,并对接车辆电池信息解析


目录

  1. [背景:为什么不是 MQTT,而是 Netty TCP](#背景:为什么不是 MQTT,而是 Netty TCP)
  2. [工程骨架:一条 JVM 里跑 HTTP 和 TCP](#工程骨架:一条 JVM 里跑 HTTP 和 TCP)
  3. [协议设计:8 段帧、命令字、应答规则](#协议设计:8 段帧、命令字、应答规则)
  4. CRC16:联调时最容易扯皮的一层
  5. [Netty 接入层:Pipeline、线程、背压](#Netty 接入层:Pipeline、线程、背压)
  6. [上行全链路:从字节到 MySQL](#上行全链路:从字节到 MySQL)
  7. [车辆电池信息 0x53:字段表与业务串联](#车辆电池信息 0x53:字段表与业务串联)
  8. [养护过程 0x55 / 详情 0x54:订单号维度的数据关联](#养护过程 0x55 / 详情 0x54:订单号维度的数据关联)
  9. [下行指令:HTTP 如何打到 TCP Channel](#下行指令:HTTP 如何打到 TCP Channel)
  10. [多副本 K8s:连接在 A Pod,请求在 B Pod](#多副本 K8s:连接在 A Pod,请求在 B Pod)
  11. 协议日志与排障手册
  12. 本地运行与配置清单
  13. 我踩过的坑与建议的实施顺序
  14. 后续演进方向

1. 背景:为什么不是 MQTT,而是 Netty TCP

我这套平台服务的场景是:充电桩/养护终端 通过运营商或厂商给定的 私有二进制协议 长连接上报数据。平台要解析 车辆 VIN、电池标称参数、养护过程 SOC、电芯温度 等,管理端(独立前端仓库 evc-web)再通过 HTTP 做设备运维、检测订单、下发「开启/结束养护」「固件升级」。

在立项时我评估过几条路:

方案 优点 我为什么没选(或没在第一版选)
MQTT + 自建 Broker 生态成熟、订阅模型清晰 终端固件已按 TCP 私有帧实现,改传输层成本高
独立网关服务 + HTTP 内网 接入与业务解耦 第一版团队规模小,多一个进程多一份部署和联调链路
Spring Boot 内嵌 Netty 与 MyBatis/事务/权限同进程 资源隔离弱,但用线程模型 + 队列可以压住

最终选型:evc-server 模块里启动 Netty TCP Server(默认 9000) ,与 Spring MVC(管理端 API)共用数据源和事务。设备走 TCP;人和运营系统走 HTTP。这不是「理论上最优」,而是 交付速度、排障路径、事务一致性 三者平衡后的结果。

如果你也在做类似项目,可以把我这篇当成一条可复制的路线:

协议先钉死 → Netty 只做 I/O → 业务异步化 → 多副本用 Redis 回答「连接在哪台机器上」


2. 工程骨架:一条 JVM 里跑 HTTP 和 TCP

2.1 模块划分

后端是 Java 21 + Spring Boot 3.5 多模块 Maven 工程:

模块 职责
evc-dependencies BOM,集中第三方版本
evc-framework 自研 Starter:Web、Security、MyBatis、Redis、定时任务、租户等
evc-infra 基础设施:定时任务运维、接口文档、代码生成
evc-system 用户、部门、角色菜单、字典、租户
evc-server 可运行应用:HTTP + Netty TCP + 业务域(设备、订单、电池等)

依赖关系(简化):

复制代码
evc-dependencies → evc-framework → evc-infra → evc-system → evc-server

设备相关代码集中在:

复制代码
evc-server/src/main/java/com/usteu/evc/server/battery/
├── common/          # CRC、BCD、设备号转换、协议常量
├── netty/
│   ├── server/      # NettyTcpServer、配置
│   ├── codec/       # ProtocolDecoder / ProtocolEncoder
│   ├── handler/     # ConnectHandler、BusinessHandler
│   ├── channel/     # ChannelManager
│   ├── ingest/      # 有界队列 + ProtocolInboundProcessor
│   ├── cluster/     # Redis 归属 + 下行 Pub/Sub
│   ├── model/       # ProtocolMessage、*Req、*Resp
│   └── log/         # 协议报文落库
└── (业务 Service 在 server.service 包)

2.2 运行时架构图

#mermaid-svg-FFgMrj0WNGPaMm22{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FFgMrj0WNGPaMm22 .error-icon{fill:#552222;}#mermaid-svg-FFgMrj0WNGPaMm22 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FFgMrj0WNGPaMm22 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FFgMrj0WNGPaMm22 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .marker.cross{stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FFgMrj0WNGPaMm22 p{margin:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label text{fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label span{color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster-label span p{background-color:transparent;}#mermaid-svg-FFgMrj0WNGPaMm22 .label text,#mermaid-svg-FFgMrj0WNGPaMm22 span{fill:#333;color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .node rect,#mermaid-svg-FFgMrj0WNGPaMm22 .node circle,#mermaid-svg-FFgMrj0WNGPaMm22 .node ellipse,#mermaid-svg-FFgMrj0WNGPaMm22 .node polygon,#mermaid-svg-FFgMrj0WNGPaMm22 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .rough-node .label text,#mermaid-svg-FFgMrj0WNGPaMm22 .node .label text,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label,#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label{text-anchor:middle;}#mermaid-svg-FFgMrj0WNGPaMm22 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .rough-node .label,#mermaid-svg-FFgMrj0WNGPaMm22 .node .label,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label,#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label{text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .node.clickable{cursor:pointer;}#mermaid-svg-FFgMrj0WNGPaMm22 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .arrowheadPath{fill:#333333;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FFgMrj0WNGPaMm22 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster text{fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 .cluster span{color:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FFgMrj0WNGPaMm22 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FFgMrj0WNGPaMm22 rect.text{fill:none;stroke-width:0;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape p,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FFgMrj0WNGPaMm22 .icon-shape .label rect,#mermaid-svg-FFgMrj0WNGPaMm22 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFgMrj0WNGPaMm22 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FFgMrj0WNGPaMm22 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FFgMrj0WNGPaMm22 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
evc-server Pod
终端侧
长连接 私有协议
充电桩 / 养护终端
Netty TCP :9000
Spring MVC :8088
InboundMessageIngestService
ProtocolInboundProcessor
UploadDataProcessServiceImpl
MaintainDownlinkLocalExecutor
MySQL
Redis
evc-web 管理端

读代码的入口建议:

  1. NettyTcpServer.java --- Pipeline 怎么搭
  2. ProtocolDecoder.java --- 粘包/CRC
  3. BusinessHandler.java --- 线程切换与入队
  4. ProtocolInboundProcessor.java --- 命令字分发与应答
  5. UploadDataProcessServiceImpl.java --- 业务落库

3. 协议设计:8 段帧、命令字、应答规则

和硬件同事对齐后,我把协议常量收口在 ProtocolConstants,避免魔法数字散落。

3.1 标准帧结构(8 段)

序号 字段 长度 说明
1 固定头 2B 0xA27C
2 长度 2B 从「版本」到 CRC(含 CRC) 的字节数
3 版本 1B 当前 0x07
4 电桩 ID 4B 大端 Int,帧头逻辑桩号
5 序号 1B 请求/应答配对,下行由服务端递增
6 命令字 1B 业务类型
7 数据域 N 与命令字相关
8 CRC16 2B 大端,覆盖「固定头」到「数据域末尾」

总帧长 = 4(固定头 + 长度字段本身)+ length字段值。

解码器里最小帧长注释写得很清楚:

复制代码
固定头2 + 长度2 + 版本1 + 电桩ID4 + 序号1 + 命令字1 + CRC2 = 13 字节(数据域可为 0)

3.2 命令字总表

上行(终端 → 服务端)
命令字 含义 数据域实体类 服务端应答
0x50 重连上报设备信息 ReconnectInfoReq 0xF0(结果 + 服务器时间 BCD)
0x51 心跳 HeartbeatReq(标签 0xAA 0xF1(结果 + 对时)
0x52 状态包(枪状态) StatusPacketReq 0xF2(仅结果)
0x53 车辆与电池信息 BatteryInfoReq 0xF3(仅结果)
0x54 养护详情 MaintainDetailReq 0xF4
0x55 养护过程数据 MaintainProcessReq(固定 36 字节) 0xF5
下行(服务端 → 终端)
命令字 含义 终端应答
0x40 开启养护 0xE0
0x41 结束养护 0xE1
0x42 升级(FTP 参数) 0xE2

执行结果码:0x00 成功,0x01 失败。

3.3 设备号与枪号的两套编号

联调早期我吃过编号混乱的亏,后来在 ProtocolDeviceIdUtil 里写死规则:

  • 帧头 pileId :4 字节大端 Int,与业务表 evc_device.device_no 十进制字符串互转(当前实现是 String.valueOf(pileId))。
  • 0x50 数据域桩号 :4 字节按 HEX 展开后再转十进制业务设备号(hexDeviceNoToBusinessDeviceNo)。
  • :数据域里是完整枪编号(Int),对应 evc_device_gun.gun_no,不要用「枪序号」冒充业务枪号。

状态包 0x52 更新枪状态时,我是 先 pileId → deviceNo → 再按 gunId 查枪表,避免更新错行。


4. CRC16:联调时最容易扯皮的一层

CRC16Util 的实现要点:

  • 算法:CRC16/MODBUS (多项式 0xA001,初值 0xFFFF)。
  • 密钥拼接CRC( crcKey + 报文数据 ),密钥来自配置 netty.protocol.crc-key(十六进制字符串,启动时解析为字节数组)。
  • 校验范围:从 固定头数据域末尾(不含 CRC 两字节)。
  • 字节序:CRC 结果 高字节在前(大端)。

解码器里我用 getBytes 窥视 校验,通过后再移动 readerIndex 顺序读字段------避免读指针乱跑导致 CRC 算错。

排障顺序 (代码注释里也写了):先看长度 → 固定头 → CRC。CRC 失败时我会关连接并把 原始帧 HEX 记入 evc_protocol_message_log,和嵌入式同事对表时极省时间。


5. Netty 接入层:Pipeline、线程、背压

5.1 Pipeline 顺序(不可乱)

NettyTcpServer@PostConstruct 启动,每个 SocketChannel 的 Pipeline:

复制代码
ProtocolDecoder → ProtocolEncoder → ConnectHandler → BusinessHandler
组件 类型 职责
ProtocolDecoder LengthFieldBasedFrameDecoder 子类 粘包/拆包、固定头、长度、CRC、转 ProtocolMessage
ProtocolEncoder MessageToByteEncoder ProtocolMessage → 字节流,回填长度、算 CRC、打出站 HEX 日志
ConnectHandler ChannelInboundHandlerAdapter 连接建立/断开、异常;断开时清理 Channel 与 Redis 归属
BusinessHandler SimpleChannelInboundHandler CRC 复核、注册连接、入队异步处理

5.2 粘包:LengthFieldBasedFrameDecoder 参数

java 复制代码
// 长度字段在偏移 2,长度 2 字节;总帧长 = 4 + length
super(MAX_FRAME_LENGTH, 2, 2, 0, 0);
MAX_FRAME_LENGTH = 1024;

超过 1024 的帧直接拒掉,防止恶意或异常终端撑爆内存。

5.3 三层线程模型(这套设计的核心)

很多人写 Netty 业务时直接在 channelRead 里调 MyBatis,连接一多 EventLoop 就被 JDBC 卡死。我拆了三层:
#mermaid-svg-IbRPozn4NFKMy7iB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IbRPozn4NFKMy7iB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IbRPozn4NFKMy7iB .error-icon{fill:#552222;}#mermaid-svg-IbRPozn4NFKMy7iB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IbRPozn4NFKMy7iB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IbRPozn4NFKMy7iB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .marker.cross{stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IbRPozn4NFKMy7iB p{margin:0;}#mermaid-svg-IbRPozn4NFKMy7iB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label text{fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label span{color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster-label span p{background-color:transparent;}#mermaid-svg-IbRPozn4NFKMy7iB .label text,#mermaid-svg-IbRPozn4NFKMy7iB span{fill:#333;color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .node rect,#mermaid-svg-IbRPozn4NFKMy7iB .node circle,#mermaid-svg-IbRPozn4NFKMy7iB .node ellipse,#mermaid-svg-IbRPozn4NFKMy7iB .node polygon,#mermaid-svg-IbRPozn4NFKMy7iB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .rough-node .label text,#mermaid-svg-IbRPozn4NFKMy7iB .node .label text,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label,#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label{text-anchor:middle;}#mermaid-svg-IbRPozn4NFKMy7iB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .rough-node .label,#mermaid-svg-IbRPozn4NFKMy7iB .node .label,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label,#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label{text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .node.clickable{cursor:pointer;}#mermaid-svg-IbRPozn4NFKMy7iB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .arrowheadPath{fill:#333333;}#mermaid-svg-IbRPozn4NFKMy7iB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IbRPozn4NFKMy7iB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IbRPozn4NFKMy7iB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster text{fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB .cluster span{color:#333;}#mermaid-svg-IbRPozn4NFKMy7iB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IbRPozn4NFKMy7iB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IbRPozn4NFKMy7iB rect.text{fill:none;stroke-width:0;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape p,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IbRPozn4NFKMy7iB .icon-shape .label rect,#mermaid-svg-IbRPozn4NFKMy7iB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IbRPozn4NFKMy7iB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IbRPozn4NFKMy7iB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IbRPozn4NFKMy7iB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Layer 3: Ingest 线程池
Layer 2: businessExecutorGroup
Layer 1: Netty Worker
编解码 + socket 读写
BusinessHandler.channelRead0
ArrayBlockingQueue 5000
50 个 consumer
ProtocolInboundProcessor

层级 配置项 默认值 做什么
Boss/Worker boss-threads / worker-threads 2 / 8 只做网络 I/O
业务 Executor business-threads 32 每 Channel 绑定一个 executor,channelReadexecute 后再处理
入站消费 ingest-queue-capacity / ingest-consumer-threads 5000 / 50 JDBC、应答组包、协议日志

背压InboundMessageIngestService.offer() 队列满返回 falseBusinessHandler 直接 ctx.close()。我的逻辑是:DB 或消费线程跟不上时,宁可让终端重连,也不要在内存里无限堆报文。

出站协议日志也单独做了有界队列(outbound-log-queue-capacity),ProtocolEncoderoffer 失败只打 warn,不阻塞写 socket。

5.4 连接管理:ChannelManager

本机内存维护双向映射:

  • pileId → Channel
  • Channel → pileId
  • pileId → AtomicInteger 序号(0~255 循环)

首次收到某桩合法报文时 registerChannelConnectHandler.channelInactiveremoveChannelpileOwnershipService.unregister


6. 上行全链路:从字节到 MySQL

0x53 车辆电池信息 走一遍完整路径,其它命令字同理。

6.1 时序图

MySQL UploadDataProcessService ProtocolInboundProcessor IngestQueue BusinessHandler ProtocolDecoder 终端 MySQL UploadDataProcessService ProtocolInboundProcessor IngestQueue BusinessHandler ProtocolDecoder 终端 #mermaid-svg-slpcloMKntlPyQrE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-slpcloMKntlPyQrE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-slpcloMKntlPyQrE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-slpcloMKntlPyQrE .error-icon{fill:#552222;}#mermaid-svg-slpcloMKntlPyQrE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-slpcloMKntlPyQrE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-slpcloMKntlPyQrE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-slpcloMKntlPyQrE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-slpcloMKntlPyQrE .marker.cross{stroke:#333333;}#mermaid-svg-slpcloMKntlPyQrE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-slpcloMKntlPyQrE p{margin:0;}#mermaid-svg-slpcloMKntlPyQrE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-slpcloMKntlPyQrE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-slpcloMKntlPyQrE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .sequenceNumber{fill:white;}#mermaid-svg-slpcloMKntlPyQrE #sequencenumber{fill:#333;}#mermaid-svg-slpcloMKntlPyQrE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-slpcloMKntlPyQrE .messageText{fill:#333;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE .labelText,#mermaid-svg-slpcloMKntlPyQrE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .loopText,#mermaid-svg-slpcloMKntlPyQrE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-slpcloMKntlPyQrE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-slpcloMKntlPyQrE .noteText,#mermaid-svg-slpcloMKntlPyQrE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-slpcloMKntlPyQrE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-slpcloMKntlPyQrE .actorPopupMenu{position:absolute;}#mermaid-svg-slpcloMKntlPyQrE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-slpcloMKntlPyQrE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-slpcloMKntlPyQrE .actor-man circle,#mermaid-svg-slpcloMKntlPyQrE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-slpcloMKntlPyQrE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TCP 字节流 拆帧 + CRC ProtocolMessage 注册 Channel + Redis touch offer(work) process(msg, channel) saveInboundAndBind 日志 processBatteryInfo insert vehicle_battery_info update check_order writeAndFlush 0xF3 应答

6.2 ProtocolInboundProcessor:命令分发中枢

所有上行在 switch (commandCode) 里路由,应答组包集中在 sendResponse,避免每个 Service 自己拼帧:

  • 继承请求的 pileIdserialNo
  • 设置应答命令字(如 0xF3
  • 数据域:多数仅 1 字节结果;0xF0/0xF1 额外带 6 字节 BCD 服务器时间

解析失败(IllegalArgumentException)会 markCurrentInboundParseFailed,并对需要应答的命令回 失败结果码

6.3 协议实体:先 parse 再落库

每个 *Req 类提供 static Xxx parse(byte[] data)

  • 入口先校验 data.length,不足直接抛异常,禁止 silent 越界读
  • 偏移用局部变量 offset += n 递增,和协议 PDF 一行一行对齐

ProtocolMessage 还提供 isCrcValid() 二次校验(Handler 里会再打一道),与 Decoder 里 CRC 形成双保险。


7. 车辆电池信息 0x53:字段表与业务串联

7.1 数据域字节布局(至少 51 字节)

偏移 长度 字段 编码 说明
0 6 上报时间 BCD BCDCodecUtil.bcdToLocalDateTime
6 4 枪 ID 大端 Int 完整枪编号
10 10 养护订单号 ASCII 右补空格或定长
20 17 车辆 VIN ASCII 17 位
37 4 公里数 大端 Int
41 1 历史快充占比 Byte 单位 %
42 1 电池类型 Byte 枚举由业务字典解释
43 2 标称电压 Short 分辨率 0.1 V
45 2 电池容量 Short 分辨率 0.1 Ah
47 2 额定总能量 Short 分辨率 0.1 kWh
49 2 预估循环次数 Short

解析代码见 BatteryInfoReq.parse

7.2 落库表 evc_vehicle_battery_info

实体 VehicleBatteryInfo 字段与协议一一对应,并增加:

  • device_no:由 ProtocolDeviceIdUtil.pileIdToDeviceNo(msg.getPileId()) 转换
  • create_time:服务端写入时间

7.3 与检测订单、用户车辆的联动

processBatteryInfo 里除了 insert 电池表,还会:

  1. findOrCreateCheckOrder(orderNo, deviceNo, gunId) --- 保证订单主表存在
  2. 回填 vin_nomileage
  3. 按 VIN 查 evc_user_vehicle,若有则补 手机号、车型

这样管理端查订单时,不必等 0x54 才看到 VIN。

7.4 应答

成功处理后 ProtocolInboundProcessor 发送 0xF3 ,数据域 1 字节:0x00。终端以此确认平台已收妥;若解析失败则 0x01


8. 养护过程 0x55 / 详情 0x54:订单号维度的数据关联

8.1 为什么 0x55 要回查 0x53

协议设计上 0x55 养护过程数据不带 VIN(数据域固定 36 字节,主要是 SOC、充入电量、电芯温度、枪端电压电流等)。平台侧规则:

订单号 查询 0x53 最新一条 VehicleBatteryInfo,回填 VIN 再 insert maintain_process_data

0x54 养护详情同理。这是和协议方一起定的:终端省带宽,平台用订单号串会话

8.2 0x55 数据域摘要(36 字节)

包含:上报时间(BCD)、枪 ID、订单号(ASCII)、养护前/中 SOC、充入电量、充电模式、电芯温差/最高最低温度及编号、枪电压(0.1V)、枪电流(0.01A)、输出功率(0.01kW) 等。详见 MaintainProcessReq

8.3 其它上行命令的业务含义(简表)

命令 业务动作
0x50 更新 evc_device 在线状态、软硬件版本、SIM
0x51 维护 heartbeat_log 最后心跳时间
0x52 更新 evc_device_gun.gun_status(空闲/插枪/充电/故障)

9. 下行指令:HTTP 如何打到 TCP Channel

管理端点「开启养护」→ Controller → Service → 需要 往终端写 0x40同步等 0xE0

9.1 MaintainDownlinkLocalExecutor 流程

  1. channelManager.isPileOnline(pileId) --- 本机是否有活跃 Channel
  2. 组装 ProtocolMessageCMD_MAINTAIN_START + MaintainStartReq.toBytes()
  3. getNextSerialNo(pileId) 生成序号
  4. MAINTAIN_START_RESP_MAP.put(pileId + "_" + serialNo, CompletableFuture)
  5. channel.writeAndFlush(msg).sync()
  6. future.get(timeout) --- 默认 netty.tcp.timeout=10
  7. UploadDataProcessServiceImpl.processMaintainStartResp 收到 0xE0complete Future

结束养护 0x41、升级 0x42 同理,对应 0xE10xE2

9.2 编码器如何组帧

ProtocolEncoder

  1. 写固定头 → 占位长度 → 版本/桩ID/序号/命令字/数据域
  2. 回填长度(版本到 CRC 前)
  3. 对当前 ByteBuf 全文算 CRC 追加
  4. 打 INFO 级 完整下行 HEX(联调神器)
  5. 协议日志异步入队

10. 多副本 K8s:连接在 A Pod,请求在 B Pod

10.1 问题定义

  • TCP:终端被 LoadBalancer 分到某一 Pod,连接粘在该 Pod 的 ChannelManager 里。
  • HTTP:Ingress 把管理端请求打到 任意 Pod

若 HTTP 落在 Pod-B,而连接在 Pod-A,直接 getChannel 必然失败。

10.2 方案一:Redis 桩归属(带 TTL)

NettyPileOwnershipService.touch(pileId)

复制代码
KEY: battery:netty:pile-owner:{pileId}
VALUE: {instanceId}   # 如 Pod 名 metadata.name
TTL: 90s(可配置 ownership-ttl-seconds)

每次收到该桩上行报文刷新 TTL。HTTP 下发前先查 owner:

  • 本机:走 MaintainDownlinkLocalExecutor
  • 它机:NettyClusterDownlinkBridge.requestRemote*

断开连接时 Lua 脚本 仅当 value 等于本 instanceId 才 DEL,防止误删新连接归属。

单实例开发:NETTY_CLUSTER_ROUTING_ENABLED=false,不访问 Redis。

10.3 方案二:Redis Pub/Sub 下行桥

Pod-A Netty Redis Pub/Sub Pod-B HTTP Pod-A Netty Redis Pub/Sub Pod-B HTTP #mermaid-svg-rTp6WuazZJHPkvg2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rTp6WuazZJHPkvg2 .error-icon{fill:#552222;}#mermaid-svg-rTp6WuazZJHPkvg2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rTp6WuazZJHPkvg2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rTp6WuazZJHPkvg2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rTp6WuazZJHPkvg2 .marker.cross{stroke:#333333;}#mermaid-svg-rTp6WuazZJHPkvg2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rTp6WuazZJHPkvg2 p{margin:0;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rTp6WuazZJHPkvg2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .sequenceNumber{fill:white;}#mermaid-svg-rTp6WuazZJHPkvg2 #sequencenumber{fill:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-rTp6WuazZJHPkvg2 .messageText{fill:#333;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 .labelText,#mermaid-svg-rTp6WuazZJHPkvg2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .loopText,#mermaid-svg-rTp6WuazZJHPkvg2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-rTp6WuazZJHPkvg2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-rTp6WuazZJHPkvg2 .noteText,#mermaid-svg-rTp6WuazZJHPkvg2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-rTp6WuazZJHPkvg2 .actorPopupMenu{position:absolute;}#mermaid-svg-rTp6WuazZJHPkvg2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-rTp6WuazZJHPkvg2 .actor-man circle,#mermaid-svg-rTp6WuazZJHPkvg2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-rTp6WuazZJHPkvg2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PUBLISH battery:netty:downlink:{instanceA} JSON onCommandMessage MaintainDownlinkLocalExecutor PUBLISH battery:netty:downlink-ack complete CompletableFuture

RemoteDownlinkEnvelope 携带 correlationIdaction(START/STOP/UPGRADE)、pileIdorderNo、FTP 参数等;ACK 频道 battery:netty:downlink-ack 把成功/失败带回发起方。

10.4 K8s 部署要点(仓库 script/k8s/

Deployment(摘录):

  • replicas: 3
  • 容器端口:8088(HTTP)、9000(Netty)
  • NETTY_INSTANCE_IDmetadata.name每 Pod 唯一
  • NETTY_CLUSTER_ROUTING_ENABLED 来自 ConfigMap

Service

  • evc-http:ClusterIP,给 Ingress
  • evc-nettyLoadBalancer,暴露 9000 给终端(公网/内网 VIP)

注意 :Ingress 只配 HTTP;不要把二进制 TCP 硬塞 Ingress。终端接入走 Netty Service。

当前探针探的是 HTTP 端口------若 Netty 假死而 HTTP 仍响应,Pod 可能不会被重启。生产建议对 9000 做 TCP 探针或独立监控。


11. 协议日志与排障手册

EvcProtocolMessageLogService 统一处理:

场景 行为
上行入站 同步 insert + ThreadLocal 绑定日志 ID,业务结束后更新状态
出站下行 有界队列异步 insert,不阻塞 Encoder
解码失败 saveDecodeFailedInbound 存原始帧 HEX
下行失败 离线、超时、终端返回失败等独立事务记录

业务状态包括:已处理、失败、解析失败、未知命令字等。

11.1 我建议的排障顺序

  1. Netty 是否监听 9000 --- 启动日志 Netty TCP服务启动成功
  2. 终端是否建连 --- ConnectHandlerchannelActive
  3. 是否拆帧成功 --- 解码失败表是否有 HEX
  4. CRC --- 密钥是否与终端一致(netty.protocol.crc-key
  5. 命令字是否命中 switch --- 收到上行报文,命令字 0x..
  6. 业务是否抛 IllegalArgumentException --- 数据域长度/格式
  7. 队列是否满 --- 入站队列已满,关闭连接
  8. 多副本 --- Redis 里 owner 是否指向持有连接的 Pod

11.2 典型日志关键词

复制代码
报文固定头错误
报文长度不匹配
报文CRC校验失败
入站队列已满
本实例电桩不在线
集群下行超时
开启养护等待应答超时

12. 本地运行与配置清单

12.1 环境

  • JDK 21、Maven
  • MySQL、Redis(集群路由开时需要)
  • Profile:localapplication.yaml 默认)

12.2 启动

bash 复制代码
mvn -pl evc-server -am spring-boot:run

主类:com.usteu.evc.server.EvcServerApplication

12.3 Netty 相关配置(application-local.yaml

yaml 复制代码
netty:
  protocol:
    crc-key: ${NETTY_PROTOCOL_CRC_KEY:8ECF1ABF}   # 与终端一致
  cluster:
    routing-enabled: ${NETTY_CLUSTER_ROUTING_ENABLED:false}
    instance-id: ${NETTY_INSTANCE_ID:${HOSTNAME:localhost}}
    ownership-ttl-seconds: 90
  tcp:
    port: 9000
    boss-threads: 2
    worker-threads: 8
    business-threads: 32
    ingest-queue-capacity: 5000
    ingest-consumer-threads: 50
    keep-alive: true
    timeout: 10

12.4 用 TCP 工具自测

可用 nc 或网络调试助手发送 HEX 帧(需自行按协议算 CRC)。更稳妥是写 JUnit 测 BatteryInfoReq.parseCRC16Util.checkCRC,我仓库里已有 BCDCodecUtilTestReconnectInfoReqTest 等,改字段先跑测试再联真机。


13. 我踩过的坑与建议的实施顺序

13.1 坑

  1. 在 Worker 线程里写 SQL --- 初期试过,压测时延迟尖刺明显;必须进 Ingest 或 business 线程池。
  2. 长度字段含义理解反 --- 协议写的是「版本到 CRC」,不是「整帧」;算错会导致永远拆不出帧。
  3. pileId 与 0x50 数据域桩号混用 --- 重连用 HEX 转十进制,其它命令用帧头 Int,文档要写清。
  4. Connection reset 刷 ERROR --- K8s/LB 探测和终端断电都会触发,已降级 WARN。
  5. CompletableFuture 泄漏 --- 下行必须用 finally 从 Map remove,超时也要 markDownlinkFailed。
  6. 多副本忘配 instanceId --- 三个 Pod 如果都用 localhost,Redis 归属会互相覆盖。

13.2 推荐实施顺序(可照着做)

阶段 交付物
1 协议 PDF + 命令字/数据域表 + CRC 样例向量(与终端对齐)
2 纯 Java parse + CRC 单元测试,不碰 Netty
3 Netty 拆帧 + Decoder/Encoder + 打 HEX 日志
4 0x51 心跳 + 0x50 重连 + ChannelManager
5 Ingest 队列 + 0x53/0x55 落库 + 应答
6 0x40 下行 + Future 等应答 + HTTP 接口
7 Redis 归属 + Pub/Sub + K8s 三副本压测
8 协议日志表 + 监控告警

14. 后续演进方向

  • 在线状态机 :心跳、重连、断连统一推导 evc_device.online_status,而不是只靠 Channel 是否存在。
  • 入站优先级:队列满直接断连较粗暴,可按桩 ID 或命令字分队列。
  • 协议日志归档:数据量大后冷热分离或按日分表。
  • 连接规模上万:评估独立接入集群、或 MQTT 网关与 TCP 并存。
  • 安全:TLS over TCP、设备证书、帧级鉴权(当前依赖 CRC + 私网)。

结语

这套 EVC 平台对我而言,难点不在 Spring Boot CRUD,而在 二进制协议、长连接、多副本路由 叠在一起。Netty 负责把字节变成 ProtocolMessage;有界队列和业务线程池负责把 稳定性 握住;Redis 负责在 K8s 里回答「这根 TCP 线连在哪台机器上」。

车辆电池信息(0x53) 只是众多命令字中的一条,但它把 VIN、容量、循环次数和后续 0x55 养护过程 串成一条业务链------协议、解析、订单三处对齐,数据才闭环。


相关推荐
我命由我123451 小时前
BOM 极简理解
运维·经验分享·笔记·物联网·学习·运维开发·学习方法
互联网推荐官2 小时前
上海物联网应用开发技术路径深度解析:协议选型、架构取舍与落地约束
物联网·架构·开发经验·上海
草莓熊Lotso2 小时前
【Linux网络】深入理解 HTTP 协议(一):从基础概念到 URL 编码解码
linux·网络·c++·网络协议·http·软件工程
TDengine (老段)2 小时前
TDengine 数据保留与 TTL — 多级存储、过期删除与分层迁移
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
Kaistar-alice2 小时前
“智慧能源・物联网” 的来龙去脉
物联网·能源
黎阳之光2 小时前
虚实同源·数智治水:黎阳之光视频孪生,重构智慧水务新范式
运维·物联网·算法·安全·数字孪生
程序员Aries2 小时前
tcp-server 项目实现流程、细节与 muduo 对比分析
linux·网络协议·tcp/ip
且听风吟_xincell18 小时前
用 TypeScript 从零写一个 TCP 聊天室(上)—— 网络编程入门实战
网络·tcp/ip·typescript
嵌入式ZYXC19 小时前
第4章:MCU最小系统设计——从一颗光杆芯片到它能跑起来
stm32·单片机·嵌入式硬件·物联网