charging-cabinet/docs/柜控板固件对接文档.md
12451 999885c38f feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复
协议(唯一权威文档:docs/柜控板固件对接文档.md)
- 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc
- dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除)
- id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI
- msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报
- 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login

平台侧实现
- 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status
- 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库)
- 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除
- 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs)
- 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单

修复(均为部署后才暴露的问题)
- 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是
  "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒
- sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上)
- 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic)
- 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换

部署
- 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向)
- 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测)
- 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、
  并用本地 .env 覆盖线上 .env)

质量
- 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约)
- 两服务 cargo check 零错误零告警;web tsc --noEmit 通过
- test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:41:09 +08:00

98 KiB
Raw Blame History

柜控板固件对接文档(TCP 接口)

项目 内容
文档版本 v1.1(2026-09-14)
交付对象 柜控板固件开发工程师
接口定义方 平台侧(本接口由平台方定义,固件按本文实现)
协议性质 已冻结:报文格式、字段名、act 取值、签名算法一经确认不再变更

本文档只描述固件需要实现的内容。平台内部实现(存储、队列、业务逻辑)不在本文范围内。


⚠️ 待确认事项(请硬件侧确认;编号与第 10 节一致)

除下表 7 项外,本文其余内容均已定稿。 确认前固件可按"现状"(即本文当前写法)实现; 我们拿到答复后会同步更新本文并通知。这 7 项都不阻塞 4G 层开工。

# 事项 现状(本文当前写法) 需硬件确认 影响范围
13 energy(单订单累计充电量,Wh) 本协议暂未包含 energy 字段 硬件是否有该数据源?若无,订单电量由平台侧按 电压 × 电流 × 时间 积分计算 §5.5 / §5.6 / §5.7 是否补该字段

关于第 13 项的兜底已落地:平台侧已按"积分计算"实现该兜底 —— 每条 status_post 的通道电压/电流按 60 秒积分累加(协议固定 60 秒一次上报), 订单结束时写入订单电量并生成充电记录。硬件侧无需为此改协议; 若硬件后续确认能提供 energy 字段,平台改为直接采用硬件值即可(不影响报文结构以外的任何东西)。 | 14 | 代码表的取值内容 | 仅知 厂家代码 01=星恒、电池型号 01=磷酸铁锂 | ① 四个字段(厂家代码 / 电池型号 / 电芯材料 / 电芯节数)的完整取值表 ② 「电芯材料」与「电池型号」是否写反("型号"的值给的是"磷酸铁锂",那是材料) ③ 代码几位、几进制(1 还是 01) | 仅影响 §5.5 代码字段能否显示为中文名 | | 15 | 电芯数据的字节数 / 项数 | 硬件文档写「电芯数据 52 个字节」,但其示例为「20 节电芯(20×2)」,按字段算 20×2+2+2+2+2 = 48 | 到底是 48 字节 / 20 项,还是 52 字节 / 22 项,或 52 字节里含未列出的 4 字节字段? | §5.5 cells 的解析与缓冲分配 | | 16 | 仓控板离线(485 芯片坏 / 线松 / 板子掉电)如何上报? | 本文暂未定义:4G 层读不到该板数据时该怎么报还没有约定(省略这块板?照报一块数值全 0 的板?还是另有在线/离线标志?)。硬件字典的「柜体状态1/2」只覆盖柜级(断电/烟/水/倾倒/门/风扇/接触器/喷淋/声光/门超时),没有"仓控板通信状态" | 硬件侧能提供什么形式的"仓控板在线/离线"信息?例如:
① 每块仓控板一个在线位(如放在 boards[] 里)
② 柜体数据里加一个「仓控板离线位图」(15 块板各一位)
③ 柜控板给出"应在线 N 块 / 实际失联 M 块"
④ 或明确回答:硬件无法检测
并请说明:板离线时 4G 层是省略该板、还是照报全 0 的板? | §5.3 status_post 需新增字段;平台「仓控板故障」告警完全依赖它 | | 17 | 直流停电是否还需要 pow_fail_dc 这条指令? | 现在直流停电有两条路径:① 事件指令 pow_fail_dc(接触器分闸时抢发,见 §5.4.1)② status_post 的 cabinet.status1 bit0「断电」(每 60 秒一个状态位)。而直流停电时柜控板本身仍有电,状态位理论上足以表达 | 直流停电能不能只用 status1 bit0 表达、取消 pow_fail_dc 这条指令?
请确认:接触器分闸时,status1 bit0 是否会置 1?
· 若会 → pow_fail_dc 冗余,建议删掉(协议少一条 act)
· 若不会(bit0 只表示外部市电断)→ pow_fail_dc 必须保留 | §5.4.1 / §4.1 / §10 / 附 A.2.2;平台停电状态的数据来源 | | 18 | 人在没充满时把电池直接拔走(强制拔出)能否检测? | §5.6 已把「无论是否充满,被拔时必须先 off 再 bat_out」列为硬性要求;但未充满时人为直接拔走,MCU 不一定能感知 | ① MCU 能否检测未充满时的强制拔出(电池状态 bit7 由 1→0)?
② 检测到后能否保证先发 off 再发 bat_out?(平台订单正确结束依赖它)
③ 若检测不到,请说明——平台只能靠 status_post bit7 归零或 10 小时订单超时兜底 | §5.6;订单能否正常结束 | | 19 | 停电恢复后如何兜底? | 停电期间设备完全失电,期间发生的电池拔出/插入等事件无法上报;恢复后设备重启,平台侧会留下不一致(例如挂着未结束的订单、台账里的电池已不在柜内) | 停电恢复后硬件侧如何处理?例如:
① 设备恢复后补报停电期间发生的事件?
② 还是以当前实际状态为准——重连后发一次全量 status_post,由平台自行对账?
③ 或硬件不做特殊处理(平台侧靠 10 小时订单超时兜底) | §5.5 / §5.6 订单;掉电期间的状态对账机制 |

关于第 14 项:「代码 → 名称」的映射责任已定稿 —— 4G 层按字符串原值透传(不补零、不转义、不改写),映射在平台侧完成(见第 10 节第 7 条)。 所以第 14 项只缺"取值内容",不改变 brand_code / model_code / material_code 的字段格式,不阻塞 4G 层实现。


1. 接入信息

项目 值
连接方式 裸 TCP 长连接(无 TLS、无 WebSocket)
角色 柜控板 = client,平台 = server;由柜控板主动发起连接
测试环境 118.31.119.206:3001
正式环境 118.31.119.206:9001
传输编码 UTF-8

⚠️ TCP 由平台侧TCP 服务(device-server)直接监听端口,不经过 nginx、也不使用域名。 请直接连接上表的 IP:端口;app.anzhizhichong.com / test.anzhizhichong.com 只用于 HTTPS(Web/API),与 TCP 无关。 联调请先连测试环境 118.31.119.206:3001。


2. 通信基础

2.1 帧格式

  • 一条报文 = 一行紧凑 JSON + LF(\n,0x0A) 结尾。
  • 平台同样以 JSON + \n 下行。
  • 单行最大 65536 字节(64 KB)(不含 LF)。超出会导致平台断开连接,固件需重连。
  • JSON 内禁止出现换行;键名一律 snake_case(小写 + 下划线)。
  • 不需要发送空行。
  • 平台按 LF 切分,天然处理 TCP 粘包/分包,固件侧按同样规则处理即可。

2.2 连接建立后的第一步

连接建立后,第一条报文必须是 auth_str(本地无安全码时)或 login(本地已有安全码时)——这两条有先后关系,顺序不可颠倒:

设备本地状态 第一步必须发 之后
无安全码(首次开机 / 换板 / 安全码被清) auth_str 收到安全码 → 持久保存 → 紧接着发 login
已有安全码(重启、断线重连) login —
  • 首次开机必须先 auth_str:拿到安全码并保存后,才能发 login。本地没有安全码时无法计算 sign,因此固件不可能、也不应该发送 login。
  • 因此平台对 login 只做签名比对:与会话中保存的安全码一致 → 成功;不一致 → invalid signature。平台无法判断设备本地是否持有安全码,也不需要判断。
  • 安全码必须持久保存、掉电不丢。
  • 平台在收到 auth_str / login 时把 TCP 连接与 dev_id 绑定,并且只认这条连接上出现的第一个 dev_id。一条 TCP 连接只服务一个 dev_id。

⛔ 登录门控:除 auth_str / login 外,其余指令必须先 login 成功

连接状态 允许的 act 其余 act 的处理
未登录(连接刚建立,尚未 login 成功) 只有 auth_str、login 平台一律丢弃,且不回任何应答
已登录(收到 suc=1 且带 spec 的 login 应答后) 全部 act 正常处理
  • 未登录连接尚未与 dev_id 建立可信绑定,平台无法路由应答,所以被丢弃的报文不会收到任何回应(固件表现为"发了没反应")。
  • ⚠️ 未登录的 TCP 连接平台不予保持:连接建立后 30 秒内未完成 login 成功,平台会主动断开。固件应在建连后立即发 auth_str / login,不要空闲等待。
  • 设备侧表现:连上后马上 auth_str/login → 收到 suc=1 → 才发 status_post 等业务报文。

login 失败的分类处理(含自愈,必须实现)

设备收到 login 应答后,必须按 err 分类处理,不能一律重试:

login 应答 含义 设备动作
{"suc":1,"spec":"0606","work_mode":"0"} 登录成功 进入正常业务流程
{"suc":0,"err":"invalid signature"} 登录失败(唯一一种失败应答)。平台刻意不区分失败原因 重新发 auth_str 申领新码 → 保存 → 重新 login(详见下方说明)

⛔ 平台对 login 失败只返回一种 err:invalid signature。 不区分是"安全码不一致"、"时间戳超窗"还是"平台无该设备记录"——这是有意设计,避免向外部泄露判定细节。 因此固件不要去猜失败原因。

失败后的处理顺序(固件必须实现)

  1. 重连(平台发出失败应答后会立即断开,见下方)
  2. 重连后先发 auth_str 申领新码:
    • 拿到新码 → 保存 → login
    • 返回 auth_str already issued(说明码没被重置)→ 停止重试,用本地已有安全码 login
  3. 若仍持续失败,检查本地时钟是否准确(时间戳超窗也会表现为 invalid signature);建议设备由 4G 网络校时,避免因时钟漂移导致永远登录不上

重新申领的边界

  • 设备未被重置、且平台仍持有安全码时,请求 auth_str 返回:
{"act":"auth_str","msg_id":N,"suc":0,"err":"auth_str already issued"}

此时停止重试,改为按重连间隔周期性重试 login,等待平台管理员重置。

  • 重新申领有频率限制:同一 IMEI 5 次 / 5 分钟,超限返回 {"suc":0,...,"err":"rate limit exceeded"},需退避后重试。

⚠️ login 失败后平台会主动断开 TCP 连接

login 返回 suc=0 之后,平台会立即断开该 TCP 连接。设备必须:

  1. 重新建立 TCP 连接(msg_id 在新连接上重新计数)
  2. 按上面的"失败后的处理顺序"继续(先 auth_str,或直接 login)
  • 连接被断开后不要在旧连接上继续发报文。
  • 重连建议按退避间隔递增(10s / 30s / 60s / 300s,见 8.3),避免 login 持续失败时高频重连。
  • 该断开是平台行为,不是网络故障;设备不应将其计入网络异常统计。

平台重置安全码后的预期行为(自愈闭环)

管理员在后台点「重置安全码」
   ↓ 平台立即用新码替换旧码(旧码立即失效)
设备仍用本地旧码 login → 收到 {"suc":0,"err":"invalid signature"}
   ↓ 平台主动断开 TCP 连接
设备重新连接
   ↓ 按上表触发重新申领
