Files
H100PowerManger/test/pccu/pccu_integration_test.py
T
zjk 90caacf9a2 pCCU 锂电池页新增 BCU 断路器控制(0x10XX81FF 经 pCanBridge 下行 CAN 总线)
- CanBms: bcuCtrlCanId(0x10XX81FF) + buildBcuRelayCtrlData(02 00 总正 总负 FF*4)
- SystemData: BcuCtrlCmd 下行队列(Web线程入队->Iterate/MOOS线程出队Notify) + 下发统计
- CCU: /api/bcu_ctrl(addr/action 或 pos/neg, 校验地址01h~36h) + Iterate 经 MOOS CAN_TX_0x* 下发
- pCanBridge 变双向透传: 订阅 CAN_TX_0x*,CanEndpoint::sendFrame(13字节CANET帧,TCP写,互斥保护)
- 网页: 每BCU节点断路器闭合/断开按钮(confirm确认) + 下发统计卡片
- 测试: pccuTest 控制帧编码用例(183项);集成测试新增假CANET服务器验证完整下行链路(闭合/断开/非法地址)
2026-09-01 15:23:30 +08:00

489 lines
20 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.
#!/usr/bin/env python3
"""pCCU 集成测试:验证真实 UDP 链路上的完整数据流。
拓扑:
测试脚本(FC模拟) --16000--> pCCU(fc_local) [FC状态 0x0002]
测试脚本(PM模拟) --16002--> pCCU(pm_local) [PM操控 0x0001, PM参数设定 0x0002]
pCCU --16001--> 测试脚本 [FC控制 0x0001 转发]
pCCU --16003--> 测试脚本 [PM状态 0x0004, PM参数反馈 0x0003]
测试脚本(真实CCU模拟) --16004--> pCCU(rcu_local) [配电反馈 0x0005~0x0008]
pCCU --16003--> 测试脚本 [配电反馈原帧转发 pPowerManger]
测试脚本(PM模拟) --16002--> pCCU(pm_local) [配电指令 0x0001~0x0004 短帧]
pCCU --16005--> 测试脚本 [配电指令原帧转发真实CCU]
锂电池已切换为 CAN 总线(BMS 协议,经 pCanBridge 透传 CAN_0x* 消息),
不在本脚本 UDP 拓扑内:BMS 解码由 pccuTest 单元测试覆盖
(testCanBmsDecode,向量取自实车抓包)。
校验:
1. FC 状态收到并整合,pCCU 周期发送 PM 状态(0x0004, 248B)到 16003
2. PM 操控(0x0001)转发为 FC 控制(0x0001, 24B)到 16001
3. PM 参数设定(0x0002)触发参数反馈(0x0003, 12B)到 16003
4. 配电反馈(0x0005~0x0008)原帧转发到 16003(字节一致)
5. 配电指令(0x0001~0x0004 短帧)原帧转发到 16005(字节一致);
PM 操控指令(45B, 同id)不转发到真实CCU(按帧长区分)
6. SQLite 记录了收/发原始帧(含 link='rcu')
7. Web 页面可访问
"""
import socket
import struct
import threading
import time
import subprocess
import os
import signal
import sys
import json
import urllib.request
# ---- 配置 ----
FC_STATUS_PORT = 16000
FC_CTRL_RECV_PORT = 16001
PM_CTRL_PORT = 16002
PM_STATUS_RECV_PORT = 16003
RCU_FB_PORT = 16004 # pCCU rcu 链路本地端口(收配电反馈)
RCU_CMD_RECV_PORT = 16005 # 模拟真实CCU 接收转发的配电指令
CANET_PORT = 16006 # 模拟 USBCAN-8E-U TCP Server(捕获下行 CAN 帧)
WEB_PORT = 18081
DB_PATH = "/tmp/pccu_it_test.db"
MOOSDB_EXE = "/usr/local/bin/MOOSDB"
PCCU_EXE = "/home/zjk/project/H100PowerManger/bin/pCCU"
CANBRIDGE_EXE = "/home/zjk/project/H100PowerManger/bin/pCanBridge"
MISSION = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pccu_it.moos")
# 帧头
START = bytes([0x40, 0x40])
def frame(msg_id, payload, checksum=True):
total = 6 + len(payload) + (4 if checksum else 0)
hdr = START + struct.pack("<HH", msg_id, total)
body = hdr + payload
if checksum:
cks = sum(body) & 0xFFFFFFFF
return body + struct.pack("<I", cks)
return body
def dis_frame(msg_id, payload):
"""配电协议帧:0x40 0x40 + id(2B) + length(2B) + payload + Sum8 校验"""
total = 6 + len(payload) + 1
body = START + struct.pack("<HH", msg_id, total) + payload
return body + bytes([sum(body) & 0xFF])
def fc_status_frame():
# payload 144B,仅填关键字段,其余为0
p = bytearray(144)
p[0] = 2 # 运行模式 自动
p[1] = 6 # 运行状态 运行
struct.pack_into("<H", p, 2, 0x0102) # 一级故障码
struct.pack_into("<H", p, 10, 1234) # 累积发电时间
p[12] = 0 # 故障等级
struct.pack_into("<H", p, 14, 2000) # 系统发电功率 0.01kW
p[16] = 80 # 储氢
p[17] = 70 # 液氧
struct.pack_into("<H", p, 24, 350) # 钯膜温度
struct.pack_into("<H", p, 68, 2500) # 舱室温度1
struct.pack_into("<H", p, 140, 4321) # 剩余发电量 MWh (字节147,148)
p[142] = 5 # 心跳
p[143] = 0x01 # 应急指令
return frame(0x0002, bytes(p), checksum=False)
def pm_control_frame():
p = bytearray(35)
struct.pack_into("<H", p, 0, 2026)
p[2], p[3], p[4], p[5], p[6], p[7] = 8, 26, 10, 30, 0, 0
p[8] = 2 # 模式 自动
p[9] = 2 # 操控 启动
p[10] = 120 # 输出功率 0.1kW
struct.pack_into("<h", p, 11, 123) # 纵倾姿态数据1 12.3°
struct.pack_into("<h", p, 13, -45) # 横倾姿态数据1 -4.5°
p[15] = 0x03 # 应急允许
struct.pack_into("<H", p, 16, 150) # 潜深
p[21] = 0x10 # 仪表锂电启动
p[22] = 0x20 # 动力锂电关闭
struct.pack_into("<H", p, 23, 300) # 动力电池功率
p[25] = 7 # 心跳
p[26] = 0xAA # 主机状态
return frame(0x0001, bytes(p))
def pm_paramset_frame():
p = bytearray(18)
p[0], p[1], p[2] = 75, 85, 93
p[5], p[6], p[7] = 75, 85, 93
return frame(0x0002, bytes(p))
#------------------ 配电协议帧(真实CCU桥接) ------------------
def dis_hv_fb_frame():
"""0x0005 高压母线配电反馈(disHighVolBusState 53B:19字节+17×u16)"""
p = bytearray(53)
p[0] = 0x55 # 燃料电池断路器 闭合
p[1] = 0xAA # 动力锂电池断路器 断开
struct.pack_into("<H", p, 19, 0x0FA0) # 动力汇流排电压
struct.pack_into("<H", p, 21, 123) # DC/DC5模块电流
struct.pack_into("<H", p, 25, 0x0F90) # A汇流排电压
return dis_frame(0x0005, bytes(p))
def dis_hva_fb_frame():
"""0x0006 高压母线A配电反馈(disHighAVolBusState 25B:9字节+8×u16)"""
p = bytearray(25)
p[0] = 0x55 # 艏部FT装置1/2/3断路器 闭合
struct.pack_into("<H", p, 9, 0x0F88) # 汇流排B电压
struct.pack_into("<H", p, 11, 321) # 汇流排B电流
return dis_frame(0x0006, bytes(p))
def dis_hvb_fb_frame():
"""0x0007 低压主母线配电反馈(disLowMainBusState 34B:10字节+12×u16)"""
p = bytearray(34)
p[0] = 0x55 # 锂电池组(仪表)断路器 闭合
struct.pack_into("<H", p, 10, 0x0F70) # 仪表母排电压
struct.pack_into("<H", p, 12, 654) # 艏部低压配电箱电流
return dis_frame(0x0007, bytes(p))
def dis_lv_fb_frame():
"""0x0008 仪表汇流排配电反馈(disLowBusState 29B:11字节+9×u16)"""
p = bytearray(29)
p[0] = 0x55 # UNIT1断路器 闭合
struct.pack_into("<H", p, 11, 0x0F60) # 仪表母排电压
struct.pack_into("<H", p, 13, 111) # UNIT1电流
return dis_frame(0x0008, bytes(p))
def dis_hv_cmd_frame():
"""0x0001 高压母线配电指令(disHighVolBusCmd 14B,pPowerManger 下发)"""
p = bytearray(14)
p[0] = 0x55 # 燃料电池断路器 闭合
p[1] = 0xAA # 动力锂电池断路器 断开
p[12] = 1 # DC/DC5模块启动
return dis_frame(0x0001, bytes(p))
def dis_hva_cmd_frame():
"""0x0002 高压母线A配电指令(disHighAVolBusCmd 6B)"""
p = bytearray(6)
p[0] = 0x55
return dis_frame(0x0002, bytes(p))
def dis_hvb_cmd_frame():
"""0x0003 低压主母线配电指令(disLowMainBusCmd 7B)"""
p = bytearray(7)
p[3] = 0x55 # DC-C1直流配电板断路器
return dis_frame(0x0003, bytes(p))
def dis_lv_cmd_frame():
"""0x0004 仪表汇流排配电指令(disLowBusCmd 8B)"""
p = bytearray(8)
p[0] = 0x55 # UNIT1断路器
return dis_frame(0x0004, bytes(p))
#------------------ BCU 断路器控制下行测试 ------------------
class CanetServer(threading.Thread):
"""最小 CANET TCP Server 模拟:接受 pCanBridge 连接,捕获下行 13 字节帧"""
def __init__(self, port):
super().__init__(daemon=True)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", port))
self.sock.listen(2)
self.frames = []
self.lock = threading.Lock()
self.stop_flag = False
def run(self):
self.sock.settimeout(1.0)
conn = None
while not self.stop_flag:
try:
if conn is None:
conn, _ = self.sock.accept()
conn.settimeout(0.5)
buf = b""
while not self.stop_flag:
try:
d = conn.recv(4096)
except socket.timeout:
break
if not d:
conn.close()
conn = None
break
buf += d
while len(buf) >= 13:
with self.lock:
self.frames.append(buf[:13])
buf = buf[13:]
except socket.timeout:
continue
except OSError:
break
def snapshot(self):
with self.lock:
return list(self.frames)
def bcu_ctrl_expect(addr, pos, neg):
"""期望的下行 13 字节 CANET 帧:扩展帧 DLC8 + 0x10XX81FF + 02 00 pos neg FF*4"""
return bytes([0x80 | 8,
(0x10000000 | (addr << 16) | 0x81FF) >> 24 & 0xFF,
(0x10000000 | (addr << 16) | 0x81FF) >> 16 & 0xFF,
(0x10000000 | (addr << 16) | 0x81FF) >> 8 & 0xFF,
(0x10000000 | (addr << 16) | 0x81FF) & 0xFF,
0x02, 0x00, 0x01 if pos else 0x00, 0x01 if neg else 0x00,
0xFF, 0xFF, 0xFF, 0xFF])
def api_get(path):
try:
resp = urllib.request.urlopen(f"http://127.0.0.1:{WEB_PORT}{path}", timeout=3)
return json.loads(resp.read().decode())
except Exception as e:
return {"ok": False, "error": str(e)}
def recv_loop(sock, label, results, msg_ids, timeout):
"""持续接收指定 msg_id 的帧,记录到 results"""
sock.settimeout(timeout)
end = time.time() + timeout
while time.time() < end:
try:
data, addr = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
if len(data) < 6:
continue
mid = struct.unpack_from("<H", data, 2)[0]
if mid in msg_ids:
results.append(data)
def main():
# 清理
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm", "/tmp/pccu_it_test.log",
"/tmp/pccu_it_can.db", "/tmp/pccu_it_can.db-wal", "/tmp/pccu_it_can.db-shm"):
if os.path.exists(f):
os.remove(f)
procs = []
canet = CanetServer(CANET_PORT)
try:
# 1. 启动 MOOSDB
moosdb = subprocess.Popen([MOOSDB_EXE, "--moos", "--port", "9000"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
procs.append(moosdb)
time.sleep(1.0)
# 2. 启动 pCCU + pCanBridge(pCanBridge 连接模拟 USBCAN,用于下行测试)
pccu = subprocess.Popen([PCCU_EXE, MISSION],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
procs.append(pccu)
canet.start()
pcan = subprocess.Popen([CANBRIDGE_EXE, MISSION],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
procs.append(pcan)
time.sleep(2.5)
if pccu.poll() is not None:
print("FAIL: pCCU exited early, code", pccu.returncode)
return 1
# 3. 启动接收监听(FC 控制转发 16001,PM 状态/配电反馈 16003,
# 转发真实CCU 的配电指令 16005)
fc_ctl_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fc_ctl_recv.bind(("127.0.0.1", FC_CTRL_RECV_PORT))
pm_status_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
pm_status_recv.bind(("127.0.0.1", PM_STATUS_RECV_PORT))
rcu_cmd_recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rcu_cmd_recv.bind(("127.0.0.1", RCU_CMD_RECV_PORT))
fc_ctl_results, pm_status_results, rcu_cmd_results = [], [], []
t1 = threading.Thread(target=recv_loop,
args=(fc_ctl_recv, "fc-ctl", fc_ctl_results, {0x0001}, 14))
t2 = threading.Thread(target=recv_loop,
args=(pm_status_recv, "pm-status", pm_status_results,
{0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008}, 14))
t3 = threading.Thread(target=recv_loop,
args=(rcu_cmd_recv, "rcu-cmd", rcu_cmd_results,
{0x0001, 0x0002, 0x0003, 0x0004}, 14))
t1.start(); t2.start(); t3.start()
# 4. 发送 FC 状态(周期模拟)
fc_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for _ in range(3):
fc_send.sendto(fc_status_frame(), ("127.0.0.1", FC_STATUS_PORT))
time.sleep(0.5)
# 5. 发送 PM 操控指令 + 参数设定
pm_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
pm_send.sendto(pm_control_frame(), ("127.0.0.1", PM_CTRL_PORT))
time.sleep(0.5)
pm_send.sendto(pm_paramset_frame(), ("127.0.0.1", PM_CTRL_PORT))
# 6. 发送配电反馈(真实CCU -> pCCU -> pPowerManger,应原帧转发到 16003)
rcu_fb_frames = [dis_hv_fb_frame(), dis_hva_fb_frame(),
dis_hvb_fb_frame(), dis_lv_fb_frame()]
for f in rcu_fb_frames:
pm_send.sendto(f, ("127.0.0.1", RCU_FB_PORT))
time.sleep(0.2)
# 7. 发送配电指令(pPowerManger -> pCCU -> 真实CCU,应原帧转发到 16005)
dis_cmd_frames = [dis_hv_cmd_frame(), dis_hva_cmd_frame(),
dis_hvb_cmd_frame(), dis_lv_cmd_frame()]
for f in dis_cmd_frames:
pm_send.sendto(f, ("127.0.0.1", PM_CTRL_PORT))
time.sleep(0.2)
t1.join(); t2.join(); t3.join()
ok = True
# 校验 1:PM 状态报文(248B)周期发送
pm_status = [d for d in pm_status_results if len(d) == 248]
print(f"[PM 状态报文] 收到 {len(pm_status)} 帧 (期望>=1, 每帧248B)")
if len(pm_status) >= 1:
d = pm_status[0]
total = struct.unpack_from("<H", d, 4)[0]
mode = d[6]; status = d[7]
print(f" -> length={total}, mode={mode}, status={status}, size={len(d)}")
ok = ok and (total == 248) and (mode == 2) and (status == 6)
else:
ok = False
# 校验 2:PM 操控转发为 FC 控制(24B,0827协议:姿态数据1转发,数据2字节预留恒0)
fc_ctl = [d for d in fc_ctl_results if len(d) == 24]
print(f"[FC 控制转发] 收到 {len(fc_ctl)} 帧 (期望>=1, 每帧24B)")
if fc_ctl:
d = fc_ctl[0]
cmd = d[7]; power = d[8]; hb = d[23]
pitch = struct.unpack_from("<h", d, 9)[0] # 字节10,11
roll = struct.unpack_from("<h", d, 13)[0] # 字节14,15
resv1 = struct.unpack_from("<H", d, 11)[0] # 字节12,13 预留
resv2 = struct.unpack_from("<H", d, 15)[0] # 字节16,17 预留
print(f" -> cmd={cmd}, outputPower={power}, pitch={pitch}, roll={roll}, "
f"resv1={resv1}, resv2={resv2}, heartbeat={hb}")
ok = ok and (cmd == 2) and (power == 120) and (hb == 7)
ok = ok and (pitch == 123) and (roll == -45)
ok = ok and (resv1 == 0) and (resv2 == 0)
else:
ok = False
# 校验 3:PM 参数反馈(12B)
fb = [d for d in pm_status_results if len(d) == 12]
print(f"[PM 参数反馈] 收到 {len(fb)} 帧 (期望>=1, 每帧12B)")
if fb:
flag = fb[0][6]
print(f" -> flag=0x{flag:02X}")
ok = ok and (flag == 0x10)
else:
ok = False
# 校验 4:配电反馈原帧转发到 pPowerManger(字节一致)
for i, f in enumerate(rcu_fb_frames):
got = [d for d in pm_status_results if d == f]
mid = struct.unpack_from("<H", f, 2)[0]
print(f"[配电反馈 0x{mid:04X} 转发] {'OK' if got else 'MISSING'} (字节一致)")
ok = ok and bool(got)
# 校验 5:配电指令原帧转发到真实CCU(字节一致)
for f in dis_cmd_frames:
got = [d for d in rcu_cmd_results if d == f]
mid = struct.unpack_from("<H", f, 2)[0]
print(f"[配电指令 0x{mid:04X} 转发] {'OK' if got else 'MISSING'} (字节一致)")
ok = ok and bool(got)
# 校验 6:PM 操控指令(0x0001, 45B)不得转发到真实CCU(按帧长区分)
pm_ctl_leak = [d for d in rcu_cmd_results if len(d) == 45]
print(f"[PM操控指令不桥接] 收到 {len(pm_ctl_leak)} 帧 (期望 0)")
ok = ok and not pm_ctl_leak
# 校验 7:数据库有记录(FC/PM 链路收发 + rcu 链路桥接)
time.sleep(1.0)
db_count, db_rcu = -1, -1
if os.path.exists(DB_PATH):
import sqlite3
conn = sqlite3.connect(DB_PATH)
db_count = conn.execute("SELECT COUNT(*) FROM comm_log").fetchone()[0]
db_rcu = conn.execute(
"SELECT COUNT(*) FROM comm_log WHERE link='rcu'").fetchone()[0]
conn.close()
print(f"[数据库] comm_log 记录数 = {db_count} (期望>=6), "
f"rcu 链路记录数 = {db_rcu} (期望>=8)")
ok = ok and (db_count >= 6) # 3条FC状态 + 1条PM操控 + 1条PM参数 + 周期PM状态(至少1)
ok = ok and (db_rcu >= 8) # 4条配电反馈(收) + 4条配电指令(发)
# 校验 8:BCU 断路器控制下行(网页API -> pCCU -> MOOS -> pCanBridge -> 模拟USBCAN)
# 闭合(addr=5, 总正+总负=1)
j = api_get("/api/bcu_ctrl?addr=5&pos=1&neg=1")
print(f"[断路器闭合 API] {j}")
ok = ok and j.get("ok") is True
# action 语法(addr=6, action=1 等价 pos=neg=1)
j = api_get("/api/bcu_ctrl?addr=6&action=1")
print(f"[断路器闭合 API(action)] {j}")
ok = ok and j.get("ok") is True
# 非法地址
j = api_get("/api/bcu_ctrl?addr=99&action=1")
print(f"[非法地址 API] {j}")
ok = ok and j.get("ok") is False
# 断开(addr=5, 总正+总负=0)
j = api_get("/api/bcu_ctrl?addr=5&pos=0&neg=0")
print(f"[断路器断开 API] {j}")
ok = ok and j.get("ok") is True
time.sleep(3.0)
frames = canet.snapshot()
print(f"[下行帧捕获] {len(frames)} 帧")
exp_close5 = bcu_ctrl_expect(5, 1, 1)
exp_close6 = bcu_ctrl_expect(6, 1, 1)
exp_open5 = bcu_ctrl_expect(5, 0, 0)
hit5 = any(f == exp_close5 for f in frames)
hit6 = any(f == exp_close6 for f in frames)
hit5o = any(f == exp_open5 for f in frames)
leak99 = any(f[1] == 0x63 and f[2] == 0x00 for f in frames) # addr=99 未下发
print(f"[断路器闭合下行(addr=5)] {'OK' if hit5 else 'MISSING'}")
print(f"[断路器闭合下行(addr=6)] {'OK' if hit6 else 'MISSING'}")
print(f"[断路器断开下行(addr=5)] {'OK' if hit5o else 'MISSING'}")
print(f"[非法地址未下发(addr=99)] {'OK' if not leak99 else 'LEAK'}")
ok = ok and hit5 and hit6 and hit5o and not leak99
web_ok = False
try:
resp = urllib.request.urlopen(f"http://127.0.0.1:{WEB_PORT}/", timeout=3)
html = resp.read().decode()
web_ok = ("pCCU" in html)
print(f"[Web] 页面可访问, 长度={len(html)}")
except Exception as e:
print(f"[Web] 访问失败: {e}")
ok = ok and web_ok
print("\n==== 集成测试 " + ("PASSED" if ok else "FAILED") + " ====")
return 0 if ok else 1
finally:
canet.stop_flag = True
for p in reversed(procs):
try:
p.terminate()
except Exception:
pass
time.sleep(0.5)
for p in procs:
try:
p.kill()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())