charging-cabinet/test/tcp_e2e_smoke.py
12451 619c96c85f docs: 文档分类整理 —— AGENTS.md 只留判据与链接,详细篇按用途归入 docs/ 子目录
## 为什么要做

`AGENTS.md` 曾达 **90,370 B**,而工作区指令预算是 **65,536 B** —— 系统按**保留头部**截断,
于是**尾部约 25 KB 永远不会被加载**:组织架构、数据模型、TCP 协议、数据单位规范、
微信小程序用户端**全部处于"写了但读不到"的状态**。手册实际上部分失效。

## 一、AGENTS.md 瘦身:90,370 → 60,227 B(余量 5,309 B,尾部不再被截断)

三个**纯参考型大段**整段搬迁到 `docs/tech/`(**内容逐字保留,未删改**),
原位置换成「判据 + 链接」:

| 原 AGENTS.md 章节 | 大小 | 现位置 |
|---|---|---|
| `## 部署与维护` | 17,611 B | `docs/tech/部署手册.md` |
| `## 数据模型` | 12,100 B | `docs/tech/数据模型.md` |
| `## 微信小程序用户端` | 10,262 B | `docs/tech/小程序用户端.md` |

替换后的章节**只留改动前必须先知道的硬约束**(例如:产物名与测试环境落地名不一致、
ETXTBSY 的 `.new`→`stop`→`mv`→`start` 流程、`stream{}` 不可重加、发布顺序 device 先 api 后、
建表顺序即依赖顺序且写错报 MySQL 1824、时间列必须 DATETIME、`cabinets.status` 是配置态不表示在线……),
其余指向详细篇。**判据留在常驻加载的 AGENTS.md,逐项参考移出**。

## 二、docs/ 分类(原 21 个文件平铺 → 五个用途目录)

```
docs/
├── README.md          新增:文档地图(分类/权威性/旧名→现名映射/维护约定)
├── protocol/          设备协议(冻结,对硬件交付)★唯一权威
│   ├── 柜控板固件对接文档.md
│   └── 1TN协议标准_*.md / *.pdf
├── tech/              平台技术文档(开发/运维必读)
│   ├── API.md · 数据模型.md · 部署手册.md · 小程序用户端.md
├── contract/          合同·规格·验收标准(商务依据,不描述实现)
├── compound-notes/    单点经验笔记(原有)
└── archive/           ⛔ 历史留痕(只读,不代表现状,不可作依据)
    ├── 开发文档.md(早于现行实现)
    └── 审查报告/深度分析报告-2026-07-06
```

- 19 个文件用 `git mv` 迁移(保留历史),3 个未跟踪文件用普通移动;
- **内容零改动**(唯一例外是引用替换),已核验 `docs/tech/API.md` 的 `git diff` 为空。

## 三、全仓引用同步:40 处 / 10 个文件

路径不只出现在文档里,还散落在**源码注释**(`db/migrate.rs`、`routes/cabinets.rs`、
`workers/report_worker.rs`、`tcp/connection.rs`、`h5-uniapp/utils/format.ts`)、
`test/tcp_e2e_smoke.py`、`tasks/001-*.md`、`README.md`、`AGENTS.md` 中,全部同步。

**并已实测校验命令能失败**:往 `docs/tech/API.md` 植入 `<!-- probe: docs/API.md -->`
→ 命中;删除 → 无输出。
> ⚠️ 过程中踩了一个坑并已修:这些 `.md` 多为 **CRLF**,我第一次用 `\n` 正则删除时
> **没匹配上 `\r\n`**,以为删干净了其实残留了一行 —— 靠 `git diff` 非空才发现。
> 已把「CRLF 必须按 `\r?\n` 处理」写进 `docs/README.md` 的维护约定。

## 四、顺带查实的失效引用(归档保留原文,不改写历史)

- `docs/tcp-protocol.md`:仅出现在 2026-07-06 的两份历史报告里(当时的事实);
- `docs/柜控板-平台TCP接口文档.md`:仅出现在协议文档 **v1.1 变更记录**里
  (记录"已删除该内部重复文档"这件事),**不是活链接**,无需修复。

两者已在 `docs/README.md` 的「旧名 → 现名映射」表中给出对应关系。
2026-09-20 04:42:51 +08:00