设备发 auth_str → 收到 {"suc":1,"auth_str":"<新码>"}
   ↓ 持久保存
设备用新码 login → {"suc":1,"spec":"0606","work_mode":"0"} 登录成功

全程无需人工到现场操作设备。

2.3 保活 —— 由 status_post 承担(无独立心跳包)

  • 本协议不设独立的心跳 / PING 包。status_post 即保活报文:平台对每条上行都会回 {"act":"status_post","msg_id":N,"suc":1} 作为存活确认。
  • 平台以「300 秒内是否收到过该设备的任意报文」判定在线 / 离线(依据 Redis device:{imei} 的 TTL)。
  • 上报节奏:固定每 60 秒一次 status_post,每次全量上报(不区分空闲 / 充电状态,不做增量——每次携带全部仓控板及其全部通道的状态)。该间隔远小于 300 秒的离线判定阈值,同时保证 4G 网络的 NAT 映射不被回收。
  • 设备不得自行增加自定义心跳指令:act 取值是本协议的一部分,平台只识别清单内的 act,其余一律回 {"suc":0,"err":"unknown act"}。

3. 报文通用规则

3.1 字段总表

字段 类型 出现位置 说明
act string 双向全部报文必带 动作类型
msg_id uint32 双向全部报文必带 交互号,见 3.4
dev_id string 仅 auth_str / login 必带(用于把 TCP 连接与设备绑定) 柜控板 IMEI,15 位十进制。
其余报文一律不带 —— 连接建立后设备身份即由该 TCP 连接确定(见 2.2)
suc uint8 仅应答 1=正常,0=异常
err string 仅 suc=0 的应答,必带 异常原因
id string 见各指令 寻址标识(动作目标),不含 IMEI,见 3.7
timestamp uint64 仅 login 秒级 Unix 时间戳
sign string 仅 login 8 位小写十六进制签名
auth_str string 仅 auth_str 的应答 8 位安全码 [A-Za-z0-9],仅下发一次,须持久保存
spec string(4 位) 仅 login 的应答 柜子规格:前 2 位 = 仓控板数、后 2 位 = 每板通道数。"0606" = 6 板 × 每板 6 通道
work_mode string 仅 login 的应答 充电工作模式:"0"=平台指令模式(默认)/ "1"=自动充电工作模式。平台侧可配置,见 5.2
rssi int32 status_post 4G 信号强度,整数 0–99,数值越大信号越好(不是 dBm,不可为负)
cabinet object status_post 柜体状态块,见 5.3
boards array status_post 仓控板及通道状态(全量),见 5.3
battery object bat_in/bat_out/off 电池数据块,见 5.5 / 5.6 / 5.7
iccid string login(可选) SIM 卡 ICCID

平台会忽略未定义的字段;固件解析下行报文时也必须忽略未知字段(平台应答中会额外附带 id)。

3.2 act —— 双向必带

  • 上行:缺少 act 的报文会被平台丢弃,且不返回任何应答(固件会一直等不到回复)。
  • 下行:平台的应答同样带 act,值为被应答报文的 act。

3.3 suc / err —— 只有两个态

  • suc = 1:正常。不带 err。
  • suc = 0:异常。必须同时给出 err 说明原因。
  • 只有应答带 suc;请求与指令都不带。
  • err 一律为英文(小写短语,无标点)。固件请按字符串精确匹配,不要做翻译或模糊匹配。

err 取值全集(平台侧下发)

err 含义 固件应做什么
unknown act act 不在本协议清单内 检查报文;不得自定义新指令
invalid signature login 失败的唯一应答(平台刻意不区分:安全码不一致 / 时间戳超窗 / 无记录) 重连 → 先 auth_str;若返回 already issued 则停止重试并检查本地时钟
auth_str already issued 安全码已下发过(未重置),拒绝重复申领 停止重试,用本地已有的码 login,等管理员重置
rate limit exceeded auth_str 申领超过 5 次 / 5 分钟 退避后重试
invalid id id 格式非法(仓体级段数 ≠ 2 等) 修正 id 格式后重发;不要重试同一错误报文

设备侧回执报错时(如 open 执行失败),err 也请用英文短语,例如 door lock not engaged。

示例:

