charging-cabinet/docs/柜控板固件对接文档.md
12451 f80b3b634c fix: 五角度审查后的安全/正确性/契约修复(含 order_timeout 反条件、连接接管、权限兜底)
## 严重问题
- order_timeout: 超时条件方向写反(`start_time < TIMESTAMPADD(HOUR,10,NOW())` 等价于
  `start_time < NOW()+10h`,条件恒真)→ 活跃订单会被当超时单提前结掉。改为
  `TIMESTAMPADD(HOUR, ?, start_time) < NOW()`,并补"刚建单不命中/超期命中"双向断言
- C1 device-server 连接池可被未验签的报文 dev_id 顶替(覆盖式 insert + 断连无条件 remove):
  改为 conn_id 归属模型 —— 唯一连接标识、remove_if/touch/is_authenticated 均带 conn 归属校验、
  接管时用 oneshot 信号主动驱逐旧连接、每条上行前用 is_owner 复核持有者身份
- C2 POST /api/devices/{imei}/command 零归属校验 + act 自由字符串 + 把 IMEI 塞进协议 id:
  补 scope_guard 归属校验 + act 白名单 + id 从 params 显式取(拒绝 15 位 IMEI)
- 非总管理员权限集恒为空(user_roles 从不写入且无分配入口)→ 全站 403:
  user_roles 为空时按 users.role 回退内置角色权限 + 建/改用户幂等同步映射(仅角色变化时同步)

## 订单生命周期并发正确性
- finalize_order 先取电量累加器后抢结束权 → off/bat_out 成对到达时 energy 被写 0:
  改为先原子抢占(WHERE compartment_id=? AND status=0 ORDER BY id DESC LIMIT 1),
  抢到者才 take_and_reset 取电量;抢不到直接返回且不碰累加器
- 建单"先查后插"无锁无唯一约束 → 同仓体两笔活跃单:
  条件插入(INSERT ... SELECT ... WHERE NOT EXISTS)+ 按仓体进程内锁
  + DB 层 active_flag 生成列 + UNIQUE 唯一索引(幂等迁移,有重复则记 ERROR 跳过,绝不阻断启动)
- handle_bat_out: updated==0 时不再复位占用;通道登记删除改为 battery_id 一致才删;
  新增 should_finalize_bat_out 挡住"迟到 bat_out 结束新单"
- 电压/电流加量程夹取(0–100000 mV/mA 之外按 0 计并 WARN),防位翻转导致积分暴增

## 协议/契约
- spec 绝不可能是 "0000"(生产 42/68 台拓扑为空):新增 build_spec 三级兜底
  (真实拓扑 → cabinets.cell×6 → 6×6),兜底生效记 WARN 报出拓扑数据
- /api/cabinets/realtime-status 改读真实字段(原来读的设备链路从不写的旧字段,恒返回 idle/空),
  用 derive_channel_view 推导旧字段以保持响应字段名不变;修掉"把通道号当板号"的幽灵板问题
- 小程序通道详情/PC 详情改读通道 Hash(弃用无写入方的 compartment:status:{id}),
  取不到返回 null 而非假的 0;补 voltage/current/temp/batt_status/pow_status
- 反馈列表补权限码 operation_log:view + status 筛选真正落到 SQL
- 小程序柜温换算(29800 曾被显示成 29800°C);签名注释 7→8 位;msg_id 序列化省略 null;
  parse_compartment_id 与 status_post 补板号 1–15/通道 1–8 量程校验(不再写 board:0 垃圾 key)

## 文档
- docs/柜控板固件对接文档.md:确立数值口径统一规则(hex 字节流=原始硬件单位·平台换算;
  JSON 数值字段=平台单位·设备换算)、修正 A.5.3 的 id 说明、作废变更记录原位标注(不删历史)、
  spec 表述改为"按拓扑现算+兜底、恒 4 位不会是 0000"、修复文首 7 项表格被引用块切断丢表头、
  修正 §10 误引 channels[].channel、写明无电池兜底阈值与 #16 风险

## 运维加固(服务器侧,非代码)
- 关闭 MySQL/Redis 公网暴露:nginx stream 由 0.0.0.0 改为只监听 127.0.0.1 + ZeroTier
- worker_shutdown_timeout 30s(原先每次 reload 泄漏永不退出的 worker)
- 测试证书改指向 app 证书(原测试证书 9 天后过期且无续期机制);补测试环境 uploads 目录;.env 0600

## 验收
- cargo check 两服务 0 error / 0 warning;cargo test api 88 + device 22 全过
- 路由 79 → 79 逐条一致;web tsc 干净
- 端到端联调(测试环境)13/13,订单结算与重构前逐字段一致;唯一索引已由迁移真实建出
- 生产已部署并验证:服务 active、health 200、ERROR 0、消费链路正常、迁移已建索引
2026-09-20 00:36:37 +08:00

