# 柜控板固件对接文档(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 服务实现逐项核对生成 |