// 平台 → 设备(应答)
{"act":"login","msg_id":2,"suc":1,"spec":"0606","work_mode":"0"}
{"act":"login","msg_id":2,"suc":0,"err":"invalid signature"}
// 设备 → 平台(回执:suc=0 时必须带 err)
{"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}

3.4 msg_id —— 配对键

规则 说明
作用域 单个 TCP 连接内。连接重建后重新计数
计数 两个方向各自独立计数,都每连接从 1 起自增(0 不使用)
唯一性 同一连接内、同一方向不允许两个未完成交互使用同一个号
方向区分 不靠号段 —— 判断一条下行报文是"应答"还是"新指令",只看有没有 suc(见 3.5),与编号大小无关
配对 应答的 msg_id 必须等于被应答报文的 msg_id(配对还需 act 一致)
归属 平台下发的指令,其 msg_id 由平台分配,固件原样回带,不要自己改号

⚠️ 同一个数字在两个方向同时出现是正常的:例如设备发出的 login 用 msg_id=2,平台随后下发的 on 也可能用 msg_id=2——两者互不干扰,因为方向不同。 固件只需在自己的「未完成请求表」里按 msg_id(+ act)查自己发出去的号;收到下行报文时先看有没有 suc,再决定它是"应答"还是"新指令"。

3.5 报文性质判定 —— 看有没有 suc(关键)

固件收到任何下行报文后,按下表判定:

下行报文 判定 固件动作
带 suc 这是平台对固件某条请求的应答 在「未完成请求表」中按 msg_id 且 act 一致配对;命中则完成并清除。应答到此结束,不要再回包
不带 suc 这是平台新下发的指令 执行指令 → 回执(act 与 msg_id 原样回带)
带 suc 但 msg_id 在表中查不到 迟到的应答(本地已超时丢弃) 丢弃,不做任何响应

平台侧对上行报文采用完全相同的判据:设备的回执带 suc → 是回执;设备的请求(status_post/bat_in/…)不带 suc → 是新请求。

✅ 该判据不依赖编号大小,因此两个方向的 msg_id 即使取到相同数字,指令也不会被误当成应答。 ✅ 配对必须同时校验 msg_id 与 act(两者都与挂起项一致才算命中)。仅凭 msg_id 配对,在两个方向编号重叠时可能把别的交互的应答错配到自己的请求上。 ⛔ 禁止只凭"有 act"判定——平台应答也带 act(见 3.2)。 ⛔ 收到应答后不要再回包(双方都遵守,链条才是单向的;若双方都对"应答"再回包,会互相应答形成回环)。

3.6 超时 —— 每个交互 60 秒

发送方对每一条发出的报文计时 60 秒,超时未收到对应应答即作废该交互。双向都适用。

角色 等待什么 时限 超时后动作
固件 自己发出的每条请求的应答 60 秒 丢弃该 msg_id 的等待,不重试
平台 自己下发的每条指令的回执 60 秒 丢弃该 msg_id 的挂起项,不重传,记 WARN

超时后即使应答到达,也一律丢弃

  • 该 msg_id 已作废:之后收到的应答不再配对、不补入挂起表,直接丢弃
  • 双方都不得因迟到的应答而回滚、重做或补记业务动作
  • 已作废的 msg_id 不得立即复用:否则迟到应答可能撞上复用后的新交互。固件应让 msg_id 单调自增,回绕前确保旧号已彻底作废
  • 配合 3.5 的 msg_id + act 双条件配对,可进一步避免误配

固件侧的隐含义务:60 秒内必须回执

平台对每条下发的指令只等 60 秒。固件收到指令后须在 60 秒内完成执行并回执,否则平台判超时,该次操作在管理端显示为"已下发·超时未确认"。

超时 ≠ 未执行

  • 固件侧:超时只说明"没等到应答",平台可能已收到并处理(例如 bat_in 已登记台账、on 已建单)→ 不得据此重发同一业务动作
  • 平台侧:超时只说明"没等到回执",柜子可能已经执行(例如门已开)→ 管理端不得据此断言失败

3.7 id —— 寻址标识

id 只表示"动作目标",不含 IMEI —— 设备身份由 TCP 连接确定(见 2.2),所以不必在每条报文里重复。

动作粒度 id 格式 示例
柜级(open / pow_on / pow_off,及 reset 整柜) 不带 id(省略该字段) —
单仓控板级(仅 reset) 仓控板(一段) "01"
仓体级(bat_in / bat_out / on / off) 仓控板-通道(两段) "01-3"
  • 仓控板地址:1–15(当前 36 仓柜型实装 6 块;下发时按 2 位补零,如 01)
  • 通道号:1–8(当前 36 仓柜型每板 6 通道;不是 0–7);不补零,如第 3 通道写 3
    • 平台解析对 03 这类补零写法也兼容,但固件请统一按不补零发
  • 段数规则:不带 = 柜级、一段 = 仅 reset 的单板重启、两段 = 仓体级
    • 仓体级 id 段数必须恰好为 2,否则平台返回 {"suc":0,...,"err":"invalid id"}

3.8 状态字节(hex)解析表

以下 4 个字段一律用 hex 字符串表示:cabinet.status1 / cabinet.status2 各 2 位; batt_status / pow_status 是通道 18 字符 hex 里的 byte0 / byte1(见 5.3)。一个字节按位承载多个状态。

cabinet.status1(柜体状态1)

位 掩码 =0 =1
bit0 0x01 正常 断电
bit1 0x02 正常 烟雾
bit2 0x04 正常 水位
bit3 0x08 正常 倾倒
bit4 0x10 柜门关闭 柜门打开
bit5 0x20 加热风扇关闭 加热风扇打开
bit6 0x40 接触器导通 接触器断开
bit7 0x80 喷淋关闭 喷淋打开

cabinet.status2(柜体状态2)

位 掩码 =0 =1
bit0 0x01 正常 声光报警
bit1 0x02 正常 柜门打开超时
bit2–bit7 — 硬件保留,平台忽略 —

「柜门打开超时」的判定阈值由硬件侧定义(硬件文档未给出具体秒数),平台只做呈现,不自行判定。

batt_status(电池状态)

位 掩码 =0 =1
bit0 0x01 电池不满 充满
bit1 0x02 停止充电 正在充电
bit4 0x10 正常 过欠压
bit5 0x20 正常 过流
bit6 0x40 正常 热失控
bit7 0x80 无电池 有电池
bit2–bit3 — 硬件保留,平台忽略 —

pow_status(电源状态)

位 掩码 =0 =1
bit0 0x01 电源正常 电源故障
bit1 0x02 电源关闭 电源打开
bit2 0x04 允许打开 禁止打开
bit7 0x80 无电源 有电源
bit3–bit6 — 硬件保留,平台忽略 —

解析示例

字段 hex 二进制 解析结果
任意 "00" 0000 0000 全 0:正常 / 无电池
batt_status "82" 1000 0010 bit7 有电池 + bit1 正在充电
pow_status "82" 1000 0010 bit7 有电源 + bit1 电源打开
cabinet.status1 "82" 1000 0010 bit7 喷淋打开 + bit1 烟雾报警
cabinet.status1 "10" 0001 0000 柜门打开
cabinet.status2 "01" 0000 0001 声光报警
batt_status "90" 1001 0000 bit7 有电池 + bit4 过欠压

保留位不做要求:硬件填 0 或 1 平台都忽略,固件不必为保留位纠结。


4. 指令清单

4.1 上行(柜控板 → 平台)

⛔ 登录门控:下表中只有 auth_str / login 可以在未登录状态发送;其余 act 必须先 login 成功,否则平台丢弃且不回任何应答(见 2.2)。

# act 说明 是否必带 id 需要应答
1 auth_str 首次获取安全码 否 ✅
2 login 登录验签 否 ✅
3 status_post 周期状态上报(每 60 秒全量:柜体 + 所有仓控板 × 全部通道) 否 ✅
4 pow_fail_dc / pow_fail_ac 停电上报(两条独立指令:直流 / 交流,见 5.4) 否 ✅
5 bat_in 电池插入(属性 + 动态首帧 + 电芯数据) 是 ✅
6 bat_out 电池拔出(末帧动态) 是 ✅
7 on 开始充电 —— 两种形态:① 不带 suc = 自动充电工作模式下设备自发上报(需应答)② 带 suc = 对下行 on 的回执(不回包) 回带原 id 视形态
8 off 两种形态:① 不带 suc = 充满自动停止(设备新请求,需应答)② 带 suc = 对下行 off 的回执(不回包) 回带原 id 视形态
9 open/pow_on/pow_off/reset 下行指令的执行回执 回带原 id ❌ 回执不回包(见 3.5)

回执类上行(第 7、9 条,及第 8 条的 suc 形态)平台一律不回包,固件发出即结束,不要等待应答。

4.2 下行(平台 → 柜控板)

act 说明 id 粒度 前置条件
on 开始充电 仓体级 需设备在线(隐含已 login)
off 停止充电 仓体级 需设备在线(隐含已 login)
open 柜门解锁(下发后由人拉开,门不会自动开) 柜级 需已 login 成功
pow_on 接触器合闸 柜级 需已 login 成功
pow_off 接触器分闸 柜级 需已 login 成功
reset 重启整柜 / 单仓控板 柜级(不带 id)/ 仓控板(一段) 需已 login 成功

未 login 成功前,平台不会下发 open/pow_on/pow_off/reset(平台侧直接拦截,固件收不到任何报文)。

⚠️ 还有一道「设备在线」前置:平台侧要求该 IMEI 的设备节点在线(即 device-server 侧存在已登录的连接),否则一律不下发,管理端会提示「设备不在线」。离线时的表现同样是"固件收不到任何报文"。

⚠️ 柜门是整柜共用的,只有一扇:open 作用在整柜的柜门上,对应 cabinet.status1 的 bit4。 本协议没有"按仓体(舱门)开门"的指令,也没有"关门"指令。

pow_on / pow_off —— 远程通断柜内总电(接触器)

指令 动作 后果
pow_on 接触器合闸 整柜上电(含各仓控板),仓体/电池数据恢复
pow_off 接触器分闸 整柜下电:仓控板随之下电 → 该柜所有仓体/电池数据不可用;设备会因此上报 pow_fail_dc(直流停电,见 5.4.1)
  • 这是运维动作(检修 / 停用 / 复位总电),不是充放电业务,与柜门、单仓充电是不同层级
  • pow_off 期间该柜无法充电、仓体状态不可读,恢复需 pow_on
  • 接触器当前状态由 status_post 的 cabinet.status1 bit6 体现(0=导通 / 1=断开)

柜门的完整闭环(开门 = 平台指令,关门 = 人为动作)

平台下发 open  →  柜控板【解锁】  →  人拉开柜门  →  cabinet.status1 bit4 = 1
                                                        (最迟 60 秒内随 status_post 上报)
人手动关闭柜门  →  cabinet.status1 bit4 = 0
                        (最迟 60 秒内随 status_post 上报,平台据此得知已关门)
  • open 是解锁动作:门不会自动打开,仍需人拉(下发后未拉开,bit4 保持 0,属正常)
  • 关门不在平台侧:人关闭,平台只通过 status_post 感知,最大延迟 60 秒
  • 因此平台侧"柜门状态"永远来自 status_post,不要把它当成实时量

5. 上行报文详情

5.1 auth_str —— 首次获取安全码(重置后可再次获取)

→ {"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}
← {"act":"auth_str","msg_id":1,"suc":1,"auth_str":"Ab3xY9Qz"}
  • 返回 8 位安全码,字符集 [A-Za-z0-9]。固件需存入非易失存储,用于后续 login 签名。
  • 只能获取一次。已申领过之后再请求,返回:
← {"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"}
  • 要再次获取,必须由平台管理员在后台执行「重置安全码」。重置后旧安全码立即失效,设备可再申领一次新码。
  • 收到「auth_str already issued」时,固件应停止重试,继续用本地已保存的安全码 login;若 login 也失败,则按重连间隔周期性重试,等待管理员重置。
  • 频率限制:同一 IMEI 5 次 / 5 分钟,超限返回:
← {"act":"auth_str","msg_id":1,"suc":0,"err":"rate limit exceeded"}

⚠️ 若该 IMEI 未在平台登记(未建档的柜子、或非法设备):平台不发码、不写缓存、不回任何包(属 8.1 的静默丢弃)。 合法设备必然已在平台建档(录入 IMEI),因此正常固件不会遇到这种情况;若遇到,说明设备未录入或 IMEI 写错了。

5.2 login —— 登录验签

→ {"act":"login","msg_id":2,"dev_id":"864720562557021","sign":"269c249d","timestamp":1750000000,"iccid":"8986012520983901841"}
← {"act":"login","msg_id":2,"suc":1,"spec":"0606","work_mode":"0"}

login 成功应答的字段

字段 类型 说明
suc int 1 = 登录成功
spec string(固定 4 位) 柜子规格:前 2 位 = 仓控板数量,后 2 位 = 每块板的通道数。"0606" = 6 板 × 每板 6 通道(即 6 块仓控板、每板 6 仓)。由平台按该柜配置下发
work_mode string 充电工作模式(平台侧可配置,仅这两种、不再扩展):
"0" = 平台指令模式(默认)——充电由平台下发 on 指令发起,设备执行后回执
"1" = 自动充电工作模式——设备检测到电池后自行开始充电,并主动上报 on(不带 suc)

固件必须按 spec / work_mode 行事

  • spec:平台对该柜的规格定义(即柜子型号),如 "0606" = 6 块仓控板 × 每板 6 仓。 它与仓控板实际能否通信无关 —— 某块板扫不到(485 芯片坏 / 线松)属于硬件故障,不由本字段表达。

  • work_mode:

    • "0" → 等平台 on 指令再开始充电;收到后执行并回执
    • "1" → 检测到电池即自行开始充电,并主动上报 on(不带 suc);平台仍可能下发 on,此时按回执处理
    • 每次 login 都以本次应答为准(平台改了配置,重连后即生效)
  • timestamp 为秒级(不是毫秒),与平台时差须在 ±60 秒内。

  • iccid 可选;上报后平台自动比对,SIM 卡更换会被记录。

  • 平台只有在收到 suc=1 且含 spec 的应答后,才把该连接视为已登录(敏感指令下发的前提)。

  • 平台对 login 只做签名比对:与会话中保存的安全码一致 → 成功;否则 → invalid signature。平台不判断、也无法判断设备本地是否持有安全码。

  • 失败应答只有一种:

场景(平台内部逻辑,不对外区分) err 固件动作
时间戳超出 ±60 秒 / 签名与平台会话中的安全码不一致 / 平台 DB 中没有该设备的安全码记录 一律 invalid signature 重连 → 先 auth_str 申领新码;若返回 already issued → 停止重试并检查本地时钟

⛔ 为什么不区分:若分别返回"时间戳无效 / 签名错误 / 无记录",外部就能据此试探系统状态(例如判断某 IMEI 是否已登记、安全码是否被重置)。统一返回 invalid signature 是刻意的安全设计,不要改动。

⚠️ login 返回 suc=0 后,平台会立即断开该 TCP 连接(见 2.2)。设备需重新建连后再走上面的固件动作。

签名算法:见第 6 节(含可直接用于固件单元测试的自测向量)。

5.3 status_post —— 定时状态上报(每 60 秒全量)

每 60 秒上报一次,每次全量:携带所有仓控板及其全部通道的状态,不做增量、不分片。空仓也要上报(byte0 = 00 无电池,byte1 照实报电源状态,数值全 0)。

→ {"act":"status_post","msg_id":3,"rssi":85,
   "cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500},
   "boards":[
     {"board":1,"channels":["828255152c01f40143","008200000000000000",
                            "828255152c01f40143","008200000000000000",
                            "828255152c01f40143","008200000000000000"]},
     {"board":2,"channels":["008200000000000000","008200000000000000",
                            "008200000000000000","008200000000000000",
                            "008200000000000000","008200000000000000"]}
   ]}
← {"act":"status_post","msg_id":3,"suc":1}

上例中 "008200000000000000" = 空仓:byte0 00 无电池;byte1 82 有电源 + 电源打开(电源状态与有没有电池无关);SOC/电压/电流/温度全 0。

顶层字段

字段 类型 说明
rssi 整数 4G 信号强度,0–99,数值越大越好(不是 dBm、不可为负)
cabinet object 柜体状态,柜级、每条报文只带一次
boards array 所有仓控板;每项含 board(地址 1–15)与 channels(该板全部通道)

cabinet 块(对应硬件「柜体数据」8 字节)

字段 类型 说明
status1 hex 字符串(2 位) 柜体状态1 字节,位定义见 3.8
status2 hex 字符串(2 位) 柜体状态2 字节,位定义见 3.8
temp 整数 柜体温度,硬件 1K ×100 → K×100(29800 = 298.00K)
meter 整数 电表读数,硬件 0.1kWh ×100 → Wh(125500 = 125.5 kWh)

channels —— 每通道 18 个 hex 字符(9 字节),原样透传

一个通道 = 一个仓(可能空着、也可能插着电池)。不拆字段、直接拼接 hex——4G 层把硬件那 9 个字节按顺序拼成 18 个 hex 字符即可,无需做任何转换。

项 规则
形式 字符串数组,每个元素 = 恰好 18 个 hex 字符
大小写 平台解析不区分大小写(0e10 与 0E10 等价);建议固件统一小写
空仓 byte0 = 00(无电池);byte1 照实报电源状态(电源与是否有电池无关);byte2–8 数值全 0。
例:空仓但电源正常 → "008200000000000000"
⚠️ 两个状态相互独立 电池状态(byte0)与电源状态(byte1)是两回事,平台前端也分两个指示分开显示——不要因为"没电池"就把 电源状态 写成 0
顺序 数组下标 = 通道号:第 1 个元素 = 通道 1,第 2 个 = 通道 2 ……
长度 数组元素个数 = 该板通道数(当前 36 仓柜型为 6);顺序不得打乱
端序 多字节字段一律大端(高字节在前)

每 18 个字符的字节布局(与硬件「充电动态数据」9 字节逐字节一致)

字节位(hex 字符位) 含义 硬件单位 平台解析
byte0(第 1–2 字符) 电池状态 — 位定义见 3.8 batt_status
byte1(第 3–4 字符) 电源状态 — 位定义见 3.8 pow_status
byte2(第 5–6 字符) SOC 1% ×10 → ‰
byte3–4(第 7–10 字符) 充电电压 0.01V,大端 ×10 → mV
byte5–6(第 11–14 字符) 充电电流 0.01A,大端 ×10 → mA
byte7–8(第 15–18 字符) 电芯最高温度 1K,大端 ×100 → K×100

解析示例(取自硬件文档的示例值)

"828255152c01f40143"
 82       电池状态 = 有电池 + 正在充电(bit7 + bit1)
 82       电源状态 = 有电源 + 电源打开(bit7 + bit1)
 55       SOC     = 85%
 152C     电压    = 5420 × 0.01V = 54.20V
 01F4     电流    = 500  × 0.01A = 5.00A
 0143     温度    = 323K

cabinet.status1 / cabinet.status2,以及通道 hex 里的 byte0 / byte1(电池状态、电源状态)——这 4 个字节的位定义与解析示例统一见 3.8 状态字节(hex)解析表。

⚠️ 仓控板离线如何上报 —— 待硬件侧确认(待确认事项第 16 项) 若某块仓控板的 485 通信中断(芯片坏 / 线松 / 板子掉电),4G 层将读不到该板的任何数据。 这种情况下 4G 层应如何上报,本文暂未定义 —— 例如:省略这块板?照报一块数值全 0 的板?还是另有在线/离线标志? 平台能否把"某块板离线"与"该板通道都空着"区分开,完全取决于硬件侧怎么报 —— 以硬件答复为准,本文不做任何预设。 在硬件答复前,平台侧「仓控板故障」这类告警不做实现。

5.4 pow_fail_dc / pow_fail_ac —— 停电上报(两条独立指令)

停电上报是两条独立的 act,没有 info 字段——看到 act 就知道是哪种停电。

5.4.1 pow_fail_dc —— 直流停电

→ {"act":"pow_fail_dc","msg_id":4}
← {"act":"pow_fail_dc","msg_id":4,"suc":1}
  • 场景:柜内接触器分闸(含平台远程 pow_off)→ 仓控板下电
  • 特征:柜控板本身仍有电,可正常上报,不依赖电容

⏳ 这一条是否还需要,待硬件确认(见文首待确认事项第 17 项): 直流停电时柜控板仍有电,而 status_post 的 cabinet.status1 **bit0「断电」**本身就是一个状态位—— 若接触器分闸时 bit0 会置 1,则本指令与 bit0 重复,可考虑取消。

5.4.2 pow_fail_ac —— 交流停电

→ {"act":"pow_fail_ac","msg_id":5}
← {"act":"pow_fail_ac","msg_id":5,"suc":1}
  • 场景:柜控板自身断电(市电 380V 断)→ 靠板载电容余电抢发这最后一条
  • 特征:与接触器无关;发完柜子即完全失电

两条的共同规则

项 说明
字段 只需 act / msg_id,无附加字段(设备身份由连接确定)
性质 事件上报(一次性,非周期):平台收到即记录,并置为该柜的停电状态(pow_fail_dc / pow_fail_ac),前端显示;不与其他业务逻辑联动
时机 检测到停电的瞬间立即发出
应答 {"act":"<同请求>","msg_id":N,"suc":1}(pow_fail_ac 发出后设备可能已失电,收不到应答属正常)
恢复 供电恢复后消除停电状态(前端不再显示)

平台内部映射(act 名与字段名一一对应):pow_fail_dc → Redis device:{imei}.pow_fail_dc;pow_fail_ac → Redis device:{imei}.pow_fail_ac。

5.5 bat_in —— 电池插入

电池插入时上报一次,携带电池组属性(硬件 38 字节)+ 动态数据首帧(硬件 9 字节)+ 电芯数据。 (注:这里的"电池组属性"是硬件文档的表名,指"插在该通道里的这块电池"的属性;status_post 里的通道状态才是按通道组织。)

→ {"act":"bat_in","msg_id":5,"id":"01-3",
   "battery":{
     "battery_id":"SN123456",
     "brand_code":"1","model_code":"1","material_code":"1",
     "cell_count":15,"rated_voltage":48000,"rated_capacity":24000,
     "info":"80825515180320012a",
     "cells":"0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e0e170e0b012a0128"
   }}
← {"act":"bat_in","msg_id":5,"suc":1}

📌 上例是15 串磷酸铁锂体系,内部自洽:15 × 3.6V = 54.0V,与 info 里的电压一致;cell_count 与 cells 的长度必须匹配(cell_count × 4 + 16)。

id 段数不是 2(仓体级要求 仓控板-通道)时:

← {"act":"bat_in","msg_id":5,"suc":0,"err":"invalid id"}

标识与属性(硬件「电池组属性」38 字节)

字段 类型 说明
battery_id string 电池序列号(ASCII ≤30),作为电池唯一标识。
BMS 读不到序列号时传空串 "" —— 不影响充电与订单记录,只是平台不知道是哪块电池
brand_code string 厂家代码,原值透传,由平台解析为名称("1" = 星恒)
model_code string 电池型号,原值透传,由平台解析为名称("1" = 磷酸铁锂)
material_code string 电芯材料,原值透传,由平台解析
cell_count 整数 电芯节数
rated_voltage 整数 额定电压,硬件 0.1V ×100 → mV(48000 = 48.0V)
rated_capacity 整数 额定容量,硬件 0.1AH ×100 → mAh(24000 = 24.0Ah)

动态数据(硬件「充电动态数据」9 字节)

字段 类型 说明
info string(恰好 18 个 hex 字符) 该通道的 9 字节电池信息(硬件「充电动态数据」)。布局与解析规则完全同 5.3 的 channels 元素:byte0 电池状态 / byte1 电源状态 / byte2 SOC / byte3–4 电压 / byte5–6 电流 / byte7–8 电芯最高温度,多字节大端

info 与 status_post.channels[] 里的元素是同一种 18 字符 hex,只是这里只针对"刚插入的这个通道"。

电芯数据(硬件「电芯数据」表)—— 同样拼接 hex,不拆字段

字段 类型 说明
cells string 电芯数据整段拼接的 hex 字符串,长度 = cell_count × 4 + 16 个字符

cells 的段布局(按硬件「电芯数据」表顺序,多字节一律大端)

段 长度(hex 字符) 含义 硬件单位
电芯电压 × N N × 4(N = cell_count) 各电芯电压,依次排列 2 字节/节
最高电压 4 所有电芯中的最高电压 2 字节
最低电压 4 最低电压 2 字节
最高温度 4 电芯最高温度 1K
最低温度 4 电芯最低温度 1K

长度对照(cell_count × 4 + 16)

电芯节数 hex 字符数 字节数
15 76 38
20 96 48
22 104 52 ← 与硬件文档写的「52 个字节」吻合

示例解析(15 节)

"0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e | 0e17 | 0e0b | 012a | 0128"
 └────────── 15 节电芯电压(每节 4 字符,共 60 字符)──────────┘   最高   最低   最高温  最低温
  0e10 = 3600mV  0e15 = 3605mV  0e0e = 3598mV …                      0e17  0e0b  298K   296K

⚠️ 待确认第 15 项:硬件文档写「电芯数据 52 个字节」,但其示例写「20 节电芯」——按 20 节算是 48 字节,若按 22 节算正好 52 字节。 固件请按实际 cell_count 拼接,不要按固定 52 字节处理。

cells 块只随 bat_in 上报一次,不进 60 秒周期上报。

平台行为

⛔ bat_in 与订单无关 —— 它只是"插入了一块电池"这个事件。平台在这里不创建订单(订单只由 on 建立,见 5.7)。

  1. 校验 id → 解析出仓控板与通道(非法 → err: invalid id)
  2. 登记 / 更新电池台账(status=1 使用中;battery_id 为空时跳过台账)
  3. 记录插入事件(device_logs)
  4. 回复 suc:1

订单从 on 开始、到 bat_out(或 off)结束;bat_out 是订单结束的标志 —— 没有 bat_out 就说明这笔订单还在进行中。 插入电池后没有开始充电(没有 on),就不会有订单。

⚠️ energy(单订单累计充电量,Wh)协议不包含该字段(待确认第 13 项)。 平台侧已按兜底方案实现:对每条 status_post 的通道电压/电流按 60 秒积分累加, 订单结束时写入订单电量与充电记录;硬件侧不需要为本条改动任何报文。 ⚠️ 代码类字段(brand_code / model_code / material_code):4G 层原值透传、平台侧负责映射(见第 10 节第 7 条);取值表内容待硬件提供(现仅知 01)。

5.6 bat_out —— 电池拔出

MCU 约定:先 off 再 bat_out。

⛔ 硬性要求:无论电池是否充满,被拔出时都必须「先 off、再 bat_out」 这条包含人在没充满时直接把电池拔走(强制拔出)的情况。 检测到电池移除后,先上报 off(停止充电),再上报 bat_out(电池拔出)。 否则平台那条订单只能靠兜底收尾(10 小时订单超时 / 状态兜底,见下方)。 该场景 MCU 能否检测到,见文首待确认事项第 18 项。

→ {"act":"bat_out","msg_id":6,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"80005f15180000012a"}}
← {"act":"bat_out","msg_id":6,"suc":1}
  • battery 至少带 battery_id;建议同时带末帧电池信息 info(18 字符 hex,规则见 5.3)。
  • 平台结束该仓体活跃订单(reason='user_stop'),并把电池台账置空闲。

平台兜底:订单不会永远挂着

设备可能来不及/无法上报结束事件——例如停电或离线期间,人把电池取走了(设备根本没机会发 bat_out)。为此平台侧有兜底:

兜底 规则
订单超时兜底(主要) 充电订单处于"进行中"超过 10 小时未结束 → 平台自动结束该订单(reason='timeout')。
依据:充电不可能超过 10 小时(再久电池会出问题)
状态兜底(可选,待确认 #16) 若该通道有活跃订单、而 status_post 报该通道 batt_status bit7 = 0(无电池) → 说明电池已被取走,可提前结束。
⚠️ 是否可用取决于 #16(板离线时的上报方式)

固件侧不必为"补报历史事件"负责:漏报的结束事件由上面两条兜底收尾。 但停电恢复后是否需要设备/平台做一次状态对账,仍待硬件侧答复(见文首待确认事项第 19 项)。

5.7 on / off —— 设备侧上报

on(开始充电)—— 两种形态,靠有无 suc 区分

设备支持两种充电启动方式:

工作模式 谁发起充电 设备发出的 on
平台指令模式(默认) 平台下发 on 指令 回执形态:带 suc,msg_id 回带平台号
自动充电工作模式 设备检测到电池后自行开始充电 上报形态:不带 suc,msg_id 用设备自己的计数

当前处于哪种模式,由 login 成功应答里的 work_mode 下发(见 5.2):"0"=平台指令模式、"1"=自动充电工作模式。 平台侧可配置、后台可改;每次 login 都以本次应答为准(改了配置,设备重连后即生效)。

// ① 平台指令模式:平台先下发 on,设备执行后回执
← {"act":"on","msg_id":2,"id":"01-3"}
→ {"act":"on","msg_id":2,"id":"01-3","suc":1}

// ② 自动充电工作模式:设备自行开始充电后主动上报
→ {"act":"on","msg_id":7,"id":"01-3"}
← {"act":"on","msg_id":7,"suc":1}
  • 判定与 off 完全一致:带 suc = 回执(平台不回包);不带 suc = 设备上报(平台按新请求处理并回包)(见 3.5)
  • 平台两种都收:回执用于配对;上报用于记录"充电已开始"

on 是建立订单的【唯一入口】

工作模式 建单时机
平台指令模式(work_mode="0") 平台下发 on 指令时运行建单逻辑(这是平台自己的动作,不必等回执)
自动充电工作模式(work_mode="1") 设备上报 on(不带 suc)时建单
— 回执形态的 on(带 suc)不建单 —— 平台指令模式下单已在下发时建了

建单判定(该通道当前状态)

该通道当前状态 平台行为
没有活跃订单 新建订单(status=0)
有活跃订单,电池 id 没变 不新建 —— 判定为同一次充电
有活跃订单,电池 id 变了 老订单 status=2、reason='forced' 结束 → 新建订单
  • 起始 SOC:取该通道最近一次 bat_in 的 info 里的 SOC(on 报文本身不带 info);若拿不到则记 0
  • 依据:一个通道只可能有一块电池 —— 所以电池 id 变了必然是换了新电池,老订单必须先结束

off(充电停止)

→ {"act":"off","msg_id":8,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"81026415540000012a"}}
← {"act":"off","msg_id":8,"suc":1}

上例 info="81026415540000012a" 解析:byte0 81 = 有电池 + 充满;byte1 02 = 电源打开;SOC 64 = 100%;电压 1554 = 5460×0.01V = 54.60V;电流 0000;温度 012a = 298K。(位定义见 3.8)

off 是「订单结束」的触发之一(另一个是 bat_out)。

⚠️ 需告知硬件侧:除"充满"外,故障 / 过温 / 电池异常等造成充电停止时,也应当上报 off —— 否则平台那条订单只能等 10 小时订单超时 兜底,期间一直显示"充电中"。异常原因不需要在 off 里单独说明,status_post 的 batt_status 故障位会体现。

若 off 到达时该通道没有活跃订单:平台不做任何处理,但照常回 suc:1(固件收到 suc:1 即视为已送达)。 可能的原因有几种,平台侧不追究:老订单已被 10 小时超时结束、或停电期间状态错乱等。

注意区分:off 也可能是平台下发的指令(管理端手动停止充电)。区分方式见 3.5:下行报文不带 suc 的就是指令,固件应执行停止并回执(见 5.8),而不是当成自己的上报。

5.8 下行指令的执行回执

收到 open / pow_on / pow_off / reset / on / off 指令后,固件执行完必须回执:

→ {"act":"open","msg_id":1,"suc":1}

执行异常时:

→ {"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}

回执要求(逐条对照检查)

字段 要求
act 必带,与收到的指令一致
msg_id 必带,与收到的指令完全一致(原样回带)
dev_id 不带 —— 回执走的是同一条连接,平台已知道是哪台设备
id 可选(平台配对只靠 msg_id + act,不依赖 id);回带只为便于日志排查
suc 执行结果:1 正常 / 0 异常
err suc=0 时必带

⛔ 回执发出后即结束,不要再等待"回执的应答"——平台对回执不再回包。若固件把平台后续的任何报文当成"对回执的应答",会破坏 msg_id 配对。

reset 的回执语义(特殊,务必按此实现)

reset 只是一个动作,回执的含义是"已收到并开始执行(重启)",不表示重启成功:

项 说明
顺序 固件必须先发回执、再执行重启 —— 整柜重启后就没机会回执了
回执内容 {"act":"reset","msg_id":N,"suc":1} —— 表示下发成功,不需要体现"重启成功与否"
重启是否成功 平台不看回执;平台看设备是否重新 login —— 重连并 login 成功即证明重启完成
平台侧表现 收到 suc:1 → 管理端显示"已下发";随后设备重连 login → 恢复正常在线

也就是说:reset 收到 suc:1 只代表指令送达并已执行,不代表柜子/仓控板已经重启完成。


6. 签名算法与自测向量

6.1 算法

input = dev_id + auth_str + timestamp      // 三段直接拼接,无分隔符,无空格
hash  = SHA256(input)                      // 32 字节
hex   = 小写十六进制字符串(64 字符)
sign  = hex[56..64]                        // 第 57~64 个字符,共 8 位
  • dev_id:IMEI 字符串
  • auth_str:auth_str 接口返回的 8 位安全码,原样拼接
  • timestamp:本次 login 发送的秒级时间戳,按十进制数字拼接
  • sign 为 8 位小写十六进制字符

6.2 自测向量(可直接用于固件单元测试)

dev_id auth_str timestamp SHA256(input) 期望 sign
123456789012345 abcd1234 1719800000 23e2f229ca7cd67cc1c74a0219aa69cfbf2b267b1974a922ddae6e453957b959 3957b959
864720562557021 Ab3xY9Qz 1750000000 a44d44fbbf7bccffc5c5068d987ea4e89f17e9a9bae865635921afcc269c249d 269c249d

固件自测时把这两个输入喂进签名函数,输出必须逐字符相等。任何一位偏差都会导致登录失败。


7. 典型交互时序

⚠️ 本产品为工厂用集中充电柜:柜门开关与充电逻辑【互不联动】。

  • 柜门开 / 关是独立动作,与电池插入、充电开始 / 结束之间没有因果关系,也没有先后要求。
  • 充电的开始 / 结束只由电池的插入 / 拔出与充满决定(bat_in → on → off → bat_out)。
  • 下面把两条流程分开列出:二者可任意交错,也可只发生其中一条。
  • 不要把"开门"当成充电流程的前置步骤。

7.1 公共前提:连接与保活

【首次配网(仅一次)】
→ {"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}
← {"act":"auth_str","msg_id":1,"suc":1,"auth_str":"Ab3xY9Qz"}
   固件保存安全码

【上电 / 断线重连后】
→ {"act":"login","msg_id":2,"dev_id":"864720562557021","sign":"269c249d","timestamp":1750000000,"iccid":"..."}
← {"act":"login","msg_id":2,"suc":1,"spec":"0606","work_mode":"0"}
   连接转为已登录

【周期上报】每 60 秒(全量)
→ {"act":"status_post","msg_id":3,"rssi":85,
   "cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500},
   "boards":[{"board":1,"channels":["828255152c01f40143","008200000000000000", ...]}, ...]}
← {"act":"status_post","msg_id":3,"suc":1}

上例为截断写法(...):实际必须带全部仓控板 × 每板全部通道(含空仓)。

7.2 柜门动作(独立流程,与充电无关)

【平台下发解锁(开门)】
← {"act":"open","msg_id":1}
→ {"act":"open","msg_id":1,"suc":1}
   柜控板解开门锁;门不会自动打开,需【人拉开】

【人拉开柜门 → 状态变化,最迟 60 秒内上报】
→ {"act":"status_post","msg_id":9,"rssi":85,
   "cabinet":{"status1":"10","status2":"00","temp":29800,"meter":125500}, ...}
                          // status1 = "10" → bit4=1 柜门打开(§3.8)

【人手动关闭柜门 → 状态变化,最迟 60 秒内上报】
→ {"act":"status_post","msg_id":10,"rssi":85,
   "cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500}, ...}
                          // status1 = "00" → bit4=0 柜门关闭
  • 柜门开 / 关不触发任何充电动作;充电的启停也不依赖柜门状态。
  • 关门:本协议不提供"关门"指令(任何柜型均如此),柜门由人手动关闭;关闭后状态经 status_post(≤60 秒)回到平台。
  • 硬件侧另有约定:「柜门由打开→关闭」这一跳变由 4G 层本地检测并用于触发电池激活(平台不参与、无需下发任何指令)。

7.3 充电流程(独立流程,与柜门无关)

【电池插入 → 设备上报】
→ {"act":"bat_in","msg_id":4,"id":"01-3","battery":{ ...属性 + info(18hex) + cells(hex)... }}
← {"act":"bat_in","msg_id":4,"suc":1}
   平台创建充电订单

【开始充电 —— 取决于工作模式,二者只发生其一】

  ① 平台指令模式(默认)
  ← {"act":"on","msg_id":2,"id":"01-3"}
  → {"act":"on","msg_id":2,"id":"01-3","suc":1}
      带 suc = 回执,平台不再回包

  ② 自动充电工作模式
  → {"act":"on","msg_id":5,"id":"01-3"}
      不带 suc = 设备自行开始充电后的主动上报
  ← {"act":"on","msg_id":5,"suc":1}
      平台按新请求处理并回包

【充电中】实时值随每 60 秒的 status_post 上报

【充满,设备主动上报】
→ {"act":"off","msg_id":6,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"81026415540000012a"}}
← {"act":"off","msg_id":6,"suc":1}

【电池拔出】
→ {"act":"bat_out","msg_id":7,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"80005f15180000012a"}}
← {"act":"bat_out","msg_id":7,"suc":1}
   平台结束订单

on / off 都是双向的 act,一律靠有无 suc 区分"回执"与"设备上报"(见 3.5)。

允许并行:同一连接内可以有多个未完成请求同时在飞,配对完全靠 msg_id,到达顺序不影响正确性。

t=0.0   → {"act":"status_post","msg_id":8,"rssi":85,"cabinet":{...},"boards":[...]}
t=0.1   ← {"act":"on","msg_id":3,"id":"01-2"}
t=0.2   ← {"act":"status_post","msg_id":8,"suc":1}            ← 命中固件挂起表 → 完成 8
t=0.3   → {"act":"on","msg_id":3,"suc":1}  ← 命中平台挂起表 → 完成 3

8. 异常与边界

8.1 会被平台静默丢弃、且不给任何回应的情况(固件会一直等不到应答)

情况 说明
上行缺 act 平台无法解析报文类型
auth_str / login 缺 dev_id 平台无法把这条 TCP 连接与设备绑定
未登录就发业务报文(首条不是 auth_str / login) 除 auth_str / login 外,其余 act 必须先 login 成功(见 2.2)。未登录连接尚未与 dev_id 建立可信绑定,平台丢弃且无法回包;且连接建立后 30 秒内未 login 成功会被主动断开
发 auth_str 的 IMEI 未在平台登记 该设备未建档(合法设备必然已录入 IMEI)。平台不发安全码、不写缓存、不回包(见 5.1)

固件必须保证每条上行都带 act、首条先完成 auth_str / login(并带上 dev_id)。出现上述情况时,固件侧表现为"发了没反应",超时后才丢弃——不要误判为网络问题。

8.2 平台会明确返回异常的常见情况

情况 平台应答
未知 act {"act":"...","suc":0,"msg_id":N,"err":"unknown act"}
login 失败(不区分原因) {"act":"login","suc":0,"msg_id":N,"err":"invalid signature"}
仓体 id 非法 {"act":"bat_in","suc":0,"msg_id":N,"err":"invalid id"}

完整 err 取值与含义见 3.3(含 auth_str already issued / rate limit exceeded / invalid signature)。

8.3 其它约定

  • 平台下发的 open/pow_on/pow_off/reset,登录成功前不会下发(平台侧拦截)。
  • ⚠️ 不要依赖平台应答里出现的"未知字段":平台内部可能附带以 _ 开头的服务端字段(如 _dev_id、_close),这些字段只用于平台内部路由/控制,不会下发到设备。固件必须忽略未知字段,不要因多字段而解析失败;也不要在应答里依赖 id —— 应答不带 id(id 只出现在指令与设备上行报文里,表示动作目标,见 3.7)。
  • 单条报文超过 **65536 字节(64 KB)**会导致连接被断开,固件需具备重连能力。
  • 固件侧建议:断线后按 10s / 30s / 60s / 300s 递增间隔重连;重连后必须重新 login 成功(收到 suc=1 且带 spec)才能发送业务报文。

9. 固件自测清单

基础连通

  • 首次开机:连接后首条报文是 auth_str,拿到安全码并持久保存后才发 login
  • 重启/断线重连:连接后首条报文直接是 login(不再申请安全码)
  • login 失败后能识别平台主动断开(读取到 EOF),并重连后按"先 auth_str"的顺序处理
  • login 持续失败时能检查本地时钟(平台只回 invalid signature,不区分原因)
  • 平台管理员重置安全码后,设备无需人工干预即可自愈登录
  • 建连后立即发 auth_str 或 login(不得先发业务报文;30 秒内未 login 成功会被平台断开)
  • 未登录时发其它 act 不会得到任何应答(固件不应依赖它,也不应据此重试)
  • 每条报文以 \n 结尾,单行 ≤ 65536 字节(64 KB)
  • 每条上行都带 act;auth_str / login 带 dev_id(其余报文不带 dev_id)
  • msg_id 在连接内由固件侧独立自增,不复用尚未完成的号

签名与登录

  • auth_str 返回 8 位 [A-Za-z0-9]
  • 用 6.2 节向量验签,期望值逐字符相等(3957b959 / 269c249d)
  • sign 取 8 位(hex[56..64])
  • timestamp 为秒级,与平台时差在 ±60 秒内
  • login 成功收到 spec、work_mode,且 spec 与实际柜型一致(如 6 板 × 6 仓 → "0606")
  • 按 work_mode 行事:"0" 等平台 on 指令;"1" 检测到电池即自行充电并主动上报 on

应答处理

  • 应答的 msg_id 与请求一致
  • 收到应答后按 msg_id 且 act 一致配对,不再回包
  • 能按"有无 suc"区分应答与指令;收到不带 suc 的报文时执行并回执
  • 回执带 act + msg_id + suc(异常时带 err;不带 dev_id)
  • 60 秒未应答则丢弃该请求,不重复发送业务动作
  • 超时后迟到的应答被丢弃、不补配;已作废的 msg_id 不被立即复用

状态上报

  • cabinet.status1 / status2 按 3.8 位定义构造(hex 2 位小写)
  • cabinet.status1 / status2 与通道 hex 的 byte0 / byte1 按 3.8 位定义构造
  • 每个通道 hex 恰好 18 个字符、数组顺序 = 通道号 1→8、多字节大端
  • 上报间隔 固定 60 秒/次,且每次为全量(所有仓控板 × 全部通道,无增量、无分片)
  • 空仓也上报:byte0 = 00(无电池),byte1 照实报电源状态(如 82),数值全 0

业务时序

  • bat_in → 平台登记电池台账(不建单)→ 收到 suc=1
  • on(开始充电)→ 平台建单(work_mode="0" 平台下发 on;"1" 设备上报 on)
  • 收到 on 指令并执行后回执
  • 充满上报 off
  • bat_out 上报
  • 断线重连后能重新 login 并继续上报

10. 约定定稿

以下约定已定稿,固件按此实现:

# 事项 定稿值
1 接入地址 测试 118.31.119.206:3001;正式 118.31.119.206:9001。TCP 由平台 TCP 服务直接监听,不经域名/nginx
2 status_post 的板号与通道 boards[].board 取 1–15;channels[].channel 取 1–8(当前 36 仓柜型为 6 板 × 每板 6 通道,8/15 是产品设计冗余)
3 状态字节格式 柜体状态 2 字节(cabinet.status1 / status2)用 hex 字符串、固定 2 位小写;通道状态则整体为一串 18 字符 hex(其中 byte0 = 电池状态、byte1 = 电源状态)。位定义与解析示例见 3.8;通道 hex 的字节布局见 5.3。保留位填 0 或 1 平台均忽略
4 上报节奏与内容 status_post 固定每 60 秒一次,每次全量上报(所有仓控板 × 全部通道;不区分空闲/充电,不做增量、不分片)。任意上行间隔必须 < 300 秒(平台 300 秒无上行即判离线)
5 柜门与充电的关系 本产品为工厂用集中充电柜,柜门开 / 关是独立动作,与充电逻辑互不联动:开门不触发充电;充电启停也不依赖柜门状态。柜门实际状态由 status_post 的 cabinet.status1 bit4 上报(0=关闭 / 1=打开)
6 柜门"开 / 关"的实现方式 开:平台下发 open(解锁),门由人拉开——门不会自动打开;关:人手动关闭。本协议不提供"关门"指令,任何柜型均如此。开 / 关状态都经 status_post 回传,最大延迟 60 秒,平台侧不得当作实时量
7 代码类字段的映射责任 brand_code / model_code / material_code:4G 层按字符串原值透传,不做任何转换;「代码 → 名称」的映射在平台侧完成。映射表由平台维护(可热更新,不硬编码进固件);4G 层只须保证原值不丢、不改写、不补零、不转义
8 充电启动的两种工作模式 ① 平台指令模式(默认):平台下发 on → 设备执行后回执(带 suc)。② 自动充电工作模式:设备检测到电池后自行开始充电 → 设备主动上报 on(不带 suc) → 平台按新请求处理并回包。两种形态一律靠有无 suc 区分(见 3.5),与 off 规则完全相同。当前模式由 login 成功应答的 work_mode 下发("0"=平台指令模式 / "1"=自动充电工作模式),平台侧可配置、后台可改,每次 login 以最新配置为准
9 login 应答下发柜子规格与工作模式 spec(固定 4 位字符串)= 前 2 位仓控板数 + 后 2 位每板通道数;当前 36 仓柜型为 "0606"(6 块仓控板 × 每板 6 仓)。由平台按该柜配置下发并作为柜子型号存储。旧的 cell(仓控板数量)已由 spec 取代,不再下发
10 连接登录门控 只有 auth_str / login 可以在未登录状态发送;其余所有 act(含 status_post、bat_in、on/off、回执等)必须先 login 成功,否则平台丢弃且不回任何应答。未登录的 TCP 连接不予保持:连接建立后 30 秒内未 login 成功,平台主动断开(固件应建连后立即 auth_str/login)
11 停电上报是两条独立指令 没有 pow_fail 这条指令,也没有 info 字段。停电上报分两条独立 act:
pow_fail_dc = 直流停电 —— 柜内接触器分闸(含远程 pow_off)导致仓控板下电,柜控板仍有电、可正常上报;
pow_fail_ac = 交流停电 —— 柜控板自身断电(市电断),靠板载电容余电抢发最后一条,与接触器无关。
两条均无附加字段,事件上报:平台收到即记录并置为该柜的停电状态,前端显示(不与其他业务逻辑联动);供电恢复后消除该状态
12 通道数据用 hex 透传,不拆字段 status_post.channels[] 的每个元素 = 恰好 18 个 hex 字符(硬件「充电动态数据」9 字节原样拼接,4G 层不做任何转换);bat_in 里电池信息用同格式的 info,电芯数据用 cells(整段拼接 hex,长度 = cell_count × 4 + 16)。
字节布局:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V) / byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K),多字节一律大端。
数组顺序 = 通道号 1→8;空仓 = byte0 00(无电池)+ byte1 照实报电源状态 + 数值全 0(例 "008200000000000000")。
电池状态与电源状态相互独立,两回事,前端分开显示

