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服务器验证完整下行链路(闭合/断开/非法地址)
This commit is contained in:
zjk
2026-09-01 15:23:30 +08:00
parent 0639b5904d
commit 90caacf9a2
15 changed files with 517 additions and 22 deletions
+89 -1
View File
@@ -5,6 +5,7 @@
#include "protocol/CanBms.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
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<uint8_t>(addr);
c.pos = static_cast<uint8_t>(pos);
c.neg = static_cast<uint8_t>(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<unsigned int>(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<int>(bc.last.addr)
<< " 总正=" << static_cast<int>(bc.last.pos)
<< " 总负=" << static_cast<int>(bc.last.neg) << ")\n";
}
if (m_db) {
m_msgs << "数据库记录数:" << m_db->count() << "\n";
+5
View File
@@ -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";
+2
View File
@@ -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 = 运行状态字符串 ");
+15
View File
@@ -679,6 +679,20 @@ std::string SnapshotBuilder::build() const {
root["canFrameCount"] = JsonVal(static_cast<Json::UInt>(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<Json::UInt>(bc.sentCount));
jc["errCount"] = JsonVal(static_cast<Json::UInt>(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);
+57
View File
@@ -3,6 +3,8 @@
#include <mutex>
#include <atomic>
#include <vector>
#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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<BcuCtrlCmd> m_bcuCtrlQueue;
BcuCtrlStat m_bcuCtrlStat;
unsigned long m_fcStatusCount = 0;
unsigned long m_pmControlCount = 0;
unsigned long m_fcControlCount = 0;
+30 -1
View File
@@ -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<uint8_t>(canId & 0xFF);
}
//--------------------------------------------------------------------------
// BCU 断路器(总正/总负继电器)下行控制
//--------------------------------------------------------------------------
// BCU 节点继电器控制指令 CAN ID(0x10XX81FF,XX=节点地址 01h~36h)
inline uint32_t bcuCtrlCanId(uint8_t addr) {
return 0x10000000u | (static_cast<uint32_t>(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);
+48 -3
View File
@@ -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;}
</style>
</head>
<body>
@@ -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 ? '<span class="fc-ok">已投递</span>' : '<span class="fc-bad">投递失败</span>'));
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)',
'<div class="row"><span class="k">数据状态</span><span class="v">等待报文...</span></div>');
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('操作',
'<button class="btn on" onclick="bcuCtrl('+n.addr+',1,1,\'断路器闭合\')">断路器闭合</button>'+
'<button class="btn off" onclick="bcuCtrl('+n.addr+',0,0,\'断路器断开\')">断路器断开</button>');
// 绝缘/端口电压(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')+'<div class="grid">'+pmParamCard(snap.pmParam)+'</div>',
// 锂电池 BCU 节点(CAN 总线,经 pCanBridge 透传,按节点分组展示)
bms: '<div class="grid">'+bmsCard(snap.bms)+
bcuCtrlStatCard(snap.bcuCtrl)+
'<div class="card"><h2>报文来源 (CAN ID)</h2>'+
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* 二进制报文')+'</div></div>',
row('订阅消息','pCanBridge 发布的 CAN_0x* 二进制报文')+
row('下行通道','pCCU 发布 CAN_TX_0x* → pCanBridge → USBCAN')+'</div></div>',
fcCmd: pageHeader(0x0001,'FC控制指令','CCU→FC')+'<div class="grid">'+fcCmdCard(snap.fcCmd)+'</div>',
// 0x0004 PM 状态报文(CCU→PM):含锂电池/应急电池数据
pmStatus: pageHeader(0x0004,'PM状态报文','CCU→PM')+'<div class="grid">'+
+52 -4
View File
@@ -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<int>(msg.GetBinaryDataSize());
uint8_t frameInfo = static_cast<uint8_t>(((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";
+14 -3
View File
@@ -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<unsigned long> m_notifyCount{0};
std::atomic<unsigned long> m_txCount{0};
};
} // namespace canbridge
+43 -1
View File
@@ -12,7 +12,6 @@
#include <iostream>
#include <chrono>
#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<uint8_t>((id >> 24) & 0xFF);
frame[2] = static_cast<uint8_t>((id >> 16) & 0xFF);
frame[3] = static_cast<uint8_t>((id >> 8) & 0xFF);
frame[4] = static_cast<uint8_t>(id & 0xFF);
if (data && dlc > 0)
std::memcpy(frame + 5, data, static_cast<size_t>(dlc));
std::lock_guard<std::mutex> 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<size_t>(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 字节即解析并回调
//----------------------------------------------------------------------
+11
View File
@@ -4,6 +4,7 @@
#include <cstdint>
#include <string>
#include <vector>
#include <mutex>
#include <atomic>
#include <thread>
#include <functional>
@@ -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<bool> m_running{false};
std::atomic<bool> m_connected{false};
std::thread m_thread;
std::mutex m_txMutex; // 下行 send 与断线 close 的并发保护
CanFrameCallback m_onFrame;
@@ -77,6 +86,8 @@ private:
std::vector<uint8_t> m_buf;
std::atomic<unsigned long> m_frameCount{0};
std::atomic<unsigned long> m_txCount{0};
std::atomic<unsigned long> m_txErrorCount{0};
std::atomic<unsigned long> m_errorCount{0};
std::atomic<unsigned long> m_reconnectCount{0};
std::atomic<double> m_lastFrameTime{0.0};
+7 -6
View File
@@ -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
+20
View File
@@ -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;
+112 -3
View File
@@ -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()
+12
View File
@@ -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
}