From 90caacf9a25c5c1cdda5b704e0eabed00b4a479c Mon Sep 17 00:00:00 2001 From: zjk <1553836110@qq.com> Date: Tue, 1 Sep 2026 15:23:30 +0800 Subject: [PATCH] =?UTF-8?q?pCCU=20=E9=94=82=E7=94=B5=E6=B1=A0=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20BCU=20=E6=96=AD=E8=B7=AF=E5=99=A8=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=EF=BC=880x10XX81FF=20=E7=BB=8F=20pCanBridge=20?= =?UTF-8?q?=E4=B8=8B=E8=A1=8C=20CAN=20=E6=80=BB=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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服务器验证完整下行链路(闭合/断开/非法地址) --- src/pCCU/CCU.cpp | 90 +++++++++++++++++++++- src/pCCU/CCU.h | 5 ++ src/pCCU/CCU_Info.cpp | 2 + src/pCCU/core/SnapshotBuilder.cpp | 15 ++++ src/pCCU/core/SystemData.h | 57 ++++++++++++++ src/pCCU/protocol/CanBms.h | 31 +++++++- src/pCCU/web/pages/index.h | 51 ++++++++++++- src/pCanBridge/CanBridge.cpp | 56 +++++++++++++- src/pCanBridge/CanBridge.h | 17 ++++- src/pCanBridge/CanEndpoint.cpp | 44 ++++++++++- src/pCanBridge/CanEndpoint.h | 11 +++ src/pCanBridge/pCanBridge.moos | 13 ++-- test/pccu/pccuTest.cpp | 20 +++++ test/pccu/pccu_integration_test.py | 115 ++++++++++++++++++++++++++++- test/pccu/pccu_it.moos | 12 +++ 15 files changed, 517 insertions(+), 22 deletions(-) diff --git a/src/pCCU/CCU.cpp b/src/pCCU/CCU.cpp index 3fb1214..1fa1f5c 100644 --- a/src/pCCU/CCU.cpp +++ b/src/pCCU/CCU.cpp @@ -5,6 +5,7 @@ #include "protocol/CanBms.h" #include #include +#include using namespace ccu; using namespace std; @@ -277,6 +278,9 @@ bool CCU::Iterate() { m_lastStatusTx = now; } + // BCU 断路器控制指令下发(网页 -> 队列 -> MOOS CAN_TX_*) + sendPendingBcuCtrl(); + // 电源协调器:协调算法占位 if (m_coord) m_coord->tick(now); @@ -503,16 +507,95 @@ std::string CCU::buildSnapshot() { } //--------------------------------------------------------- -// handleApi:/api/logs +// handleApi:/api/logs、/api/bcu_ctrl std::string CCU::handleApi(const std::string& uri, const std::string& query) { if (uri == "/api/logs") { if (m_snap) return m_snap->buildLogs(50); return "[]"; } + if (uri == "/api/bcu_ctrl") { + return handleBcuCtrlApi(query); + } return ""; } +//--------------------------------------------------------- +// handleBcuCtrlApi:BCU 断路器控制指令下发 +// +// GET /api/bcu_ctrl?addr=5&action=1 action: 1 闭合 / 0 断开(总正+总负) +// GET /api/bcu_ctrl?addr=5&pos=1&neg=0 也可单独指定总正/总负 +// Web 线程不可直接 Notify,指令入队后由 Iterate(MOOS 线程)下发。 + +std::string CCU::handleBcuCtrlApi(const std::string& query) { + if (!m_sysData) return "{\"ok\":false,\"error\":\"not ready\"}"; + + // 简易 query 解析(k=v&k=v) + auto getParam = [&query](const char* key, int& out) -> bool { + std::string k = std::string(key) + "="; + size_t p = query.find(k); + if (p == std::string::npos) return false; + size_t e = query.find('&', p); + std::string v = query.substr(p + k.size(), + (e == std::string::npos) ? std::string::npos + : e - p - k.size()); + out = atoi(v.c_str()); + return true; + }; + + int addr = 0, action = -1, pos = -1, neg = -1; + getParam("addr", addr); + getParam("action", action); + getParam("pos", pos); + getParam("neg", neg); + + if (addr < kBcuAddrMin || addr > kBcuAddrMax) + return "{\"ok\":false,\"error\":\"bad addr\"}"; + if (pos < 0) pos = action; // 未单独指定时跟随 action + if (neg < 0) neg = action; + if (action < 0 && (pos < 0 || neg < 0)) + return "{\"ok\":false,\"error\":\"missing action\"}"; + if (action > 1 || pos > 1 || neg > 1) + return "{\"ok\":false,\"error\":\"bad value\"}"; + + BcuCtrlCmd c; + c.addr = static_cast(addr); + c.pos = static_cast(pos); + c.neg = static_cast(neg); + if (!m_sysData->pushBcuCtrlCmd(c)) + return "{\"ok\":false,\"error\":\"queue full\"}"; + + LOG_F(INFO, "[BCU/CAN] 断路器指令入队: 节点%d 总正=%d 总负=%d", addr, pos, neg); + char buf[96]; + std::snprintf(buf, sizeof(buf), + "{\"ok\":true,\"addr\":%d,\"pos\":%d,\"neg\":%d}", addr, pos, neg); + return buf; +} + +//--------------------------------------------------------- +// sendPendingBcuCtrl:出队控制指令,编码 0x10XX81FF 经 MOOS 下发 +// 链路:MOOS CAN_TX_0x* -> pCanBridge 订阅 -> CanEndpoint(TCP) -> USBCAN + +void CCU::sendPendingBcuCtrl() { + if (!m_sysData) return; + BcuCtrlCmd c; + while (m_sysData->popBcuCtrlCmd(c)) { + uint8_t data[8]; + buildBcuRelayCtrlData(c.pos, c.neg, data); + uint32_t canId = bcuCtrlCanId(c.addr); + + char key[32]; + std::snprintf(key, sizeof(key), "CAN_TX_0x%08X", canId); + // 二进制构造同 pCanBridge 上行发布(MOOS_BINARY_STRING) + CMOOSMsg msg(MOOS_NOTIFY, key, + static_cast(sizeof(data)), data); + bool ok = m_Comms.Post(msg); + m_sysData->noteBcuCtrlSent(c, ok); + LOG_F(INFO, "[BCU/CAN] 断路器指令 0x%08X 节点%d 总正=%d 总负=%d %s", + canId, c.addr, c.pos, c.neg, ok ? "已投递MOOS" : "投递失败"); + } +} + //--------------------------------------------------------- // buildReport @@ -551,6 +634,11 @@ bool CCU::buildReport() { m_msgs << "BMS 报文接收计数:" << m_sysData->bmsStatusCount() << "\n"; m_msgs << "FC 状态接收次数:" << m_sysData->fcStatusCount() << "\n"; m_msgs << "PM 指令接收次数:" << m_sysData->pmControlCount() << "\n"; + const BcuCtrlStat& bc = m_sysData->bcuCtrlStat(); + m_msgs << "断路器指令下发:" << bc.sentCount << " 成功 / " << bc.errCount + << " 失败 (最近: 节点" << static_cast(bc.last.addr) + << " 总正=" << static_cast(bc.last.pos) + << " 总负=" << static_cast(bc.last.neg) << ")\n"; } if (m_db) { m_msgs << "数据库记录数:" << m_db->count() << "\n"; diff --git a/src/pCCU/CCU.h b/src/pCCU/CCU.h index a6a3cd6..84b3380 100644 --- a/src/pCCU/CCU.h +++ b/src/pCCU/CCU.h @@ -60,6 +60,11 @@ private: std::string buildSnapshot(); std::string handleApi(const std::string& uri, const std::string& query); + // BCU 断路器控制:/api/bcu_ctrl 解析与下发(经 MOOS CAN_TX_* -> pCanBridge) + std::string handleBcuCtrlApi(const std::string& query); + // 出队并下发一条 BCU 控制指令(Iterate 调用,MOOS 线程内 Notify 安全) + void sendPendingBcuCtrl(); + // 配置 long m_fcLocalPort = 6000; std::string m_fcHost = "192.168.1.162"; diff --git a/src/pCCU/CCU_Info.cpp b/src/pCCU/CCU_Info.cpp index 825e9fc..2a172eb 100644 --- a/src/pCCU/CCU_Info.cpp +++ b/src/pCCU/CCU_Info.cpp @@ -101,6 +101,8 @@ void showInterfaceAndExit() { blk(" "); blk("PUBLICATIONS: "); blk("------------------------------------ "); + blk(" CAN_TX_0x%08X = BCU 断路器控制帧 0x10XX81FF(二进制 "); + blk(" 数据域,经 pCanBridge 下行到 CAN 总线) "); blk(" CCU_FC_LINK_STATE = 运行状态字符串 "); blk(" CCU_PM_LINK_STATE = 运行状态字符串 "); blk(" CCU_RCU_LINK_STATE = 运行状态字符串 "); diff --git a/src/pCCU/core/SnapshotBuilder.cpp b/src/pCCU/core/SnapshotBuilder.cpp index f69bd65..882ea83 100644 --- a/src/pCCU/core/SnapshotBuilder.cpp +++ b/src/pCCU/core/SnapshotBuilder.cpp @@ -679,6 +679,20 @@ std::string SnapshotBuilder::build() const { root["canFrameCount"] = JsonVal(static_cast(m_sys->canFrameCount())); // 真实CCU 配电桥接数据(RCU 链路) root["realCcu"] = realCcuJson(m_sys->realCcu(), MOOSTime(false)); + // BCU 断路器控制下发统计(网页 -> 0x10XX81FF -> pCanBridge) + { + BcuCtrlStat bc = m_sys->bcuCtrlStat(); + JsonVal jc(Json::objectValue); + jc["sentCount"] = JsonVal(static_cast(bc.sentCount)); + jc["errCount"] = JsonVal(static_cast(bc.errCount)); + jc["valid"] = JsonVal(bc.lastTime > 0); + jc["age"] = JsonVal(bc.lastTime > 0 ? (MOOSTime(false) - bc.lastTime) : -1.0); + put(jc, "addr", bc.last.addr); + put(jc, "pos", bc.last.pos); + put(jc, "neg", bc.last.neg); + jc["lastOk"] = JsonVal(bc.lastOk); + root["bcuCtrl"] = jc; + } } else { root["fc"] = JsonVal(Json::objectValue); root["pmCmd"] = JsonVal(Json::objectValue); @@ -688,6 +702,7 @@ std::string SnapshotBuilder::build() const { root["pmStatus"] = JsonVal(Json::objectValue); root["bms"] = JsonVal(Json::arrayValue); root["realCcu"] = JsonVal(Json::objectValue); + root["bcuCtrl"] = JsonVal(Json::objectValue); } JsonVal links(Json::objectValue); diff --git a/src/pCCU/core/SystemData.h b/src/pCCU/core/SystemData.h index ddced80..9091b64 100644 --- a/src/pCCU/core/SystemData.h +++ b/src/pCCU/core/SystemData.h @@ -3,6 +3,8 @@ #include #include +#include +#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h" #include "../protocol/FcProtocol.h" #include "../protocol/PmProtocol.h" #include "../protocol/CanBms.h" @@ -58,6 +60,29 @@ struct RealCcuState { // 通过互斥锁保护读写。 //============================================================================ +//============================================================================ +// BcuCtrlCmd:BCU 断路器(总正/总负继电器)控制指令。 +// +// 链路:网页 /api/bcu_ctrl(Web 线程)-> 入队 -> CCU::Iterate(MOOS 线程) +// 出队编码 0x10XX81FF -> MOOS CAN_TX_0x* -> pCanBridge 下行 CAN 总线。 +// 队列化是为了让 MOOS Notify 只发生在 MOOS 线程(Web 线程不可直接 Notify)。 +//============================================================================ + +struct BcuCtrlCmd { + uint8_t addr = 0; // BCU 节点地址 1~54 + uint8_t pos = 0; // 总正继电器 1 闭合 / 0 断开 + uint8_t neg = 0; // 总负继电器 1 闭合 / 0 断开 +}; + +// BCU 断路器控制的下发统计(网页展示) +struct BcuCtrlStat { + unsigned long sentCount = 0; // 已下发帧数(成功投递 MOOS) + unsigned long errCount = 0; // 投递失败帧数 + BcuCtrlCmd last{}; + bool lastOk = false; + double lastTime = 0; // 最近下发时刻(MOOSTime) +}; + class SystemData { public: // 更新/获取燃料电池最新状态 @@ -189,6 +214,36 @@ public: return m_realCcu; } + //-------- BCU 断路器控制(网页 -> MOOS 下行队列) -------- + + // 入队一条控制指令(Web API 线程调用);队列上限 16,满则丢弃并返回 false + bool pushBcuCtrlCmd(const BcuCtrlCmd& c) { + std::lock_guard lock(m_mutex); + if (m_bcuCtrlQueue.size() >= 16) return false; + m_bcuCtrlQueue.push_back(c); + return true; + } + // 出队一条控制指令(MOOS 主循环 Iterate 调用);空队列返回 false + bool popBcuCtrlCmd(BcuCtrlCmd& out) { + std::lock_guard lock(m_mutex); + if (m_bcuCtrlQueue.empty()) return false; + out = m_bcuCtrlQueue.front(); + m_bcuCtrlQueue.erase(m_bcuCtrlQueue.begin()); + return true; + } + // 记录一次下发结果(Iterate 调用,供网页统计展示) + void noteBcuCtrlSent(const BcuCtrlCmd& c, bool ok) { + std::lock_guard lock(m_mutex); + if (ok) m_bcuCtrlStat.sentCount++; else m_bcuCtrlStat.errCount++; + m_bcuCtrlStat.last = c; + m_bcuCtrlStat.lastOk = ok; + m_bcuCtrlStat.lastTime = MOOSTime(false); + } + BcuCtrlStat bcuCtrlStat() const { + std::lock_guard lock(m_mutex); + return m_bcuCtrlStat; + } + private: mutable std::mutex m_mutex; FcStatusValue m_fcStatus; @@ -199,6 +254,8 @@ private: PmParamSetFbValue m_pmParamFb; BcuNodeStatus m_bcuNodes[kBcuNodeCount]; // 下标 = 节点地址-1 RealCcuState m_realCcu; + std::vector m_bcuCtrlQueue; + BcuCtrlStat m_bcuCtrlStat; unsigned long m_fcStatusCount = 0; unsigned long m_pmControlCount = 0; unsigned long m_fcControlCount = 0; diff --git a/src/pCCU/protocol/CanBms.h b/src/pCCU/protocol/CanBms.h index 98e5182..1242277 100644 --- a/src/pCCU/protocol/CanBms.h +++ b/src/pCCU/protocol/CanBms.h @@ -15,7 +15,13 @@ namespace ccu { // 【节点地址】一个 MBMS 下最多挂 54 个 BCU,CAN ID 中间的 XX(01h~36h) // 为 BCU 节点地址编号,其余位固定: // 0x10XX00YY BCU 主动上报消息(YY=功能码 00/01/02/03/06/10) -// 0x10XX81FF MBMS 下发指令(继电器控制,pCCU 只收不发,不解析) +// 0x10XX81FF MBMS 下发指令(继电器控制,经 MOOS CAN_TX_0x* 下行) +// +// 【下发指令 0x10XX81FF】数据域 8 字节(BYTE1~8 = byte0~7): +// byte0 = 0x02(索引号) byte1 = 0x00(索引号) +// byte2 = 总正继电器控制(1 闭合 / 0 断开) +// byte3 = 总负继电器控制(1 闭合 / 0 断开) +// byte4~7 = Reserve(填充 0xFF) // // 【上报报文】均为扩展帧,字段大端,BYTE1~8 对应数据域 byte0~7: // 0x10XX0000 (200ms) 累加电压 u16@0 ÷10 V @@ -68,6 +74,29 @@ inline uint8_t bcuFuncOf(uint32_t canId) { return static_cast(canId & 0xFF); } +//-------------------------------------------------------------------------- +// BCU 断路器(总正/总负继电器)下行控制 +//-------------------------------------------------------------------------- + +// BCU 节点继电器控制指令 CAN ID(0x10XX81FF,XX=节点地址 01h~36h) +inline uint32_t bcuCtrlCanId(uint8_t addr) { + return 0x10000000u | (static_cast(addr) << 16) | 0x81FFu; +} + +// 编码继电器控制数据域(8 字节,经 MOOS CAN_TX_0x%08X 下发,pCanBridge 发送): +// [0]=0x02 [1]=0x00 [2]=总正(1闭合/0断开) [3]=总负 [4..7]=0xFF +// pos/neg 非 0 值均视为闭合(1)。 +inline void buildBcuRelayCtrlData(int pos, int neg, uint8_t out[8]) { + out[0] = 0x02; + out[1] = 0x00; + out[2] = pos ? 0x01 : 0x00; + out[3] = neg ? 0x01 : 0x00; + out[4] = 0xFF; + out[5] = 0xFF; + out[6] = 0xFF; + out[7] = 0xFF; +} + // 是否为 BCU 主动上报报文 ID(0x10XX00YY,YY 为已定义功能码) inline bool isCanBcuId(uint32_t canId) { uint8_t addr = bcuAddrOf(canId); diff --git a/src/pCCU/web/pages/index.h b/src/pCCU/web/pages/index.h index d4f6866..67331cc 100644 --- a/src/pCCU/web/pages/index.h +++ b/src/pCCU/web/pages/index.h @@ -68,6 +68,11 @@ th{color:var(--dim);font-weight:500;} /* 卡片内分组小标题 */ .sub{font-size:11px;color:#79c0ff;margin:10px 0 2px;border-bottom:1px solid #22304a;padding-bottom:2px;} .sub:first-child{margin-top:0;} +.btn{padding:3px 10px;margin:0 4px 4px 0;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:11px;cursor:pointer;} +.btn:hover{border-color:#3b4a63;} +.btn.on{background:#1f6feb;border-color:#1f6feb;color:#fff;font-weight:600;} +.btn.off{background:#3d2226;border-color:#f85149;color:#f85149;} +.btn:disabled{opacity:.4;cursor:not-allowed;} @@ -402,6 +407,39 @@ function pmFbCard(d){ return card('设定结果', h); } +//------------------ BCU 断路器控制(下行 0x10XX81FF) ------------------ +// pos/neg: 1 闭合 / 0 断开(总正+总负同帧下发,经 pCanBridge 写 USBCAN) +function bcuCtrl(addr, pos, neg, label){ + if(!confirm('确认向 BCU 节点 '+addr+' 下发「'+label+'」指令?\n该指令将直接控制电池簇总正/总负继电器。')) return; + fetch('/api/bcu_ctrl?addr='+addr+'&pos='+pos+'&neg='+neg) + .then(function(r){ return r.json(); }) + .then(function(j){ + if(j.ok){ + alert('已下发: 节点'+j.addr+' 总正='+(j.pos?'闭合':'断开')+' 总负='+(j.neg?'闭合':'断开')+ + '\n继电器状态以 0x10'+pad2(j.addr)+'0003 上报刷新为准'); + }else{ + alert('下发失败: '+(j.error||'未知错误')); + } + }) + .catch(function(){ alert('网络错误,下发未完成'); }); +} + +// 断路器控制下发统计卡片(最近一次指令与成败计数) +function bcuCtrlStatCard(s){ + let c=''; + const v = s && s.valid; + c+=row('累计下发', (s && s.sentCount || 0) + ' 成功 / ' + (s && s.errCount || 0) + ' 失败'); + if(v){ + c+=row('最近指令', '节点'+s.addr+' 总正='+(s.pos?'闭合':'断开')+' 总负='+(s.neg?'闭合':'断开') + + ' ' + (s.lastOk ? '已投递' : '投递失败')); + c+=row('下发时间', ageCell(s.age, true)); + }else{ + c+=row('最近指令', '无'); + } + c+=sub('链路: 网页 → pCCU(0x10XX81FF) → MOOS CAN_TX_* → pCanBridge → USBCAN → BCU'); + return card('断路器控制下发', c); +} + //------------------ 锂电池 BCU 节点(CAN) 卡片 ------------------ // 每个节点(CAN ID 0x10XX00YY 中的 XX)一张卡片, // 同一节点的 0000/0001/0002/0003/0006/0010 各报文归并在卡片内分组展示 @@ -410,8 +448,7 @@ function bmsCard(d){ if(!nodes.length) return card('锂电池BMS (CAN)', '
数据状态等待报文...
'); let html=''; - for(const n of nodes){ - const addrTxt = 'BCU节点 '+pad2(n.addr)+'h (#'+n.addr+')'; + for(const n of nodes){ const addrTxt = 'BCU节点 '+pad2(n.addr)+'h (#'+n.addr+')'; let h=''; // 概要(0x10XX0000) h+=sub('概要 0x10'+pad2(n.addr)+'0000'); @@ -434,6 +471,11 @@ function bmsCard(d){ h+=sub('继电器 0x10'+pad2(n.addr)+'0003'); h+=row('正极继电器', relayText(n.posRelay)); h+=row('负极继电器', relayText(n.negRelay)); + // 断路器控制(0x10XX81FF 下发,总正+总负继电器同帧控制) + h+=sub('断路器控制 (0x10'+pad2(n.addr)+'81FF)'); + h+=row('操作', + ''+ + ''); // 绝缘/端口电压(0x10XX0006) h+=sub('绝缘/端口 0x10'+pad2(n.addr)+'0006'); h+=row('正极绝缘阻抗', n.posInsulationKohm.toFixed(0)+' kΩ'); @@ -758,6 +800,7 @@ function render(snap){ pmParam: pageHeader(0x0002,'PM参数设定','PM→CCU')+'
'+pmParamCard(snap.pmParam)+'
', // 锂电池 BCU 节点(CAN 总线,经 pCanBridge 透传,按节点分组展示) bms: '
'+bmsCard(snap.bms)+ + bcuCtrlStatCard(snap.bcuCtrl)+ '

报文来源 (CAN ID)

'+ row('0x10XX0000','累加电压 / 回路电流 / SOC / 告警码 / 自检状态')+ row('0x10XX0001','最高/最低/平均单体电压及编号')+ @@ -765,8 +808,10 @@ function render(snap){ row('0x10XX0003','正/负极继电器状态')+ row('0x10XX0006','正/负极绝缘阻抗 / 端口电压 / 继电器外侧电压')+ row('0x10XX0010','告警位(附录1,按位解析显示告警含义)')+ + row('0x10XX81FF','断路器控制下发(MBMS→BCU,总正/总负继电器,总正+总负同帧)')+ row('XX','BCU 节点地址 01h~36h,同一节点的报文归入同一卡片')+ - row('订阅消息','pCanBridge 发布的 CAN_0x* 二进制报文')+'
', + row('订阅消息','pCanBridge 发布的 CAN_0x* 二进制报文')+ + row('下行通道','pCCU 发布 CAN_TX_0x* → pCanBridge → USBCAN')+'', fcCmd: pageHeader(0x0001,'FC控制指令','CCU→FC')+'
'+fcCmdCard(snap.fcCmd)+'
', // 0x0004 PM 状态报文(CCU→PM):含锂电池/应急电池数据 pmStatus: pageHeader(0x0004,'PM状态报文','CCU→PM')+'
'+ diff --git a/src/pCanBridge/CanBridge.cpp b/src/pCanBridge/CanBridge.cpp index f11fa3c..6a024a4 100644 --- a/src/pCanBridge/CanBridge.cpp +++ b/src/pCanBridge/CanBridge.cpp @@ -90,25 +90,70 @@ bool CanBridge::OnConnectToServer() { } //--------------------------------------------------------- -// registerVariables:纯发布应用,无需注册订阅变量 +// registerVariables:订阅下行指令(CAN_TX_0x*,pCCU 发布的 +// BCU 断路器控制帧等)。 void CanBridge::registerVariables() { AppCastingMOOSApp::RegisterVariables(); + // 通配订阅必须用三参数重载(变量模式 + 来源模式) + Register("CAN_TX_0x*", "*", 0); } //--------------------------------------------------------- -// OnNewMail:当前无订阅指令,预留 +// OnNewMail:下行指令 CAN_TX_0x%08X(二进制数据域) bool CanBridge::OnNewMail(MOOSMSG_LIST &NewMail) { AppCastingMOOSApp::OnNewMail(NewMail); MOOSMSG_LIST::iterator p; for (p = NewMail.begin(); p != NewMail.end(); p++) { CMOOSMsg &msg = *p; - (void)msg; + if (msg.m_sKey.rfind("CAN_TX_0x", 0) == 0) + handleTxMessage(msg); } return true; } +//--------------------------------------------------------- +// handleTxMessage:MOOS 下行消息 -> CANET 13 字节帧 -> USBCAN +// +// m_sKey = "CAN_TX_0x%08X"(CAN ID) +// m_sVal = 二进制数据域(dlc<=8) +// 帧信息:id>0x7FF 视为扩展帧(bit7=1),DLC=数据字节数。 + +void CanBridge::handleTxMessage(CMOOSMsg& msg) { + if (!m_endpoint) return; + + uint32_t id = 0; + // "CAN_TX_0x" 前缀长 9 字符(注意不是 8) + int sr = std::sscanf(msg.m_sKey.c_str() + 9, "%x", &id); + if (sr != 1) { + reportRunWarning("CAN_TX bad key: " + msg.m_sKey); + return; + } + if (!msg.IsBinary() || msg.GetBinaryDataSize() <= 0 || + msg.GetBinaryDataSize() > 8) { + reportRunWarning("CAN_TX bad data size: " + msg.m_sKey); + return; + } + + const unsigned char* d = msg.GetBinaryData(); + if (!d) return; + int dlc = static_cast(msg.GetBinaryDataSize()); + uint8_t frameInfo = static_cast(((id > 0x7FF) ? 0x80 : 0x00) | dlc); + + if (m_endpoint->sendFrame(frameInfo, id, d, dlc)) { + ++m_txCount; + char hex[32] = {0}; + for (int i = 0; i < dlc && i < 8; ++i) + std::snprintf(hex + i * 2, 3, "%02X", d[i]); + std::cout << "[CAN-TX] 0x" << std::hex << id << std::dec + << " dlc=" << dlc << " data=" << hex << std::endl; + } else { + reportRunWarning("CAN_TX send failed: " + msg.m_sKey + + (m_endpoint->isConnected() ? "" : " (未连接)")); + } +} + //--------------------------------------------------------- // Iterate:周期任务 @@ -146,7 +191,7 @@ void CanBridge::handleFrame(uint8_t frameInfo, uint32_t id, bool CanBridge::buildReport() { m_msgs << "============================================" << "\n"; - m_msgs << "pCanBridge CAN->MOOSDB 透传" << "\n"; + m_msgs << "pCanBridge CAN<->MOOSDB 双向透传" << "\n"; m_msgs << "============================================" << "\n"; if (m_endpoint) { m_msgs << "目标: " << m_endpoint->host() << ":" @@ -154,6 +199,9 @@ bool CanBridge::buildReport() { m_msgs << "连接状态: " << (m_endpoint->isConnected() ? "已连接" : "断开") << "\n"; m_msgs << "收帧数: " << m_endpoint->frameCount() << "\n"; m_msgs << "发布数: " << m_notifyCount << "\n"; + m_msgs << "下行帧数: " << m_endpoint->txCount() << " (错误 " + << m_endpoint->txErrorCount() << ")" << "\n"; + m_msgs << "MOOS下发数: " << m_txCount << "\n"; m_msgs << "错误数: " << m_endpoint->errorCount() << "\n"; m_msgs << "重连次数: " << m_endpoint->reconnectCount() << "\n"; m_msgs << "最近收帧: " << m_endpoint->lastFrameTime() << "\n"; diff --git a/src/pCanBridge/CanBridge.h b/src/pCanBridge/CanBridge.h index 6ba2c30..5d404f2 100644 --- a/src/pCanBridge/CanBridge.h +++ b/src/pCanBridge/CanBridge.h @@ -10,14 +10,20 @@ namespace canbridge { //============================================================================ -// CanBridge:CAN -> MOOSDB 透传桥(MOOS 应用外壳)。 +// CanBridge:CAN <-> MOOSDB 双向透传桥(MOOS 应用外壳)。 // -// 数据流: +// 数据流(上行): // USBCAN-8E-U (TCP Server, 默认 192.168.0.222:4001 = CAN0) // -> CanEndpoint(TCP Client,13 字节切帧) // -> handleFrame() 解析 // -> Notify 到 MOOSDB // +// 数据流(下行,BCU 断路器/继电器控制等): +// MOOS "CAN_TX_0x%08X"(二进制数据域,dlc<=8) +// -> OnNewMail 解析 CAN ID 与数据 +// -> CanEndpoint::sendFrame(同一 TCP 连接写出 13 字节帧) +// -> USBCAN -> CAN 总线 +// // MOOS 消息映射: // m_sKey = "CAN_0x%08X" CAN ID(完整 4 字节,标准/扩展帧同格式) // m_sVal = 二进制 data(dlc 字节,MOOS_BINARY_STRING) @@ -25,7 +31,8 @@ namespace canbridge { // m_dfVal2 = 原始帧信息字节 byte0(bit7 FF / bit6 RTR / bit3~0 DLC) // m_nID 不使用(MOOS 内部消息序号) // -// 每帧同时落库 SQLite(can_frame 表,配置项 dbpath)。 +// 每帧(上行)同时落库 SQLite(can_frame 表,配置项 dbpath); +// 下行帧仅日志记录(can_frame 表无方向列,保持旧库兼容)。 //============================================================================ class CanBridge : public AppCastingMOOSApp { @@ -45,6 +52,9 @@ private: // 收帧回调(CanEndpoint 接收线程调用) void handleFrame(uint8_t frameInfo, uint32_t id, const uint8_t* data, int dlc); + // 下行指令处理(OnNewMail 调用):CAN_TX_0x%08X -> CanEndpoint 发送 + void handleTxMessage(CMOOSMsg& msg); + // 配置 std::string m_canHost = "192.168.0.222"; long m_canPort = 4001; @@ -55,6 +65,7 @@ private: CanEndpoint* m_endpoint = nullptr; CanDbStore* m_db = nullptr; std::atomic m_notifyCount{0}; + std::atomic m_txCount{0}; }; } // namespace canbridge diff --git a/src/pCanBridge/CanEndpoint.cpp b/src/pCanBridge/CanEndpoint.cpp index 8792fa8..abcd415 100644 --- a/src/pCanBridge/CanEndpoint.cpp +++ b/src/pCanBridge/CanEndpoint.cpp @@ -12,7 +12,6 @@ #include #include #include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h" - namespace canbridge { namespace { @@ -128,6 +127,49 @@ void CanEndpoint::closeSocket() { } } +//---------------------------------------------------------------------- +// 下行发送:组装 13 字节 CANET 帧并写出 +// 接收线程可能并发断线重连(m_fd 被关闭),用 m_txMutex 串行化: +// - 持锁期间检查 m_connected,避免对已关闭 fd 发送 +// - 接收线程的 closeSocket 不持此锁,若恰逢发送中出现 EPIPE 等 +// 错误按发送失败计,断线由接收线程统一检出 +//---------------------------------------------------------------------- +bool CanEndpoint::sendFrame(uint8_t frameInfo, uint32_t id, + const uint8_t* data, int dlc) { + if (dlc < 0 || dlc > 8) return false; + + uint8_t frame[kFrameLen] = {0}; + frame[0] = frameInfo; + frame[1] = static_cast((id >> 24) & 0xFF); + frame[2] = static_cast((id >> 16) & 0xFF); + frame[3] = static_cast((id >> 8) & 0xFF); + frame[4] = static_cast(id & 0xFF); + if (data && dlc > 0) + std::memcpy(frame + 5, data, static_cast(dlc)); + + std::lock_guard lock(m_txMutex); + if (!m_connected || m_fd < 0) { + ++m_txErrorCount; + std::cerr << "[CanEndpoint] send failed: not connected" << std::endl; + return false; + } + size_t sent = 0; + while (sent < kFrameLen) { + ssize_t n = ::send(m_fd, frame + sent, kFrameLen - sent, MSG_NOSIGNAL); + if (n > 0) { + sent += static_cast(n); + continue; + } + if (n < 0 && (errno == EINTR)) continue; + ++m_txErrorCount; + std::cerr << "[CanEndpoint] send error: " + << std::strerror(errno) << std::endl; + return false; + } + ++m_txCount; + return true; +} + //---------------------------------------------------------------------- // 切帧:缓冲 >= 13 字节即解析并回调 //---------------------------------------------------------------------- diff --git a/src/pCanBridge/CanEndpoint.h b/src/pCanBridge/CanEndpoint.h index f8a3c2c..c2488f8 100644 --- a/src/pCanBridge/CanEndpoint.h +++ b/src/pCanBridge/CanEndpoint.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -48,8 +49,15 @@ public: bool isConnected() const { return m_connected; } + // 下行发送一帧 CAN(MOOS->CAN 方向,须已连接)。 + // frameInfo: bit7 FF(1扩展/0标准) bit6 RTR bit3~0 DLC; + // data 为 dlc(0~8) 字节数据域。返回是否成功写出。 + bool sendFrame(uint8_t frameInfo, uint32_t id, const uint8_t* data, int dlc); + // 统计 unsigned long frameCount() const { return m_frameCount; } + unsigned long txCount() const { return m_txCount; } + unsigned long txErrorCount() const { return m_txErrorCount; } unsigned long errorCount() const { return m_errorCount; } unsigned long reconnectCount() const { return m_reconnectCount; } double lastFrameTime() const { return m_lastFrameTime; } @@ -70,6 +78,7 @@ private: std::atomic m_running{false}; std::atomic m_connected{false}; std::thread m_thread; + std::mutex m_txMutex; // 下行 send 与断线 close 的并发保护 CanFrameCallback m_onFrame; @@ -77,6 +86,8 @@ private: std::vector m_buf; std::atomic m_frameCount{0}; + std::atomic m_txCount{0}; + std::atomic m_txErrorCount{0}; std::atomic m_errorCount{0}; std::atomic m_reconnectCount{0}; std::atomic m_lastFrameTime{0.0}; diff --git a/src/pCanBridge/pCanBridge.moos b/src/pCanBridge/pCanBridge.moos index 4e16671..74fafc7 100644 --- a/src/pCanBridge/pCanBridge.moos +++ b/src/pCanBridge/pCanBridge.moos @@ -1,12 +1,13 @@ //============================================================================ // pCanBridge 配置示例 // -// 拓扑: -// USBCAN-8E-U(CANET 系列,TCP Server 模式) -// 192.168.0.222:4001 = CAN0 工作端口(CAN1=4002 ... CAN7=4008) -// pCanBridge 以 TCP Client 连接目标,收帧后透传 MOOSDB: -// 变量名 CAN_0x%08X = CAN ID;m_sVal = 二进制 data; -// m_sSrcAux = 通道名;m_dfVal2 = 原始帧信息字节(FF/RTR/DLC) +// 拓扑(双向透传): +// 上行:USBCAN-8E-U(CANET TCP Server,192.168.0.222:4001=CAN0) +// -> pCanBridge(TCP Client) -> MOOS "CAN_0x%08X"(二进制 data) +// 下行:MOOS "CAN_TX_0x%08X"(二进制 data,dlc<=8,如 BCU 断路器控制 +// 帧 0x10XX81FF,由 pCCU 发布)-> pCanBridge -> USBCAN -> CAN 总线 +// 变量名 CAN_0x%08X = CAN ID;m_sVal = 二进制 data; +// m_sSrcAux = 通道名;m_dfVal2 = 原始帧信息字节(FF/RTR/DLC) //============================================================================ ProcessConfig = pCanBridge diff --git a/test/pccu/pccuTest.cpp b/test/pccu/pccuTest.cpp index 74eb684..7f7d1ec 100644 --- a/test/pccu/pccuTest.cpp +++ b/test/pccu/pccuTest.cpp @@ -520,6 +520,25 @@ static void testRcuRegistry() { CHECK(gotName == "pm_status"); } +//-------------------------------------------------------------------------- +// 13. BCU 断路器控制帧(0x10XX81FF 下行) +static void testBcuCtrlFrame() { + // CAN ID:0x10 XX 81 FF,XX=节点地址 + CHECK(bcuCtrlCanId(1) == 0x100181FFu); + CHECK(bcuCtrlCanId(54) == 0x103681FFu); + + // 数据域:02 00 总正 总负 FF*4 + uint8_t d[8]; + buildBcuRelayCtrlData(1, 1, d); + CHECK(d[0] == 0x02 && d[1] == 0x00); + CHECK(d[2] == 0x01 && d[3] == 0x01); + CHECK(d[4] == 0xFF && d[5] == 0xFF && d[6] == 0xFF && d[7] == 0xFF); + buildBcuRelayCtrlData(0, 0, d); + CHECK(d[2] == 0x00 && d[3] == 0x00); + buildBcuRelayCtrlData(1, 0, d); + CHECK(d[2] == 0x01 && d[3] == 0x00); +} + //-------------------------------------------------------------------------- int main() { testFrameLengths(); @@ -535,6 +554,7 @@ int main() { testDisRoundTrip(); testRegistryLengthDispatch(); testRcuRegistry(); + testBcuCtrlFrame(); std::printf("\n==== pccuTest: %d passed, %d failed ====\n", g_pass, g_fail); return g_fail == 0 ? 0 : 1; diff --git a/test/pccu/pccu_integration_test.py b/test/pccu/pccu_integration_test.py index 4005648..e32362d 100644 --- a/test/pccu/pccu_integration_test.py +++ b/test/pccu/pccu_integration_test.py @@ -33,6 +33,7 @@ import subprocess import os import signal import sys +import json import urllib.request # ---- 配置 ---- @@ -42,10 +43,12 @@ 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") # 帧头 @@ -184,6 +187,72 @@ def dis_lv_cmd_frame(): 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) @@ -204,11 +273,13 @@ def recv_loop(sock, label, results, msg_ids, timeout): def main(): # 清理 - for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm", "/tmp/pccu_it_test.log"): + 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"], @@ -216,11 +287,15 @@ def main(): procs.append(moosdb) time.sleep(1.0) - # 2. 启动 pCCU + # 2. 启动 pCCU + pCanBridge(pCanBridge 连接模拟 USBCAN,用于下行测试) pccu = subprocess.Popen([PCCU_EXE, MISSION], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) procs.append(pccu) - time.sleep(2.0) + 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 @@ -349,6 +424,39 @@ def main(): 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) @@ -363,6 +471,7 @@ def main(): return 0 if ok else 1 finally: + canet.stop_flag = True for p in reversed(procs): try: p.terminate() diff --git a/test/pccu/pccu_it.moos b/test/pccu/pccu_it.moos index 0ded712..8e5de0d 100644 --- a/test/pccu/pccu_it.moos +++ b/test/pccu/pccu_it.moos @@ -30,4 +30,16 @@ ProcessConfig = pCCU web_port = 18081 // 避开 pPowerMangerHost(18080) web_enable = true +} + +ProcessConfig = pCanBridge +{ + AppTick = 4 + CommsTick = 4 + + // 测试用 CANET TCP Server(本脚本模拟,捕获 pCanBridge 下行帧) + can_host = 127.0.0.1 + can_port = 16006 + can_channel_name = CAN0 + dbpath = /tmp/pccu_it_can.db } \ No newline at end of file