尚未定稿(7 项):energy 数据源(第 13 项)、代码表取值内容(第 14 项)、电芯数据字节数(第 15 项)、仓控板离线如何上报(第 16 项)、直流停电是否需要 pow_fail_dc(第 17 项)、强制拔出能否检测(第 18 项)、停电恢复后如何兜底(第 19 项)。 完整清单、现状与影响范围见文首「⚠️ 待确认事项」 —— 此处不再重复列一遍,避免两处漂移。

其余协议规则见文末「附:本文档采用的约定」。如需变更任一条,须双方书面确认后再改固件。


附:本文档采用的约定(如有异议请提出)

  1. sign 取 hex[56..64],8 位小写十六进制
  2. suc:1=正常,0=异常,suc=0 必须带 err
  3. 应答必带 act,值等于被应答报文的 act
  4. msg_id 在单条 TCP 连接内由各方向独立自增,应答原样回带;两个方向的编号允许重叠,不需要约定号段
  5. 允许并行交互;配对按 msg_id + act 双条件;每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试
  6. 报文性质按有无 suc 判定(带 suc = 应答,不带 = 指令);收到应答不再回包,双向都遵守,链条才是单向的

附 A:全指令带注释示例(JSONC,仅供阅读用)

本节覆盖协议内全部指令,每条报文逐字段带 // 注释。 ⚠️ 带注释的报文不是合法 JSON、不能直接解析;可直接复制自测的干净示例见第 5 节(已通过 JSON 校验)。 → 柜控板→平台;← 平台→柜控板。