167 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""TCP 协议端到端联调(模拟柜控板)——针对测试环境 device-server。
流程(严格按 docs/protocol/柜控板固件对接文档.md v1.1):
auth_str → login → status_post → bat_in → on(上报) → status_post×3(积分) → off
每一步都打印平台的应答,便于人工核对协议实现。
"""
import sys
import hashlib
import json
import socket
import time
HOST, PORT = "10.8.0.252", 3001
IMEI = "860000000000001"
BATTERY_ID = "SN-LEGACY-001"
def hex_channel(batt: int, power: int, soc_pct: int, voltage_001v: int,
current_001a: int, temp_k: int) -> str:
"""按协议 §5.3:byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) /
byte3-4 电压(0.01V 大端) / byte5-6 电流(0.01A 大端) / byte7-8 温度(1K 大端)。"""
return (f"{batt:02x}{power:02x}{soc_pct:02x}"
f"{voltage_001v:04x}{current_001a:04x}{temp_k:04x}")
EMPTY = hex_channel(0x00, 0x82, 0, 0, 0, 0) # 空仓:无电池但有电源
CHARGING = hex_channel(0x82, 0x82, 85, 5400, 800, 298) # 充电中:54.00V / 8.00A / 298K
FULL = hex_channel(0x81, 0x02, 100, 5460, 0, 298) # 充满
assert len(EMPTY) == 18 and len(CHARGING) == 18, "通道 hex 必须 18 字符"
print(f" 通道 hex:空仓={EMPTY} 充电中={CHARGING} 充满={FULL}")
class Cabinet:
def __init__(self):
self.sock = socket.create_connection((HOST, PORT), timeout=10)
self.sock.settimeout(10)
self.buf = b""
self.msg_id = 0
def send(self, payload: dict):
line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n"
print(f" → {line.strip()}")
self.sock.sendall(line.encode())
def recv(self, timeout=8):
"""读一行平台报文(LF 分帧)。"""
deadline = time.time() + timeout
while b"\n" not in self.buf:
if time.time() > deadline:
return None
try:
chunk = self.sock.recv(65536)
except socket.timeout:
return None
if not chunk:
return None
self.buf += chunk
line, self.buf = self.buf.split(b"\n", 1)
obj = json.loads(line.decode())
print(f" ← {json.dumps(obj, ensure_ascii=False)}")
return obj
def next_id(self):
self.msg_id += 1
return self.msg_id
def step(title):
print(f"\n=== {title} ===")
def status_post(cab, channels_board1, channels_board2, msg_id):
cab.send({
"act": "status_post",
"msg_id": msg_id,
"rssi": 85,
"cabinet": {"status1": "00", "status2": "00", "temp": 29800, "meter": 125500},
"boards": [
{"board": 1, "channels": channels_board1},
{"board": 2, "channels": channels_board2},
],
})
return cab.recv()
cab = Cabinet()
ok = []
step("① auth_str(未登录可发)")
cab.send({"act": "auth_str", "msg_id": cab.next_id(), "dev_id": IMEI})
resp = cab.recv()
auth_str = resp.get("auth_str") if resp else None
ok.append(("auth_str 返回 8 位安全码", bool(auth_str) and len(auth_str) == 8))
print(f" 安全码 = {auth_str}")
step("② login(签名 = SHA256(dev_id+auth_str+timestamp)[56..64])")
ts = int(time.time())
sign = hashlib.sha256(f"{IMEI}{auth_str}{ts}".encode()).hexdigest()[56:64]
cab.send({"act": "login", "msg_id": cab.next_id(), "dev_id": IMEI,
"sign": sign, "timestamp": ts, "iccid": "8986012520983901841"})
resp = cab.recv() or {}
ok.append(("login suc=1", resp.get("suc") == 1))
ok.append(("login 应答带 spec(非旧 cell)", "spec" in resp and "cell" not in resp))
ok.append(("login 应答带 work_mode", "work_mode" in resp))
ok.append(("login 应答带 act(协议 §3.1)", resp.get("act") == "login"))
step("③ status_post(全空仓,应回 suc=1)")
resp = status_post(cab, [EMPTY, EMPTY], [EMPTY, EMPTY], cab.next_id()) or {}
ok.append(("status_post suc=1", resp.get("suc") == 1))
step("④ bat_in(电池插入 —— 只登记台账,不建单)")
cab.send({"act": "bat_in", "msg_id": cab.next_id(), "id": "01-1", "battery": {
"battery_id": BATTERY_ID, "brand_code": "1", "model_code": "1", "material_code": "1",
"cell_count": 15, "rated_voltage": 48000, "rated_capacity": 24000,
"info": "80825515180320012a",
"cells": "0e100e150e0e0e120e170e0b0e110e140e0f0e130e160e0d0e100e120e0e0e170e0b012a0128",
}})
resp = cab.recv() or {}
ok.append(("bat_in suc=1", resp.get("suc") == 1))
step("⑤ on(设备上报形态,不带 suc —— 建单唯一入口)")
cab.send({"act": "on", "msg_id": cab.next_id(), "id": "01-1"})
resp = cab.recv() or {}
ok.append(("on 上报 suc=1", resp.get("suc") == 1))
step("⑥ status_post ×3(充电中,验证平台侧积分电量)")
for _ in range(3):
resp = status_post(cab, [CHARGING, EMPTY], [EMPTY, EMPTY], cab.next_id()) or {}
ok.append(("status_post(充电中) suc=1", resp.get("suc") == 1))
step("⑦ off(充满停止 —— 结束订单并结算电量)")
cab.send({"act": "off", "msg_id": cab.next_id(), "id": "01-1",
"battery": {"battery_id": BATTERY_ID, "info": FULL}})
resp = cab.recv() or {}
ok.append(("off suc=1", resp.get("suc") == 1))
step("⑧ 未登录发业务报文(应被丢弃、无任何应答)")
try:
bad = Cabinet()
bad.send({"act": "status_post", "msg_id": 1, "rssi": 50,
"cabinet": {"status1": "00", "status2": "00", "temp": 0, "meter": 0},
"boards": [{"board": 1, "channels": [EMPTY, EMPTY]}]})
silent = bad.recv(timeout=4)
ok.append(("未登录业务报文被静默丢弃", silent is None))
bad.sock.close()
except Exception as e: # noqa: BLE001
ok.append(("未登录业务报文被静默丢弃", False))
print(f" 异常: {e}")
cab.sock.close()
print("\n========== 联调结果 ==========")
for name, passed in ok:
print(f" {'✅' if passed else '❌'} {name}")
print(f"\n 合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过")
# ⛔ 退出码必须反映结果:0=全部通过,1=有失败(此前本脚本永远 exit 0,属假绿);
# 失败项额外打到 stderr,便于 CI/人只看 stderr 就能定位(口径见 AGENTS.md「验收流程」)。
failed = [n for n, p in ok if not p]
if failed:
print("未通过项(stderr):", file=sys.stderr)
for n in failed:
print(f" ❌ {n}", file=sys.stderr)
print(f"合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过,{len(failed)} 项失败", file=sys.stderr)
sys.exit(0 if not failed else 1)