# 柜控板固件对接文档(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」只覆盖柜级(断电/烟/水/倾倒/门/风扇/接触器/喷淋/声光/门超时),**没有"仓控板通信状态"** | 硬件侧**能提供什么形式的"仓控板在线/离线"信息**?例如:
① 每块仓控板一个在线位(如放在 `boards[]` 里)
② 柜体数据里加一个「**仓控板离线位图**」(15 块板各一位)
③ 柜控板给出"应在线 N 块 / 实际失联 M 块"
④ 或明确回答:**硬件无法检测**
**并请说明:板离线时 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` 这条指令?**
请确认:**接触器分闸时,`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` 的 `batt_status` **bit7 连续 2 次为 0(约 2 分钟)**即判定"电池已被取走",**提前异常结单**(`forced`),并提供 **10 小时订单超时**双保险(详见 §5.6) | §5.6;**订单能否正常结束**(漏报时由平台兜底) |
| **19** | **停电恢复后如何兜底?** | 停电期间设备**完全失电**,期间发生的**电池拔出/插入**等事件**无法上报**;恢复后设备重启,平台侧会留下不一致(例如挂着未结束的订单、台账里的电池已不在柜内) | 停电恢复后硬件侧**如何处理**?例如:
① 设备恢复后**补报**停电期间发生的事件?
② 还是**以当前实际状态为准**——重连后发一次全量 `status_post`,由平台自行对账?
③ 或硬件不做特殊处理(平台侧靠 **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 位十进制。
**其余报文一律不带** —— 连接建立后设备身份即由该 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 | **充电工作模式**(平台侧可配置,**仅这两种、不再扩展**):
`"0"` = **平台指令模式**(默认)——充电由平台下发 `on` 指令发起,设备执行后回执
`"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`。
例:空仓但电源正常 → `"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),**作为电池唯一标识**。
**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 电芯最高温度,多字节**大端**。
⚠️ **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'`)。
依据:**充电不可能超过 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** → 平台会把「**整板离线**」误判成「**电池被拔走**」,**把该板上所有活跃订单提前异常结单**。
若选择「**省略该板**」,平台读不到该板通道 → 兜底不触发(该通道订单退回 10 小时超时兜底)。
**请在答复 #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 位上报)。
**`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 通道)。
**固件必须以收到的 `spec` 决定上报几块板 / 每板几路通道**(前 2 位 = 板数、后 2 位 = 每板通道数);若与实际不符,**先按 `spec` 上报**并记日志反馈平台,**不得自造新 `act`**。
旧的 `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`:
`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 个元素 = 通道 1),数组顺序不得打乱;**空仓** = byte0 `00`(无电池)+ **byte1 照实报电源状态** + 数值全 `0`(例 `"008200000000000000"`)。
**电池状态与电源状态相互独立**,两回事,前端分开显示 |
| 13 | **数值口径:hex 由平台换算、JSON 由设备换算** | **统一规则(见 3.1,全文唯一口径)**:
① **hex 字节流**(`channels[]` 元素、`info`、`cells`)= **原始硬件单位**(SOC 1% / 电压 0.01V / 电流 0.01A / 温度 1K),**平台换算**(×10/×10/×10/×100),**固件原样拼接、不做任何换算**;
② **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'`)。
② **「无电池」状态兜底(已实现并生效)**:该通道**已有活跃订单**、而 `status_post` 的 **`batt_status` bit7 = 0(无电池)连续 2 次**(`status_post` 每 60 秒一次 → **约 2 分钟**;平台对账每 5 分钟一次,故最迟约 5–7 分钟)→ 平台判定"**电池已被取走**",**提前结束该订单**:原因 **`forced`(异常)**,充电记录状态记为**异常**。
⚠️ **风险(待确认第 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 条一致) ㊲ **交付前一致性修正(本文与实现对齐,不改动任何已冻结的协议行为)**:
① **新增 §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 示例"完全自洽。
② **`spec` 口径更正**:由平台**每次 `login` 按该柜已配置的仓控板/通道拓扑现算**(**不再**写"作为柜子型号存储"),**拓扑未配置/不完整时按柜型兜底**,**恒为 4 位数字、不会是 `"0000"`**;§5.2 / §9 自测清单 / §10 第 9 条同步补上"**固件应以收到的 `spec` 决定上报几块板、每板几路通道**;收到与预期不符应先按 `spec` 上报并记日志反馈平台(不得自造新 `act`)"。
③ **附 A.5.3 注释更正**:`open` 为柜级指令**不带 `id`**(原注释"只写 IMEI"与 §3.7 矛盾);并明确 ㉛ 末句"下发时补回 IMEI 前缀"**已被 ㉟ 取代(作废)**,⑮ 的 `id` 段数规则、⑯/⑰ 的 `pow_fail` `info` 规则均**在原处标注作废**、保留可追溯;⑲ 的"第 12 项"标注为**后调整为第 16 项**。
④ **§10 第 2 条更正**:`channels` 是**字符串数组**、元素**没有 `channel` 字段**,**通道号由数组下标决定**(第 1 个元素 = 通道 1);§5.3 同段措辞统一为同一句话。
⑤ **「无电池」状态兜底写实**(§5.6 与 §10 新增第 14 条):判定依据 `batt_status` bit7、**连续 2 次(约 2 分钟)**、后果为 **`forced` 异常结单**,并写明与待确认第 16/18 项的关系及"照报全 0 的板会误判"的风险。
⑥ **结构修复**:文首「⚠️ 待确认事项」表中被引用块切断的 7 行表格恢复连续(引用块移至表格之后);§5.1 示例中不可解析的 `msg_id:N` 占位改为具体编号;附 A.3.1 清理陈旧注释(`soc/voltage/current/temp`、重复的 `cells` 行) |
| v1.0 | 2026-09-14 | 首版:基于平台 TCP 服务实现逐项核对生成 |