指令总览

# act 方向 小节
1 auth_str 上行 A.1.1
2 login 上行 A.1.2(成功)/ A.1.3(失败)
3 status_post 上行 A.2.1
4 pow_fail_dc / pow_fail_ac 上行(停电,两条独立指令) A.2.2 / A.2.3
5 bat_in 上行 A.3.1
6 bat_out 上行 A.3.2
7 on 上行(回执,或自动充电模式下自发上报) A.4.1
8 off 上行(充满自动停) A.4.2
9 on 下行(平台发起充电) A.5.1
10 off 下行(平台停止充电) A.5.2
11 open 下行(柜门解锁) A.5.3
12 pow_on 下行(接触器合闸) A.5.4
13 pow_off 下行(接触器分闸) A.5.5
14 reset 下行(重启整柜 / 单仓控板) A.5.6
15 下行指令的执行回执(第 9–14 条,共 6 条;见 A.6) 上行 A.6

本协议共 13 个不同 act(auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off / open / pow_on / pow_off / reset);其中 on / off 是双向的(上行见 A.4、下行见 A.5)。

A.1 鉴权

A.1.1 auth_str —— 首次获取安全码

→ {"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}
//   act    : 动作类型,双向必带;缺失时报文被丢弃且【无任何应答】
//   msg_id : 本连接内的交互号,发送方自行分配(柜控板从 1 起自增)
//   dev_id : 柜控板 IMEI,15 位十进制 ——【仅 auth_str / login 带】,
//            平台用它把这条 TCP 连接与设备绑定;之后报文一律不再带(见 2.2)
//   时机   : 仅"本地无安全码"时(首次开机 / 换板 / 被重置后)

← {"act":"auth_str","msg_id":1,"suc":1,"auth_str":"Ab3xY9Qz"}
//   act      : 与请求一致 —— 应答必带 act
//   msg_id   : 与请求完全一致 —— 配对键
//   suc      : 1=正常 / 0=异常;只有应答带此字段
//   auth_str : 8 位安全码 [A-Za-z0-9],仅此一次返回,须存入非易失存储

//   未重置时重复申领 → 平台拒绝(见 A.7):
//   ← {"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"}

A.1.2 login —— 登录验签(成功)

→ {"act":"login","msg_id":2,"dev_id":"864720562557021",
   "sign":"269c249d","timestamp":1750000000,"iccid":"8986012520983901841"}
//   sign      : SHA256(dev_id + auth_str + timestamp) 的小写 hex,取 hex[56..64] = 8 位
//   timestamp : 秒级 Unix 时间戳(不是毫秒),与平台时差须在 ±60 秒内
//   iccid     : 可选;上报后平台自动比对,SIM 卡变更会被记录

← {"act":"login","msg_id":2,"suc":1,"spec":"0606","work_mode":"0"}
//   spec      : 柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数)
//              "0606" = 6 块仓控板 × 每板 6 仓;平台据此把连接置为"已登录"
//   work_mode : 充电工作模式 "0"=平台指令模式 / "1"=自动充电工作模式
//          敏感下行指令(open / pow_on / pow_off / reset)以此为前提

A.1.3 login —— 失败(平台随即断开连接)

← {"act":"login","msg_id":2,"suc":0,"err":"invalid signature"}
//   本地安全码与平台不一致(平台重置过 / 设备侧码被改被清)
//   → 固件动作:重连后先发 auth_str 申领新码,保存后再 login

← {"act":"login","msg_id":2,"suc":0,"err":"invalid signature"}
//   ⛔ login 失败【只有这一种应答】——平台刻意不区分:
//      安全码不一致 / 时间戳超窗 / 平台无该设备记录,对外都是 invalid signature
//      (避免外部据此试探系统状态,属刻意安全设计)
//   → 固件动作:重连后先发 auth_str 申领新码 → 保存 → 重新 login
//     若 auth_str 返回 already issued(码没被重置)→ 停止重试,
//     并【检查本地时钟】是否准确(时间戳超窗也表现为 invalid signature)
//   ⚠️ 平台发出该应答后会【主动断开 TCP】,固件读到 EOF 后重新建连

A.2 周期与事件上报

A.2.1 status_post —— 周期状态上报(每 60 秒全量)

→ {"act":"status_post","msg_id":3,"rssi":85,
   "cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500},
   "boards":[{"board":1,"channels":["828255152c01f40143","008200000000000000",
                                    "828255152c01f40143","008200000000000000",
                                    "828255152c01f40143","008200000000000000"]}]}
//   rssi       : 4G 信号强度,整数 0–99(不是 dBm,不可为负)
//   cabinet    : 柜体状态,柜级只带一次
//                status1 / status2 → 柜体状态 2 字节(hex 2 位,位定义见 §3.8)
//                temp  → 柜体温度 K×100(29800 = 298.00K)
//                meter → 电表读数 Wh(125500 = 125.5 kWh)
//   boards[]   : 所有仓控板(board 1–15),每板带全部通道
//                channels = **每通道 18 个 hex 字符**(9 字节原样透传)
//                           数组顺序 = 通道号 1→8;多字节**大端**
//                           byte0 电池状态 / byte1 电源状态(位定义见 §3.8)
//                           byte2 SOC(1%) / byte3–4 电压(0.01V)
//                           byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K)
//   空仓 = byte0 "00"(无电池)+ byte1 照实报电源状态 + 数值全 0
//          例:"008200000000000000" = 空仓、但电源有电且已打开
//   节奏:固定每 60 秒一次,每次全量(不区分空闲/充电、不做增量)
//   本报文同时是【保活报文】:平台 300 秒无任何上行即判离线

← {"act":"status_post","msg_id":3,"suc":1}
//   suc : 1=已接收

A.2.2 pow_fail_dc —— 直流停电

→ {"act":"pow_fail_dc","msg_id":4}
//   含义 : 柜内【接触器分闸】(含平台远程 pow_off)→ 仓控板下电
//   特征 : 柜控板本身仍有电,可正常上报,不依赖电容
//   时机 : 检测到停电瞬间立即发出(事件报文,非周期)
//   平台 : 置 pow_fail_dc 标记 + 停电告警
//   字段 : 只需 act / msg_id,【无附加字段】(设备身份由连接确定)
← {"act":"pow_fail_dc","msg_id":4,"suc":1}

A.2.3 pow_fail_ac —— 交流停电

→ {"act":"pow_fail_ac","msg_id":5}
//   含义 : 【柜控板自身断电】(市电 380V 断)→ 靠【板载电容余电】抢发这最后一条
//   特征 : 与接触器无关;发完柜子即完全失电
//   平台 : 置 pow_fail_ac 标记 + 停电告警
//   ⚠️ 发完这条之后,平台不会再收到该柜任何报文,
//      直到恢复供电并重连 login
← {"act":"pow_fail_ac","msg_id":5,"suc":1}

A.3 电池交互

A.3.1 bat_in —— 电池插入

→ {"act":"bat_in","msg_id":5,"id":"01-3",
   "battery":{
     "battery_id":"SN123456",
     "brand_code":"1","model_code":"1","material_code":"1",
     "cell_count":15,"rated_voltage":48000,"rated_capacity":24000,
     "info":"80825515180320012a",
     "cells":"0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e0e170e0b012a0128"}}}
