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

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

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

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

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

157 lines
5.9 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/柜控板固件对接文档.md v1.1):
auth_str → login → status_post → bat_in → on(上报) → status_post×3(积分) → off
每一步都打印平台的应答,便于人工核对协议实现。
"""
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)} 通过")