1496 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 柜控板固件对接文档(TCP 接口)
| 项目 | 内容 |
|------|------|
| 文档版本 | **v1.1**(2026-09-14) |
| 交付对象 | 柜控板固件开发工程师 |
| 接口定义方 | 平台侧(本接口由平台方定义,固件按本文实现) |
| 协议性质 | 已冻结:报文格式、字段名、`act` 取值、签名算法一经确认不再变更 |
> 本文档只描述**固件需要实现的内容**。平台内部实现(存储、队列、业务逻辑)不在本文范围内。
---
## ⚠️ 待确认事项(请硬件侧确认;编号与第 10 节一致)
> **除下表 7 项外,本文其余内容均已定稿。** 确认前固件可按"现状"(即本文当前写法)实现;
> 我们拿到答复后会同步更新本文并通知。**这 7 项都不阻塞 4G 层开工。**
| # | 事项 | 现状(本文当前写法) | 需硬件确认 | 影响范围 |
|---|------|---------------------|-----------|----------|
| **13** | **`energy`(单订单累计充电量,Wh)** | 本协议**暂未包含** `energy` 字段 | 硬件是否有该数据源?**若无**,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算 | §5.5 / §5.6 / §5.7 是否补该字段 |
| **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」只覆盖柜级(断电/烟/水/倾倒/门/风扇/接触器/喷淋/声光/门超时),**没有"仓控板通信状态"** | 硬件侧**能提供什么形式的"仓控板在线/离线"信息**?例如:<br>① 每块仓控板一个在线位(如放在 `boards[]` 里)<br>② 柜体数据里加一个「**仓控板离线位图**」(15 块板各一位)<br>③ 柜控板给出"应在线 N 块 / 实际失联 M 块"<br>④ 或明确回答:**硬件无法检测**<br>**并请说明:板离线时 4G 层是省略该板、还是照报全 0 的板?** | §5.3 `status_post` 需新增字段;**平台「仓控板故障」告警完全依赖它**;**并且该答复会直接影响 §5.6 的「无电池」状态兜底** —— 若"板离线时照报全 0 的板",平台会把整板离线误判为"电池被拔走"并提前异常结单(详见 §5.6 / §10 第 14 条) |
| **17** | **直流停电是否还需要 `pow_fail_dc` 这条指令?** | 现在直流停电有**两条路径**:① **事件指令** `pow_fail_dc`(接触器分闸时抢发,见 §5.4.1)② `status_post` 的 `cabinet.status1` **bit0「断电」**(每 60 秒一个状态位)。而**直流停电时柜控板本身仍有电**,状态位理论上足以表达 | **直流停电能不能只用 `status1` bit0 表达、取消 `pow_fail_dc` 这条指令?**<br>请确认:**接触器分闸时,`status1` bit0 是否会置 1?**<br>· 若会 → `pow_fail_dc` 冗余,建议删掉(协议少一条 `act`)<br>· 若不会(bit0 只表示外部市电断)→ `pow_fail_dc` 必须保留 | §5.4.1 / §4.1 / §10 / 附 A.2.2;平台停电状态的数据来源 |
| **18** | **人在没充满时把电池直接拔走(强制拔出)能否检测?** | §5.6 已把「**无论是否充满,被拔时必须先 `off` 再 `bat_out`**」列为**硬性要求**;但**未充满时人为直接拔走**,MCU 不一定能感知 | ① MCU 能否检测**未充满时的强制拔出**(电池状态 bit7 由 1→0)?<br>② 检测到后**能否保证先发 `off` 再发 `bat_out`**?(平台订单正确结束依赖它)<br>③ 若检测不到,请说明 —— 平台侧已实现**兜底**:`status_post` 的 `batt_status` **bit7 连续 2 次为 0(约 2 分钟)**即判定"电池已被取走",**提前异常结单**(`forced`),并提供 **10 小时订单超时**双保险(详见 §5.6) | §5.6;**订单能否正常结束**(漏报时由平台兜底) |
| **19** | **停电恢复后如何兜底?** | 停电期间设备**完全失电**,期间发生的**电池拔出/插入**等事件**无法上报**;恢复后设备重启,平台侧会留下不一致(例如挂着未结束的订单、台账里的电池已不在柜内) | 停电恢复后硬件侧**如何处理**?例如:<br>① 设备恢复后**补报**停电期间发生的事件?<br>② 还是**以当前实际状态为准**——重连后发一次全量 `status_post`,由平台自行对账?<br>③ 或硬件不做特殊处理(平台侧靠 **10 小时订单超时**兜底) | §5.5 / §5.6 订单;**掉电期间的状态对账机制** |
> **关于第 13 项的兜底已落地**:平台侧**已按"积分计算"实现**该兜底 ——
> 每条 `status_post` 的通道电压/电流按 **60 秒**积分累加(协议固定 60 秒一次上报),
> 订单结束时写入订单电量并生成充电记录。**硬件侧无需为此改协议**;
> 若硬件后续确认能提供 `energy` 字段,平台改为**直接采用硬件值**即可(不影响报文结构以外的任何东西)。
> **关于第 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` 返回:
```json
{"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"}
```
此时**停止重试**,改为按重连间隔周期性重试 `login`,等待平台管理员重置。
- 重新申领有频率限制:同一 IMEI **5 次 / 5 分钟**,超限返回 `{"act":"auth_str","msg_id":1,"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 位十进制。<br>**其余报文一律不带** —— 连接建立后设备身份即由该 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 通道。**由平台按该柜已配置拓扑在每次 `login` 时现算**,**恒为 4 位数字、不会是 `"0000"`**(见 5.2) |
| `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`)。
#### 📏 数值口径的统一规则(全文唯一口径,务必按此实现)
| 数据形态 | 出现在哪 | 数值口径 | **谁负责换算** |
|---|---|---|---|
| **hex 字节流** | `status_post.channels[]` 元素、`bat_in`/`bat_out`/`off` 的 `info`、`bat_in` 的 `cells` | **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K …,多字节大端) | **平台换算**(×10 / ×10 / ×10 / ×100,见 5.3 字节布局表)。**固件原样拼接 hex,不做任何换算** |
| **JSON 数值字段** | `cabinet.temp` / `cabinet.meter` / `battery.rated_voltage` / `battery.rated_capacity` 等 | **平台单位**(K×100 / Wh / mV / mAh …) | **设备换算后上报**。平台**原样存库、不再换算** |
> **一句话记住**:**hex 字节流里的数值 = 原始硬件单位,由平台换算;JSON 数值字段 = 平台单位,由设备换算。**
> 本文示例都按各自口径给出,不要互相套用:`"temp":29800` = **已是换算结果**(设备送的 K×100);
> `channels` 里的 `0143` = **硬件原始值**(1K 的 323),由**平台** ×100 得到 32300(K×100)。
### 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`。
示例:
```json
// 平台 → 设备(应答)
{"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` —— 首次获取安全码(重置后可再次获取)
```json
→ {"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` 签名。
- **只能获取一次**。已申领过之后再请求,返回:
```json
← {"act":"auth_str","msg_id":1,"suc":0,"err":"auth_str already issued"}
```
- **要再次获取,必须由平台管理员在后台执行「重置安全码」**。重置后旧安全码**立即失效**,设备可再申领一次新码。
- 收到「auth_str already issued」时,固件应**停止重试**,继续用本地已保存的安全码 `login`;若 `login` 也失败,则按重连间隔周期性重试,等待管理员重置。
- 频率限制:同一 IMEI **5 次 / 5 分钟**,超限返回:
```json
← {"act":"auth_str","msg_id":1,"suc":0,"err":"rate limit exceeded"}
```
> ⚠️ **若该 IMEI 未在平台登记**(未建档的柜子、或非法设备):平台**不发码、不写缓存、不回任何包**(属 8.1 的静默丢弃)。
> 合法设备必然已在平台建档(录入 IMEI),因此正常固件不会遇到这种情况;若遇到,说明设备未录入或 IMEI 写错了。
### 5.2 `login` —— 登录验签
```json
→ {"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 仓)。**由平台在每次 `login` 时按该柜已配置的仓控板 / 通道拓扑现算**;**恒为 4 位数字、不会是 `"0000"`**(拓扑未配置时平台按柜型兜底,见下方说明) |
| **`work_mode`** | string | **充电工作模式**(平台侧可配置,**仅这两种、不再扩展**):<br>`"0"` = **平台指令模式**(默认)——充电由平台下发 `on` 指令发起,设备执行后回执<br>`"1"` = **自动充电工作模式**——设备检测到电池后**自行开始充电**,并**主动上报** `on`(不带 `suc`) |
**固件必须按 `spec` / `work_mode` 行事**
- `spec`:**平台按该柜已配置的仓控板 / 通道拓扑实时生成**(**不是**"设备型号"的持久存储字段),如 `"0606"` = 6 块仓控板 × 每板 6 仓。生成规则:
- 该柜的仓控板 / 通道拓扑**已配置** → 按拓扑统计(仓控板数 × 每板通道数);
- 拓扑**未配置 / 不完整** → **平台按柜型兜底**(按该柜的仓控板数量 × 每板 6 通道生成)。
- 因此 **`spec` 恒为 4 位数字,永远不会是 `"0000"`** —— 固件**不需要**为"收到 0000"做任何特殊处理。
- **固件必须以收到的 `spec` 决定上报结构**:**前 2 位 = 上报几块仓控板,后 2 位 = 每板上报几路通道**(`"0606"` → `boards[]` 6 项、每项 `channels[]` 6 个元素)。
- 若 `spec` 与本机**实际读到的**板数 / 通道数**不一致**:**先按 `spec` 的结构上报**(板数与每板通道数都按 `spec`),把该不一致记入本地日志并经**运维渠道**反馈平台。
- ⛔ **不要自造"规格异常"上行指令** —— `act` 取值是本协议的一部分,平台只识别清单内的 `act`(见 2.3)。
- `spec` **与仓控板实际能否通信无关** —— 某块板扫不到(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`)。
```json
→ {"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` | 整数 | 柜体温度。**⚠️ 这是 JSON 数值字段 = 平台单位,由设备换算后上报**:送 **K×100**(即硬件 1K 的读数 ×100,`29800` = `298.00K`)。平台**原样存库、不再 ×100**;管理端按 `temp/100 − 273.15` 显示为 ℃ |
| `meter` | 整数 | 电表读数。**⚠️ 这是 JSON 数值字段 = 平台单位,由设备换算后上报**:送 **Wh**(即硬件 0.1kWh 的读数 ×100,`125500` = `125.5 kWh` = 125500 Wh)。平台**原样存库、不再 ×100** |
> ⚠️ **`temp` / `meter` 与 `channels[]` 不是同一套口径,千万别混**(全文统一规则见 **3.1**):
> - **`temp` / `meter` 是 JSON 数值字段 → 设备换算**:示例里的 `29800` / `125500` **就是最终值**,**固件不要再乘 100**。
> - **`channels[]` 是 hex 字节流 → 平台换算**:同一条报文里的 `0143` 是**硬件原始值**(323K),平台会 **×100** 得到 32300(K×100)。
#### `channels` —— 每通道 **18 个 hex 字符**(9 字节),**原样透传**
> 一个**通道** = 一个仓(可能**空着**、也可能插着电池)。**不拆字段、直接拼接 hex**——4G 层把硬件那 9 个字节按顺序拼成 18 个 hex 字符即可,**无需做任何转换**。
> ⚠️ 因此 **hex 里的数值一律是原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**换算在平台侧做**(见 **3.1** 统一规则)。
| 项 | 规则 |
|---|---|
| 形式 | **字符串数组**,每个元素 = **恰好 18 个 hex 字符** |
| **大小写** | 平台解析**不区分大小写**(`0e10` 与 `0E10` 等价);**建议固件统一小写** |
| **空仓** | **byte0 = `00`(无电池)**;**byte1 照实报电源状态**(电源与是否有电池**无关**);byte2–8 数值全 `0`。<br>例:空仓但电源正常 → `"008200000000000000"` |
| **⚠️ 两个状态相互独立** | **电池状态(byte0)与电源状态(byte1)是两回事**,平台前端也**分两个指示分开显示**——不要因为"没电池"就把 电源状态 写成 0 |
| **顺序(通道号怎么来)** | **通道号由数组下标决定,下标从 0 开始**:**第 1 个元素(下标 0)= 通道 1**,第 2 个元素(下标 1)= 通道 2 …… 依此类推。**数组顺序不得打乱** |
| **长度** | 数组元素个数 = 该板通道数(当前 36 仓柜型为 **6**),由 `login` 应答的 `spec` 后 2 位决定 |
| 端序 | 多字节字段一律**大端**(高字节在前) |
**每 18 个字符的字节布局**(与硬件「充电动态数据」9 字节逐字节一致)
> 左半是**设备侧**(照抄硬件原始字节),右半是**平台侧**换算(统一规则见 **3.1**)。
| 字节位(hex 字符位) | 含义 | 设备侧:硬件单位(原样拼进 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
```
> 以上**全是硬件原始值**(设备侧原样拼接,不换算)。平台换算后的入库值为:SOC `850‰`、电压 `54200mV`、电流 `5000mA`、温度 `32300`(K×100)。
> `cabinet.status1` / `cabinet.status2`,以及通道 hex 里的 **byte0 / byte1**(电池状态、电源状态)——这 4 个字节的位定义与解析示例统一见 **3.8 状态字节(hex)解析表**。
> ⚠️ **仓控板离线如何上报 —— 待硬件侧确认(待确认事项第 16 项)**
> 若某块仓控板的 485 通信中断(**芯片坏 / 线松 / 板子掉电**),4G 层将**读不到该板的任何数据**。
> **这种情况下 4G 层应如何上报,本文暂未定义** —— 例如:省略这块板?照报一块数值全 `0` 的板?还是另有在线/离线标志?
> **平台能否把"某块板离线"与"该板通道都空着"区分开,完全取决于硬件侧怎么报** —— **以硬件答复为准,本文不做任何预设。**
> **在硬件答复前,平台侧「仓控板故障」这类告警不做实现。**
> ⚠️ 该答复还会直接影响 §5.6 的**「无电池」状态兜底**:若选择"**板离线时照报一块数值全 0 的板**",
> 平台会把**整板离线**误判成"**电池被拔走**"(详见 §5.6),**请一并确认**。
### 5.4 `pow_fail_dc` / `pow_fail_ac` —— 停电上报(**两条独立指令**)
停电上报是**两条独立的 `act`**,**没有 `info` 字段**——看到 `act` 就知道是哪种停电。
#### 5.4.1 `pow_fail_dc` —— 直流停电
```json
→ {"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` —— 交流停电
```json
→ {"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` 里的通道状态才是按**通道**组织。)
```json
→ {"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(仓体级要求 `仓控板-通道`)时:
```json
← {"act":"bat_in","msg_id":5,"suc":0,"err":"invalid id"}
```
**标识与属性**(硬件「电池组属性」38 字节)
| 字段 | 类型 | 说明 |
|------|------|------|
| `battery_id` | string | 电池序列号(ASCII ≤30),**作为电池唯一标识**。<br>**BMS 读不到序列号时传空串 `""`** —— **不影响充电与订单记录**,只是平台不知道是哪块电池 |
| `brand_code` | string | 厂家代码,**原值透传**,由平台解析为名称(`"1"` = 星恒) |
| `model_code` | string | 电池型号,**原值透传**,由平台解析为名称(`"1"` = 磷酸铁锂) |
| `material_code` | string | 电芯材料,**原值透传**,由平台解析 |
| `cell_count` | 整数 | 电芯节数 |
| `rated_voltage` | 整数 | 额定电压。**JSON 数值字段 = 平台单位,由设备换算后上报**:送 **mV**(硬件 0.1V 的读数 ×100,`48000` = `48.0V`)。平台原样存库 |
| `rated_capacity` | 整数 | 额定容量。**JSON 数值字段 = 平台单位,由设备换算后上报**:送 **mAh**(硬件 0.1AH 的读数 ×100,`24000` = `24.0Ah`)。平台原样存库 |
**动态数据**(硬件「充电动态数据」9 字节)
| 字段 | 类型 | 说明 |
|------|------|------|
| `info` | string(**恰好 18 个 hex 字符**) | 该通道的 9 字节**电池信息**(硬件「充电动态数据」)。**布局与解析规则完全同 5.3 的 `channels` 元素**:byte0 电池状态 / byte1 电源状态 / byte2 SOC / byte3–4 电压 / byte5–6 电流 / byte7–8 电芯最高温度,多字节**大端**。<br>⚠️ **hex 内是原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**由平台换算**(见 3.1 统一规则),**固件不换算** |
> `info` 与 `status_post.channels[]` 里的元素是**同一种 18 字符 hex**,只是这里只针对"刚插入的这个通道"。
**电芯数据**(硬件「电芯数据」表)—— **同样拼接 hex,不拆字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| `cells` | string | 电芯数据**整段拼接**的 hex 字符串,长度 = **`cell_count × 4 + 16`** 个字符 |
**`cells` 的段布局**(按硬件「电芯数据」表顺序,多字节一律**大端**)
> ⚠️ `cells` 也是 **hex 字节流 = 原始硬件单位**(电压、温度均为硬件原值),**由平台换算,固件只负责按 `cell_count` 顺序拼接**(见 3.1 统一规则)。
| 段 | 长度(hex 字符) | 含义 | 设备侧:硬件单位(原值) |
|---|---|---|---|
| 电芯电压 × N | **N × 4**(N = `cell_count`) | 各电芯电压,依次排列,每节 2 字节 | 硬件原值(示例按 mV 对照) |
| 最高电压 | 4 | 所有电芯中的最高电压,2 字节 | 硬件原值 |
| 最低电压 | 4 | 最低电压,2 字节 | 硬件原值 |
| 最高温度 | 4 | 电芯最高温度,2 字节 | 1K |
| 最低温度 | 4 | 电芯最低温度,2 字节 | 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 = 3600 0e15 = 3605 0e0e = 3598 …(硬件原始值) =3607 =3595 =298K =296K
```
> 上例各段均为**硬件原始值**,平台侧再按 3.1 的规则换算(温度 ×100 → K×100)。
> ⚠️ **待确认第 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 项。**
```json
→ {"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;**hex 内是原始硬件单位,由平台换算**)。
- 平台结束该仓体活跃订单(**`reason='user_stop'`**),并把电池台账置空闲。
**平台兜底:订单不会永远挂着**
设备可能**来不及/无法**上报结束事件——例如**停电或离线期间,人把电池取走了**(设备根本没机会发 `bat_out`)。为此平台侧有兜底:
| 兜底 | 规则 |
|---|---|
| **订单超时兜底(主要)** | **充电订单**处于"进行中"**超过 10 小时**未结束 → 平台**自动结束该订单**(`reason='timeout'`)。<br>依据:**充电不可能超过 10 小时**(再久电池会出问题) |
| **「无电池」状态兜底(次要,平台侧已实现并生效,固件无需实现)** | 该通道**已有活跃订单**、而 `status_post` 报该通道 **`batt_status` bit7 = 0(无电池)连续 2 次** → 平台判定"**电池已被取走**"并**提前结束该订单**。阈值、后果与风险见下表 |
**「无电池」状态兜底细则(平台侧行为,固件不需要做任何事)**
| 项 | 内容 |
|---|---|
| **判定依据** | `status_post` 里该通道 18 字符 hex 的 **byte0 `batt_status` bit7 = 0**(0=无电池 / 1=有电池,位定义见 **3.8**) |
| **阈值** | **连续 2 次**上报为 0。`status_post` 固定每 60 秒一次 → **约 2 分钟**;平台对账任务每 **5 分钟**跑一次,故实际**最迟约 5–7 分钟**内结单。单次瞬时抖动**不会**触发 |
| **前提** | 该通道**已存在活跃订单**(空仓、没建单的通道不受影响) |
| **后果** | 平台把该订单**提前结束**,结束原因记为 **`forced`(异常结束)**,充电记录状态记为**异常**;电池台账置空闲。人工可在充电记录里识别这批异常单 |
| **与待确认 #18 的关系** | 这正是**第 18 项(未充满强制拔出能否检测)**的平台侧兜底:MCU 检测不到、发不出 `off`/`bat_out` 时,靠它避免订单一直挂到 10 小时超时 |
| ⚠️ **与待确认 #16 的关系(给硬件侧的输入)** | 若 #16 最终选择「**板离线时照报一块数值全 0 的板**」,则**该板所有通道的 bit7 都会是 0** → 平台会把「**整板离线**」误判成「**电池被拔走**」,**把该板上所有活跃订单提前异常结单**。<br>若选择「**省略该板**」,平台读不到该板通道 → 兜底不触发(该通道订单退回 10 小时超时兜底)。<br>**请在答复 #16 时一并说明离线时的上报方式。** |
> **固件侧不必为"补报历史事件"负责**:漏报的结束事件由上面两条兜底收尾。
> 但**停电恢复后**是否需要设备/平台做一次状态对账,仍待硬件侧答复(见文首待确认事项第 19 项)。
### 5.7 `on` / `off` —— 设备侧上报
**`on`(开始充电)—— 两种形态,靠有无 `suc` 区分**
设备支持两种充电启动方式:
| 工作模式 | 谁发起充电 | 设备发出的 `on` |
|---|---|---|
| **平台指令模式**(默认) | 平台下发 `on` 指令 | **回执形态**:带 `suc`,`msg_id` 回带平台号 |
| **自动充电工作模式** | 设备检测到电池后**自行开始充电** | **上报形态**:**不带 `suc`**,`msg_id` 用设备自己的计数 |
> **当前处于哪种模式,由 `login` 成功应答里的 `work_mode` 下发**(见 5.2):`"0"`=平台指令模式、`"1"`=自动充电工作模式。
> 平台侧可配置、后台可改;**每次 `login` 都以本次应答为准**(改了配置,设备重连后即生效)。
```json
// ① 平台指令模式:平台先下发 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`(充电停止)**
```json
→ {"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**;**以上均为 hex 内的原始硬件值,平台再按 3.1 换算**)
**`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` 指令后,固件执行完必须回执:
```json
→ {"act":"open","msg_id":1,"suc":1}
```
执行异常时:
```json
→ {"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` **恒为 4 位数字、不会是 `"0000"`**(平台按该柜拓扑生成,拓扑未配置时按柜型兜底)
- [ ] **以收到的 `spec` 决定上报结构**:前 2 位 = 上报几块仓控板、后 2 位 = 每板上报几路通道(如 `"0606"` → 6 块板 × 每板 6 通道)
- [ ] `spec` 与本机实际读到的板数 / 通道数不符时:**先按 `spec` 的结构上报**,并记日志反馈平台(**不得自造新的 `act`**,见 2.3)
- [ ] 按 `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`(hex 2 位小写)与通道 hex 的 **byte0 / byte1** 均按 **3.8** 位定义构造
- [ ] 每个通道 hex **恰好 18 个字符**、多字节**大端**;**通道号由数组下标决定(第 1 个元素 = 通道 1)**,数组顺序不得打乱
- [ ] **数值口径**(见 **3.1** 统一规则):`cabinet.temp` 送 **K×100**、`cabinet.meter` 送 **Wh**(**设备换算**);`channels[]` / `info` / `cells` 一律是 **hex 原始硬件单位**(**4G 层零换算**,换算由平台做)
- [ ] 上报间隔 **固定 60 秒/次**,且每次为**全量**(所有仓控板 × 全部通道,无增量、无分片)
- [ ] **空仓也上报**:byte0 = `00`(无电池),**byte1 照实报电源状态**(如 `82`),数值全 `0`
**业务时序**
- [ ] `bat_in` → 平台登记电池台账(**不建单**)→ 收到 `suc=1`
- [ ] `on`(开始充电)→ 平台**建单**(`work_mode="0"` 平台下发 `on`;`"1"` 设备上报 `on`)
- [ ] 收到 `on` 指令并执行后回执
- [ ] 充满上报 `off`
- [ ] `bat_out` 上报
- [ ] 拔出电池时**先 `off` 再 `bat_out`**;若未充满强制拔出检测不到,平台会在**该通道连续 2 次(约 2 分钟)报无电池**后**提前异常结单**(`forced`,见 §5.6 状态兜底)
- [ ] 断线重连后能重新 `login` 并继续上报
---
## 10. 约定定稿
以下约定已定稿,固件按此实现:
| # | 事项 | 定稿值 |
|---|------|--------|
| 1 | **接入地址** | 测试 **`118.31.119.206:3001`**;正式 **`118.31.119.206:9001`**。TCP 由平台 TCP 服务直接监听,**不经域名/nginx** |
| 2 | **`status_post` 的板号与通道** | `boards[].board` 取 **1–15**(当前 36 仓柜型实装 6 块,按 `spec` 前 2 位上报)。<br>**`channels` 是字符串数组**(每个元素 = 该通道的 **18 字符 hex**),**元素本身没有 `channel` 字段** —— **通道号由数组下标决定**:**第 1 个元素(下标 0)= 通道 1**,第 2 个元素(下标 1)= 通道 2 ……,数组长度 = 每板通道数(当前柜型为 6,即 1–8 / 1–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 位字符串、恒为数字、不会是 `"0000"`**)= **前 2 位仓控板数 + 后 2 位每板通道数**;当前 36 仓柜型为 **`"0606"`**(6 块仓控板 × 每板 6 仓)。**平台在每次 `login` 时按该柜已配置的仓控板 / 通道拓扑现算**(**不是**"设备型号的持久存储"):拓扑已配置 → 按拓扑统计;**拓扑未配置 / 不完整 → 按柜型兜底**(该柜仓控板数 × 每板 6 通道)。<br>**固件必须以收到的 `spec` 决定上报几块板 / 每板几路通道**(前 2 位 = 板数、后 2 位 = 每板通道数);若与实际不符,**先按 `spec` 上报**并记日志反馈平台,**不得自造新 `act`**。<br>旧的 `cell`(仓控板数量)**不再下发给设备**(平台仅在生成 `spec` 兜底时内部使用),`work_mode`(`"0"`=平台指令模式 / `"1"`=自动充电工作模式)**平台侧可配置、后台可改**,每次 `login` 以最新配置为准 |
| 10 | **连接登录门控** | **只有 `auth_str` / `login` 可以在未登录状态发送**;**其余所有 `act`(含 `status_post`、`bat_in`、`on`/`off`、回执等)必须先 `login` 成功**,否则平台**丢弃且不回任何应答**。**未登录的 TCP 连接不予保持**:连接建立后 **30 秒内**未 `login` 成功,平台**主动断开**(固件应建连后立即 `auth_str`/`login`) |
| 11 | **停电上报是两条独立指令** | **没有 `pow_fail` 这条指令,也没有 `info` 字段**。停电上报分两条独立 `act`:<br>`pow_fail_dc` = **直流停电** —— 柜内**接触器分闸**(含远程 `pow_off`)导致**仓控板下电**,柜控板仍有电、可正常上报;<br>`pow_fail_ac` = **交流停电** —— **柜控板自身断电**(市电断),靠**板载电容余电抢发最后一条**,**与接触器无关**。<br>两条均**无附加字段**,**事件上报**:平台收到即**记录并置为该柜的停电状态**,**前端显示**(不与其他业务逻辑联动);**供电恢复后消除该状态** |
| 12 | **通道数据用 hex 透传,不拆字段** | `status_post.channels[]` 的每个元素 = **恰好 18 个 hex 字符**(硬件「充电动态数据」9 字节**原样拼接**,4G 层**不做任何转换**);`bat_in` 里电池信息用同格式的 **`info`**,电芯数据用 **`cells`**(整段拼接 hex,长度 = `cell_count × 4 + 16`)。<br>**字节布局**:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V) / byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K),多字节一律**大端**。<br>**通道号由数组下标决定**(第 1 个元素 = 通道 1),数组顺序不得打乱;**空仓** = byte0 `00`(无电池)+ **byte1 照实报电源状态** + 数值全 `0`(例 `"008200000000000000"`)。<br>**电池状态与电源状态相互独立**,两回事,前端分开显示 |
| 13 | **数值口径:hex 由平台换算、JSON 由设备换算** | **统一规则(见 3.1,全文唯一口径)**:<br>① **hex 字节流**(`channels[]` 元素、`info`、`cells`)= **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**平台换算**(×10/×10/×10/×100),**固件原样拼接、不做任何换算**;<br>② **JSON 数值字段**(`cabinet.temp` / `cabinet.meter` / `battery.rated_voltage` / `battery.rated_capacity`)= **平台单位**,**设备换算后上报**:`temp` 送 **K×100**(`29800` = 298.00K,管理端按 `temp/100 − 273.15` 显示 ℃)、`meter` 送 **Wh**(`125500` = 125.5 kWh)、`rated_voltage` 送 **mV**、`rated_capacity` 送 **mAh**。平台对 JSON 数值**原样存库、不再换算** |
| 14 | **订单结束的兜底(平台侧行为,固件无需实现)** | ① **订单超时(主要)**:订单"进行中"超过 **10 小时**未结束 → 平台自动结束(`reason='timeout'`)。<br>② **「无电池」状态兜底(已实现并生效)**:该通道**已有活跃订单**、而 `status_post` 的 **`batt_status` bit7 = 0(无电池)连续 2 次**(`status_post` 每 60 秒一次 → **约 2 分钟**;平台对账每 5 分钟一次,故最迟约 5–7 分钟)→ 平台判定"**电池已被取走**",**提前结束该订单**:原因 **`forced`(异常)**,充电记录状态记为**异常**。<br>⚠️ **风险(待确认第 16 项)**:若"**板离线时照报全 0 的板**",该板所有通道 bit7 均为 0 → 平台会把**整板离线**误判为**电池被拔走**,**把该板上所有活跃订单提前异常结单**;若"**省略该板**"则不触发(退回 10 小时超时兜底)。**请在答复第 16 项时一并说明离线时的上报方式**(详见 §5.6) |
**尚未定稿(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` —— 首次获取安全码
```jsonc
→ {"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` —— 登录验签(成功)
```jsonc
→ {"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` —— 失败(平台随即断开连接)
```jsonc
← {"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 秒全量)
```jsonc
→ {"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)——【JSON 字段,由设备换算】
// meter → 电表读数 Wh(125500 = 125.5 kWh)——【JSON 字段,由设备换算】
// boards[] : 所有仓控板(board 1–15),每板带全部通道
// channels = **每通道 18 个 hex 字符**(9 字节原样透传)
// 通道号由**数组下标**决定(第 1 个元素 = 通道 1);多字节**大端**
// byte0 电池状态 / byte1 电源状态(位定义见 §3.8)
// byte2 SOC(1%) / byte3–4 电压(0.01V)
// byte5–6 电流(0.01A) / byte7–8 电芯最高温度(1K)
// ⚠️ hex 内是【原始硬件单位】,换算由平台做(见 §3.1)
// 空仓 = byte0 "00"(无电池)+ byte1 照实报电源状态 + 数值全 0
// 例:"008200000000000000" = 空仓、但电源有电且已打开
// 节奏:固定每 60 秒一次,每次全量(不区分空闲/充电、不做增量)
// 本报文同时是【保活报文】:平台 300 秒无任何上行即判离线
← {"act":"status_post","msg_id":3,"suc":1}
// suc : 1=已接收
```
#### A.2.2 `pow_fail_dc` —— 直流停电
```jsonc
→ {"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` —— 交流停电
```jsonc
→ {"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` —— 电池插入
```jsonc
→ {"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)——【JSON 字段,设备按硬件 0.1V ×100 换算后上报】
// rated_capacity : mAh(24000 = 24.0Ah)——【JSON 字段,设备按硬件 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
// ⚠️ 以上都是 hex 内的【原始硬件单位】,换算由平台做(见 §3.1)
// ⚠️ 插入首帧时平台还没下发 on,故不应是"正在充电"
// cells : 电芯数据整段拼接 hex,长度 = cell_count × 4 + 16
// (示例 15 节 → 76 字符:15 节电压 + 最高 + 最低 + 最高温 + 最低温)
// 只随 bat_in 带一次,不进周期上报;段内同为【原始硬件值】,平台换算
// (SOC / 电压 / 电流 / 温度不再单独成字段:都在 info 的 18 字符 hex 里,见上)
// ⚠️ 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` —— 电池拔出
```jsonc
→ {"act":"bat_out","msg_id":6,"id":"01-3",
"battery":{"battery_id":"SN123456","info":"80005f15180000012a"}}
// MCU 约定:先发 off,再发 bat_out
// battery 至少带 battery_id,建议带末帧动态 info(18 字符 hex,规则同 §5.3;hex 内为原始硬件值,平台换算)
← {"act":"bat_out","msg_id":6,"suc":1}
// 平台动作:结束订单(reason='user_stop')、电池台账置空闲
```
### A.4 设备上报 / 回执(`on` / `off`)
#### A.4.1 `on` —— 开始充电(两种形态)
```jsonc
// ── ① 平台指令模式:平台先下发 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` —— 充满自动停止(设备主动上报)
```jsonc
→ {"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` —— 开始充电(仓体级)
```jsonc
← {"act":"on","msg_id":2,"id":"01-3"}
// msg_id : 由平台分配,固件回执时【必须原样回带】,不要自己改号
// id : 两段【仓控板-通道】
// 固件动作:对该仓体开始充电 → 回执(见 A.6)
```
#### A.5.2 `off` —— 停止充电(仓体级)
```jsonc
← {"act":"off","msg_id":3,"id":"01-3"}
// 固件动作:停止该仓体充电 → 回执(带 suc、msg_id 原样回带)
// 区分同名的两种 off 靠【方向】,不靠 suc:
// 下行 off = 平台指令(本条)→ 固件执行后回执,平台不回包
// 上行 off = 设备上报(A.4.2)→ 平台按新请求处理并回包
// 固件收到的一定是下行,按"指令"处理即可,无需自行判别
```
#### A.5.3 `open` —— 柜门解锁(柜级,整柜)
```jsonc
← {"act":"open","msg_id":1}
// id : 【柜级指令不带 id】(省略该字段)—— id 只表示【动作目标】、不含 IMEI,
// 设备身份由本 TCP 连接确定(见 §3.7);平台内部路由字段不下发到设备
// 含义 : 对【整柜唯一的柜门】执行【解锁】—— 门不会自动打开,需人拉开
// 协议没有"按仓体开门"的指令,也没有"关门"指令
// 固件动作:解开门锁 → 回执;失败时回执带 err(如 "door lock not engaged")
// 状态回传: 柜门开/关由 status_post 的 cabinet.status1 bit4 带回
// (0=关闭 / 1=打开);关门为人手动,最迟 60 秒内上报
// ⚠️ 本产品为工厂集中充电柜:柜门动作【独立】,与充电逻辑互不联动
// 开门不触发充电;充电的启停也不依赖柜门状态
```
#### A.5.4 `pow_on` —— 接触器合闸(柜级)
```jsonc
← {"act":"pow_on","msg_id":4}
// 固件动作:接触器【合闸】→ 回执
// 后果:整柜上电(含各仓控板)→ 仓体/电池数据恢复
// 状态回传:接触器状态见 status_post 的 cabinet.status1 bit6(0导通 / 1断开)
```
#### A.5.5 `pow_off` —— 接触器分闸(柜级)
```jsonc
← {"act":"pow_off","msg_id":5}
// 固件动作:接触器【分闸】→ 回执
// ⚠️ 后果:整柜下电,【仓控板随之下电】
// → 该柜所有仓体/电池数据不可用,且设备会因此上报
// pow_fail_dc(直流停电,见 A.2.2)
// → 恢复供电需再下发 pow_on
// 属【运维动作】(检修/停用/复位总电),不是充放电业务
```
#### A.5.6 `reset` —— 重启(柜级 / 单仓控板)
```jsonc
← {"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` 为例)
```jsonc
← {"act":"open","msg_id":1}
→ {"act":"open","msg_id":1,"suc":1}
// act : 与收到的指令一致(回执必带)
// msg_id : 与收到的指令完全一致 —— 必须原样回带
// id : 建议回带,与指令一致
// suc : 1=执行正常
// 注意 : 【不带 dev_id】—— 回执走同一条连接,平台已知道是哪台设备
```
#### A.6.2 失败回执
```jsonc
→ {"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}
// suc=0 时 err【必带】;平台据此在管理端显示"执行失败"
```
### A.7 平台异常应答(`suc=0` 时 `err` 必带)
```jsonc
← {"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 秒,双向对称)
```jsonc
← {"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` 单板 / 三段=仓体级)**(⚠️ 该 `id` 段数规则与最终定稿完全相反,已被 ㉛ 修正并经 ㉟ 确认,作废;当前生效规则见 §3.7)** ⑯ **补充 `pow_on` / `pow_off` 业务语义**:远程通断柜内总电(接触器)——**`pow_off` 分闸后仓控板随之下电**(仓体/电池数据不可用,并因此产生 `pow_fail` `info:"0"` 直流停电),属运维动作;`pow_fail` 与 `cabinet.status1` bit0 的关系(事件 vs 状态)在 §5.4 说明 **(⚠️ 本条中的 `pow_fail` / `info:"0"` 已被 ⑱ 取代,作废)** ⑰ **`pow_fail` 的 `info` 区分定稿**(判定依据 = **"谁掉电"**):`"0"`=直流停电(**接触器分闸**→仓控板下电,柜控板仍有电)/ `"1"`=交流停电(**柜控板自身断电**,靠**板载电容余电抢发最后一条**,与接触器无关);该条由「待确认」转入「约定定稿」第 11 条,待确认事项由 4 项缩减为 **3 项** **(⚠️ 已被 ⑱ 取代,作废:`pow_fail` 与 `info` 字段均已取消)** ⑱ **停电上报更正为两条独立指令**:**取消 `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 项**(**该编号后调整为第 16 项,见文首「待确认事项」**):**仓控板离线(485 芯片坏 / 线松 / 掉电)如何上报** —— **4G 层读不到该板数据时该怎么报尚未约定**(省略该板?照报全 0 的板?另有在线标志?),**以硬件答复为准、本文不预设结论**(§5.3 同步加警示) ⑳ **`login` 失败统一为单一应答**:取消 `invalid timestamp` / `auth_str not found`,**只返回 `invalid signature`**(不区分失败原因,避免外部试探系统状态——安全设计);同时修正 `spec` 的定位为「平台对柜子的规格定义(型号)」,**与仓控板实际能否通信无关** **(⚠️ 其中"型号"的措辞已在 ㊲ 更正:`spec` 由平台按拓扑现算、**不承诺"型号存储"**,见 §5.2 / §10 第 9 条)**;`work_mode` 明确**仅两种、不再扩展** ㉑ **`status_post` 字段改名**:`batteries[]` → **`channels[]`**、内层 `group` → **`channel`**(那是**仓板的通道**、可能空着,不是纯电池信息;同时与平台侧 `channel_id` 对齐)**(⚠️ 该 `channel` 字段已在 ㉒ 随之取消:`channels[]` 改为**字符串数组**,元素内不含任何字段名,**通道号由数组下标决定**,见 §5.3 / §10 第 2 条)**;`id` 第三段说明改为「通道」;全文「电池组」按语境改为「通道」(硬件表名「电池组属性」保留) ㉒ **通道状态改为 hex 透传(大幅瘦身)**:`status_post.channels[]` 由"每通道 6 个命名字段"改为 **每通道 18 个 hex 字符**(9 字节原样拼接,**4G 层零转换**),**数组下标 = 通道号**(**精确定义:数组下标从 0 开始,第 1 个元素 = 通道 1**,见 §5.3 / §10 第 2 条);`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 前缀**(内部改动) **(⚠️ 仅本条末句被 ㉟ 取代、作废:`id` 不下发 IMEI,"补回 IMEI 前缀"的做法已废弃,改由**内部路由字段 `_dev_id`** 定位目标设备;`id` 只表示动作目标、不含 IMEI 的规则本身仍然生效,见 §3.7)** ㉜ **`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 前缀";当前生效口径:`id` 只表示动作目标、**不含 IMEI**,设备身份由 TCP 连接确定,平台内部路由字段一律不下发 —— 与 §3.7 / §8.3 一致)** ㊱ **补两条边界说明**:① **未登记的 IMEI 发 `auth_str` 时平台静默丢弃**(不发码/不写缓存/不回包,见 §5.1 与 §8.1 表)—— 合法设备必然已建档,正常固件不会遇到;② **电池属性按原值入库**(`brand_code`/`model_code`/`material_code`/`cell_count`/`rated_voltage`/`rated_capacity` 均入电池台账,映射出的中文名留待代码表确认后回填,与 §10 定稿第 7 条一致) ㊲ **交付前一致性修正(本文与实现对齐,不改动任何已冻结的协议行为)**:<br>① **新增 §3.1「数值口径的统一规则」并全文贯彻** —— **hex 字节流(`channels[]` / `info` / `cells`)= 原始硬件单位、由平台换算;JSON 数值字段(`cabinet.temp` / `cabinet.meter` / `rated_voltage` / `rated_capacity`)= 平台单位、由设备换算**;据此改正 §5.3 `temp`/`meter`(设备送 K×100 / Wh,平台原样存库)、§5.5 `info`/`cells`/额定值的说明,使"字段说明"与"JSON 示例"完全自洽。<br>② **`spec` 口径更正**:由平台**每次 `login` 按该柜已配置的仓控板/通道拓扑现算**(**不再**写"作为柜子型号存储"),**拓扑未配置/不完整时按柜型兜底**,**恒为 4 位数字、不会是 `"0000"`**;§5.2 / §9 自测清单 / §10 第 9 条同步补上"**固件应以收到的 `spec` 决定上报几块板、每板几路通道**;收到与预期不符应先按 `spec` 上报并记日志反馈平台(不得自造新 `act`)"。<br>③ **附 A.5.3 注释更正**:`open` 为柜级指令**不带 `id`**(原注释"只写 IMEI"与 §3.7 矛盾);并明确 ㉛ 末句"下发时补回 IMEI 前缀"**已被 ㉟ 取代(作废)**,⑮ 的 `id` 段数规则、⑯/⑰ 的 `pow_fail` `info` 规则均**在原处标注作废**、保留可追溯;⑲ 的"第 12 项"标注为**后调整为第 16 项**。<br>④ **§10 第 2 条更正**:`channels` 是**字符串数组**、元素**没有 `channel` 字段**,**通道号由数组下标决定**(第 1 个元素 = 通道 1);§5.3 同段措辞统一为同一句话。<br>⑤ **「无电池」状态兜底写实**(§5.6 与 §10 新增第 14 条):判定依据 `batt_status` bit7、**连续 2 次(约 2 分钟)**、后果为 **`forced` 异常结单**,并写明与待确认第 16/18 项的关系及"照报全 0 的板会误判"的风险。<br>⑥ **结构修复**:文首「⚠️ 待确认事项」表中被引用块切断的 7 行表格恢复连续(引用块移至表格之后);§5.1 示例中不可解析的 `msg_id:N` 占位改为具体编号;附 A.3.1 清理陈旧注释(`soc/voltage/current/temp`、重复的 `cells` 行) |
| v1.0 | 2026-09-14 | 首版:基于平台 TCP 服务实现逐项核对生成 |