//   id        : 两段【仓控板-通道】(仓控板补零 01–15、通道 1–8 不补零),段数必须为 2
//   battery_id: 电池序列号(ASCII ≤30),电池唯一标识;**BMS 读不到时传空串 ""**(不影响充电)
//   brand_code / model_code / material_code
//             : 硬件代码,**字符串原值透传**,由平台解析成名称
//               ("1"=星恒 / "1"=磷酸铁锂;全套代码表待硬件提供)
//   cell_count: 电芯节数(示例为 15 串磷酸铁锂:15×3.6V = 54.0V,与 info 里的电压自洽)
//   rated_voltage  : mV(48000 = 48.0V,硬件 0.1V ×100)
//   rated_capacity : mAh(24000 = 24.0Ah,硬件 0.1AH ×100)
//   info           : **18 个 hex 字符** = 该通道的 9 字节电池信息(布局同 §5.3)
//                    "80825515180320012a" 解析:
//                      byte0 80 = 有电池、尚未充电(位定义见 §3.8)
//                      byte1 82 = 有电源 + 电源打开(位定义见 §3.8)
//                      SOC 55 = 85%   电压 1518 = 54.00V
//                      电流 0320 = 8.00A   温度 012a = 298K
//                    ⚠️ 插入首帧时平台还没下发 on,故不应是"正在充电"
//   cells          : 电芯数据整段拼接 hex,长度 = cell_count × 4 + 16
//                    (示例 15 节 → 76 字符:15 节电压 + 最高 + 最低 + 最高温 + 最低温)
//                    只随 bat_in 带一次,不进周期上报
//   soc/voltage/current/temp : 换算同 A.2.1
//   cells          : 电芯数据,**只随 bat_in 带一次**;项数须与 cell_count 相同
//   ⚠️ energy(累计充电量)数据源待硬件确认,确认后再补入本报文

← {"act":"bat_in","msg_id":5,"suc":1}
//   平台动作:登记/更新电池台账(本指令【不建单】—— 订单由 on 建立,见 §5.7)
//   异常:id 段数 ≠ 2 → ← {"act":"bat_in","msg_id":5,"suc":0,"err":"invalid id"}

A.3.2 bat_out —— 电池拔出

→ {"act":"bat_out","msg_id":6,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"80005f15180000012a"}}
//   MCU 约定:先发 off,再发 bat_out
//   battery 至少带 battery_id,建议带末帧动态(换算同 A.2.1)

← {"act":"bat_out","msg_id":6,"suc":1}
//   平台动作:结束订单(reason='user_stop')、电池台账置空闲

A.4 设备上报 / 回执(on / off)

A.4.1 on —— 开始充电(两种形态)

// ── ① 平台指令模式:平台先下发 on(见 A.5.1),设备执行后回执 ──
← {"act":"on","msg_id":2,"id":"01-3"}
→ {"act":"on","msg_id":2,"id":"01-3","suc":1}
//   带 suc + msg_id 回带平台号 → 回执,平台不再回包

// ── ② 自动充电工作模式:设备检测到电池后自行开始充电,主动上报 ──
→ {"act":"on","msg_id":7,"id":"01-3"}
//   不带 suc + msg_id 用自己的计数 → 平台按【新请求】处理并回包
← {"act":"on","msg_id":7,"suc":1}
//   两种形态一律靠"有没有 suc"区分(见 3.5),与 off 的规则相同

A.4.2 off —— 充满自动停止(设备主动上报)

→ {"act":"off","msg_id":6,"id":"01-3",
   "battery":{"battery_id":"SN123456","info":"81026415540000012a"}}
//   info="81026415540000012a" → byte0 81 = 有电池 + 充满;byte1 02 = 电源打开;
//                              SOC 64 = 100%;电压 1554 = 54.60V(位定义见 §3.8)
//   不带 suc → 这是设备【新发起】的上报(不是对平台指令的回执)
//   → 平台结束订单并回包
← {"act":"off","msg_id":6,"suc":1}
//   ⚠️ 同名 off 有两种语义,靠"有没有 suc"区分(见 3.5):
//      下行不带 suc → 平台停止充电指令(A.5.2)
//      上行不带 suc → 设备自发的充满自动停(本条)

A.5 平台下发指令(下行,全 6 条)

下行报文不带 suc(见 3.5);固件收到后立即执行,并在 60 秒内回执(见 A.6)。 open / pow_on / pow_off / reset 要求该连接已 login 成功,否则平台不投递。

A.5.1 on —— 开始充电(仓体级)

← {"act":"on","msg_id":2,"id":"01-3"}
//   msg_id : 由平台分配,固件回执时【必须原样回带】,不要自己改号
//   id     : 两段【仓控板-通道】
//   固件动作:对该仓体开始充电 → 回执(见 A.6)

A.5.2 off —— 停止充电(仓体级)

← {"act":"off","msg_id":3,"id":"01-3"}
//   固件动作:停止该仓体充电 → 回执(带 suc、msg_id 原样回带)
//   区分同名的两种 off 靠【方向】,不靠 suc:
//     下行 off = 平台指令(本条)→ 固件执行后回执,平台不回包
//     上行 off = 设备上报(A.4.2)→ 平台按新请求处理并回包
//   固件收到的一定是下行,按"指令"处理即可,无需自行判别

A.5.3 open —— 柜门解锁(柜级,整柜)

← {"act":"open","msg_id":1}
//   id     : 柜级指令只写 IMEI(15 位)
//   含义   : 对【整柜唯一的柜门】执行【解锁】—— 门不会自动打开,需人拉开
//            协议没有"按仓体开门"的指令,也没有"关门"指令
//   固件动作:解开门锁 → 回执;失败时回执带 err(如 "door lock not engaged")
//   状态回传: 柜门开/关由 status_post 的 cabinet.status1 bit4 带回
//             (0=关闭 / 1=打开);关门为人手动,最迟 60 秒内上报
//   ⚠️ 本产品为工厂集中充电柜:柜门动作【独立】,与充电逻辑互不联动
//      开门不触发充电;充电的启停也不依赖柜门状态

A.5.4 pow_on —— 接触器合闸(柜级)

← {"act":"pow_on","msg_id":4}
//   固件动作:接触器【合闸】→ 回执
//   后果:整柜上电(含各仓控板)→ 仓体/电池数据恢复
//   状态回传:接触器状态见 status_post 的 cabinet.status1 bit6(0导通 / 1断开)

A.5.5 pow_off —— 接触器分闸(柜级)

← {"act":"pow_off","msg_id":5}
//   固件动作:接触器【分闸】→ 回执
//   ⚠️ 后果:整柜下电,【仓控板随之下电】
//      → 该柜所有仓体/电池数据不可用,且设备会因此上报
//        pow_fail_dc(直流停电,见 A.2.2)
//      → 恢复供电需再下发 pow_on
//   属【运维动作】(检修/停用/复位总电),不是充放电业务

A.5.6 reset —— 重启(柜级 / 单仓控板)

← {"act":"reset","msg_id":6}
//   id 省略(柜级)→ 重启【整柜】
//   ⚠️ 必须先【回执】再重启(参见 §5.8):回执只表示"下发成功",
//      不表示重启成功;重启成功的标志是设备随后【重新 login】

← {"act":"reset","msg_id":7,"id":"01"}
//   id = 仓控板(一段,如 "01")→ 只重启【1 号仓控板】
//   固件动作:先回执再执行重启(否则重启后无法回包)

A.6 设备对下行指令的执行回执

回执必带 act、msg_id 原样回带;不带 suc 的报文是指令(见 3.5)。 ⚠️ 回执发出即结束,不要等待"回执的应答"——平台不再回包。

A.6.1 成功回执(以 open 为例)

← {"act":"open","msg_id":1}

→ {"act":"open","msg_id":1,"suc":1}
//   act    : 与收到的指令一致(回执必带)
//   msg_id : 与收到的指令完全一致 —— 必须原样回带
//   id     : 建议回带,与指令一致
//   suc    : 1=执行正常
//   注意   : 【不带 dev_id】—— 回执走同一条连接,平台已知道是哪台设备

A.6.2 失败回执

→ {"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}
//   suc=0 时 err【必带】;平台据此在管理端显示"执行失败"

A.7 平台异常应答(suc=0 时 err 必带)

← {"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"}
← {"act":"auth_str","msg_id":1,"suc":0,"err":"rate limit exceeded"}
← {"act":"login","msg_id":2,"suc":0,"err":"invalid signature"}
//   ⛔ login 失败【只有这一种】,平台刻意不区分失败原因
← {"act":"bat_in","msg_id":5,"suc":0,"err":"invalid id"}
← {"act":"bat_out","msg_id":6,"suc":0,"err":"invalid id"}
← {"act":"heartbeat","msg_id":7,"suc":0,"err":"unknown act"}
//   "unknown act" = act 不在本协议清单内。固件【不得】自定义新指令
//   (本协议无独立心跳包,保活由 status_post 承担,见 2.3)

A.8 超时(60 秒,双向对称)

← {"act":"open","msg_id":4}
//   t=0    :平台登记挂起项(msg_id=4, act=open)
//   t=60s  :仍未收到回执 → 平台作废该挂起项,不重传,记 WARN
//   t=65s  :迟到的回执到达 → 该 msg_id 已作废 → 【丢弃】,不补配

→ {"act":"status_post","msg_id":9, ...}
//   t=0    :固件登记挂起项
//   t=60s  :仍未收到应答 → 固件作废该交互,【不重试】该业务动作
//            (下一条周期 status_post 照常发,用新的 msg_id)

附 B:变更记录

版本 日期 变更内容
v1.1 2026-09-14 ① 签名定稿为 8 位(hex[56..64]),自测向量更新为 3957b959 / 269c249d ② 明确 act 双向必带、suc=0 必带 err ③ 报文性质判定改为按有无 suc,配对要求 msg_id + act 双条件 ④ 明确每个交互 60 秒超时、超时作废、迟到应答丢弃、不重试 ⑤ 新增 login 失败分类处理与安全码自愈闭环(重置后设备自动重新申领)⑥ 接入端口定稿:测试 3001、正式 9001,明确 TCP 不经 nginx/域名 ⑦ 第 10 节遗留项全部定稿;新增附 A 带注释报文示例 ⑧ login 失败后平台主动断开 TCP 连接,设备须重连后重新握手 ⑨ 安全码存储明确为 DB 权威 + Redis 缓存(缓存未命中回源 DB,Redis 故障不再导致设备无法登录)⑩ 上报节奏定稿:status_post 固定每 60 秒一次、每次全量(取消原"空闲 60s / 充电 15s"与按板分片设想);单行上限 4096 → 65536 字节 ⑪ 上报字段按硬件权威数据字典重构:status_post 改为 cabinet(柜体状态2 字节 + 柜温 + 电表)+ boards[].batteries[](2 个状态字节 + SOC/电压/电流/温度);bat_in / bat_out / off 的 bms 改为 battery(属性 + 动态 + 电芯);新增 §3.8 状态字节 hex 解析表;id 量程改为仓控板 1–15 / 电池组 1–8;soh / power 移除 ⑫ 柜门整章定稿:柜门与充电完全解耦(§7.2/§7.3 拆分)、open 明确为解锁(门由人拉开)、无"关门"指令(任何柜型)、柜门状态经 status_post ≤60 秒回传;rssi 口径由 dBm 改为 0–99 信号等级 ⑬ 挂起 4 项待确认(pow_fail info 语义、energy 数据源、代码表取值内容、电芯数据字节数)并在文首新增「⚠️ 待确认事项」集中列出;删除内部重复文档 docs/柜控板-平台TCP接口文档.md ⑭ login 成功应答新增 spec 与 work_mode:spec(4 位,前 2 位仓控板数 + 后 2 位每板通道数,当前柜型 "0606")作为柜子规格/型号由平台下发;work_mode("0"=平台指令模式 / "1"=自动充电工作模式)下发充电工作模式,固件据此决定是否等平台 on 指令——平台侧可配置、后台可改,每次 login 以最新配置为准 ⑮ 新增连接登录门控:只有 auth_str / login 可在未登录状态发送,其余 act 必须先 login 成功,否则平台丢弃且不回包;未登录的 TCP 连接不予保持(建连后 30 秒未 login 成功即主动断开)。同时 err 文案全部改为英文小写短语(§3.3 新增取值全集表),id 段数规则明确(一段=柜级 / 两段=仅 reset 单板 / 三段=仓体级) ⑯ 补充 pow_on / pow_off 业务语义:远程通断柜内总电(接触器)——pow_off 分闸后仓控板随之下电(仓体/电池数据不可用,并因此产生 pow_fail info:"0" 直流停电),属运维动作;pow_fail 与 cabinet.status1 bit0 的关系(事件 vs 状态)在 §5.4 说明 ⑰ pow_fail 的 info 区分定稿(判定依据 = "谁掉电"):"0"=直流停电(接触器分闸→仓控板下电,柜控板仍有电)/ "1"=交流停电(柜控板自身断电,靠板载电容余电抢发最后一条,与接触器无关);该条由「待确认」转入「约定定稿」第 11 条,待确认事项由 4 项缩减为 3 项 ⑱ 停电上报更正为两条独立指令:取消 pow_fail(及 info 字段),改为 pow_fail_dc(直流停电:接触器分闸→仓控板下电,柜控板仍有电)与 pow_fail_ac(交流停电:柜控板自身断电,靠板载电容余电抢发最后一条,与接触器无关)两条独立 act,均无附加字段;act 名与平台 Redis 字段 pow_fail_dc / pow_fail_ac 一一对应。代码侧 report_worker 分发与 handle_pow_fail 同步改为按 act 判定 ⑲ 新增待确认第 12 项:仓控板离线(485 芯片坏 / 线松 / 掉电)如何上报 —— 4G 层读不到该板数据时该怎么报尚未约定(省略该板?照报全 0 的板?另有在线标志?),以硬件答复为准、本文不预设结论(§5.3 同步加警示) ⑳ login 失败统一为单一应答:取消 invalid timestamp / auth_str not found,只返回 invalid signature(不区分失败原因,避免外部试探系统状态——安全设计);同时修正 spec 的定位为「平台对柜子的规格定义(型号)」,与仓控板实际能否通信无关;work_mode 明确仅两种、不再扩展 ㉑ status_post 字段改名:batteries[] → channels[]、内层 group → channel(那是仓板的通道、可能空着,不是纯电池信息;同时与平台侧 channel_id 对齐);id 第三段说明改为「通道」;全文「电池组」按语境改为「通道」(硬件表名「电池组属性」保留) ㉒ 通道状态改为 hex 透传(大幅瘦身):status_post.channels[] 由"每通道 6 个命名字段"改为 每通道 18 个 hex 字符(9 字节原样拼接,4G 层零转换),数组下标 = 通道号;bat_in/bat_out/off 的动态数据同步改为 info(同一种 18 字符 hex);定字节布局与大端规则,§3.8/§5.3/§5.5/§7/§9/§10/附A 全面同步。体积:6 板 × 6 通道由 3.4KB 降至 ~0.8KB ㉓ 修正空仓写法:空仓 ≠ 全 0 —— byte0 = 00(无电池)但 byte1 照实报电源状态(电源与有无电池无关),例 "008200000000000000";明确电池状态与电源状态是两个独立维度,平台前端分两个指示分开显示 ㉔ 字段改名:bat_in/bat_out/off 里的电池数据字段 dyn → info(它是这块电池的信息,不是"充电数据");同时明确 hex 大小写不敏感(平台两种都解析,建议固件统一小写),示例 hex 全部改小写 ㉕ cells 同样改拼接 hex:由"JSON 数组 + 4 个统计字段"改为整段拼接的 hex 字符串,长度 = cell_count × 4 + 16(N 节电压 + 最高/最低电压 + 最高/最低温度,大端),15 节 = 76 字符、20 节 = 96、22 节 = 104(与硬件文档「52 字节」吻合) ㉖ battery_id 允许为空串:BMS 读不到序列号时传 "",不影响充电与订单记录;平台侧 battery_id 为空时不做"换电池"判断、跳过电池台账,订单照常创建(只是不知道是哪块电池) ㉗ bat_in 重复上报不新建订单:明确 bat_out 是订单结束标志——没有 bat_out 就说明订单还在进行;因此该通道已有活跃订单且 battery_id 相同(含两边都为空串)时不新建订单,仅当 battery_id 不同且都非空才 forced 结束旧订单并新建。依据:一个通道只可能有一块电池,bat_in 带不同 battery_id 必然是换了电池 → 老订单必须先结束(⚠️ 该条后续在 ㉝ 修正:bat_in 最终定为与订单无关,建单只在 on) ㉘ 新增订单超时兜底:设备可能停电/离线期间电池被取走而永远发不出 bat_out → 平台侧充电订单"进行中"超过 10 小时即自动结束(reason='timeout',依据"充电不可能超过 10 小时");固件不必补报历史事件 ㉙ 补充两条待确认(第 18/19 项):① 人在没充满时强制拔电池——MCU 能否检测、能否保证"先 off 再 bat_out"(§5.6 已把该顺序列为硬性要求);② 停电恢复后如何兜底——掉电期间事件无法上报,恢复后是补报、还是以当前状态为准由平台对账 ㉚ 报文瘦身:dev_id 只在 auth_str / login 带 —— 这两条用于把 TCP 连接与设备绑定,之后所有报文都不再带 dev_id(设备身份由连接确定;断开重连才需重新 login)。同步更新 §3.1 字段总表、§5.3§5.8、§7 时序、§8.1、§9 清单、附 A 全部示例;平台侧 device-server 转发时按连接注入设备身份(内部改动、固件无感) ㉛ id 也去掉 IMEI 前缀:既然设备身份来自连接,id 只表示动作目标 —— 不带=柜级、一段=单板(仅 reset,如 "01")、两段=仓体级(如 "01-3");仓体级段数校验由 ≠3 改为 ≠2;§3.7 / §4.2 / §5.5 / 附 A 全部示例与注释同步。平台侧 device-server 下发时需按目标连接补回 IMEI 前缀(内部改动) ㉜ msg_id 编号规则写实:两个方向各自独立计数、都每连接从 1 起自增(0 不使用),重连归零;明确不约定号段(方向靠有无 suc 区分),并说明"同一数字在两个方向同时出现是正常的"。原示例中的大号(2147483648 等,共 25 处)全部改为从 1 起的小号;删除原先"平台从高位起"的不实描述 ㉝ 订单生命周期定稿(关键修正):bat_in 与订单无关(只登记电池台账,不建单);建立订单的唯一入口是 on(work_mode="0" 平台下发 on 时建单、work_mode="1" 设备上报 on 时建单、回执形态不建单);结束 = bat_out / off / 10 小时订单超时。建单判定:该通道无活跃订单→新建;有活跃订单且电池 id 没变→不新建;变了→老订单 forced + 新建(依据"一个通道只可能有一块电池");起始 SOC 取最近一次 bat_in 的 info。off 到达时无活跃订单 → 平台不处理但照常回 suc:1。并在 §5.7 加注释告知硬件侧:除充满外,故障 / 过温 / 电池异常等停止也应上报 off ㉞ reset 回执语义明确:reset 只是一个动作,回执表示"已收到并开始执行(重启)"= 下发成功,不表示重启成功;固件必须先回执、再重启(整柜重启后就没机会回执);重启是否成功平台不看回执,而是看设备是否重新 login;回执的 id 明确为可选(平台配对只靠 msg_id + act) ㉟ 平台应答补上 act(实现对齐协议):ServerResponse 新增 act(必带,与请求一致),新增 success_for / error_for 从请求报文取 act+msg_id(协议 §3.1 要求 act 双向必带、§3.4 要求配对含 act,原实现应答无 act 属实现缺陷);连带修复 device-server 的「应答 vs 指令」判据(只看有没有 suc,不再看 act)与敏感指令门控(只拦指令 —— 否则 auth_str 的应答会被自己拦掉 → 首次配网死锁);并新增内部路由字段 _dev_id(id 去掉 IMEI 后 device-server 无法再从 id 推导目标设备,改用该字段;下发前摘除、设备侧不可见,§8.3 同步说明) ㊱ 补两条边界说明:① 未登记的 IMEI 发 auth_str 时平台静默丢弃(不发码/不写缓存/不回包,见 §5.1 与 §8.1 表)—— 合法设备必然已建档,正常固件不会遇到;② 电池属性按原值入库(brand_code/model_code/material_code/cell_count/rated_voltage/rated_capacity 均入电池台账,映射出的中文名留待代码表确认后回填,与 §10 定稿第 7 条一致)
v1.0 2026-09-14 首版:基于平台 TCP 服务实现逐项核对生成