From 873a1a9b09293e892234ba3c01689bcd91cc3dfa Mon Sep 17 00:00:00 2001 From: zjk <1553836110@qq.com> Date: Wed, 2 Sep 2026 10:50:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20pMotor=20=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=E7=94=B5=E6=9C=BA=E6=93=8D=E4=BD=9C=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=EF=BC=9A=E7=BB=8F=20pCanBridge(CAN1)=20=E4=B8=8E=E7=94=B5?= =?UTF-8?q?=E6=9C=BA=E6=8E=A7=E5=88=B6=E5=99=A8=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 协议模块(protocol/MotorCan):12 条 CAN ID、指令帧 0x18EF2010 编码、 状态帧(转速/母线电压/温度)与支路一/二故障报警帧解码,故障位按协议 逐位映射中文描述(docs/推进电机通信协议20260321.docx) - 主程序(MOOS 应用):订阅 pCanBridge 透传 CAN_0x* 解码电机状态; 500ms 周期下发指令帧(CAN_TX_0x18EF2010,m_sSrcAux=CAN1), 可选 red_channel 冗余下发 0x18EF2011;复位位保持 reset_hold_sec - 网页(18081):按协议指令帧字段独立下发(使能命令 Byte0 bit0 / 复位命令 Byte0 bit1 / 期望转速 Byte2/3,无联动按钮); WebSocket 1Hz 推送转速/电压/温度/故障告警/链路统计 - 配置接入:missions/h100.moos 增加 pMotor 配置块(can_channel=CAN1) - 测试:pmotorTest 92 项(指令帧字节级对照协议示例、解码/故障表) - 部署:scripts/build-board.sh 增加 pMotor 产物拷贝 + pMotor.service; .gitignore 补 bin/pMotor、bin/pmotorTest - 联调验证:MOOSDB+pCanBridge+假CANET 全链路,下行帧 01 00 E8 03 与协议文档示例一致,复位位 3s 保持行为正确 --- .gitignore | 2 + docs/推进电机通信协议20260321.docx | Bin 0 -> 60792 bytes missions/h100.moos | 33 +- scripts/build-board.sh | 32 +- src/CMakeLists.txt | 1 + src/pMotor/CMakeLists.txt | 46 +++ src/pMotor/Motor.cpp | 467 +++++++++++++++++++++++++++++ src/pMotor/Motor.h | 96 ++++++ src/pMotor/Motor_Info.cpp | 103 +++++++ src/pMotor/Motor_Info.h | 15 + src/pMotor/main.cpp | 54 ++++ src/pMotor/pMotor.moos | 37 +++ src/pMotor/protocol/MotorCan.cpp | 219 ++++++++++++++ src/pMotor/protocol/MotorCan.h | 154 ++++++++++ src/pMotor/web/WebServer.cpp | 144 +++++++++ src/pMotor/web/WebServer.h | 66 ++++ src/pMotor/web/pages/index.h | 271 +++++++++++++++++ src/pPowerManger/PowerManger.h | 2 +- test/CMakeLists.txt | 20 ++ test/pmotor/pmotorTest.cpp | 255 ++++++++++++++++ 20 files changed, 2007 insertions(+), 10 deletions(-) create mode 100644 docs/推进电机通信协议20260321.docx create mode 100644 src/pMotor/CMakeLists.txt create mode 100644 src/pMotor/Motor.cpp create mode 100644 src/pMotor/Motor.h create mode 100644 src/pMotor/Motor_Info.cpp create mode 100644 src/pMotor/Motor_Info.h create mode 100644 src/pMotor/main.cpp create mode 100644 src/pMotor/pMotor.moos create mode 100644 src/pMotor/protocol/MotorCan.cpp create mode 100644 src/pMotor/protocol/MotorCan.h create mode 100644 src/pMotor/web/WebServer.cpp create mode 100644 src/pMotor/web/WebServer.h create mode 100644 src/pMotor/web/pages/index.h create mode 100644 test/pmotor/pmotorTest.cpp diff --git a/.gitignore b/.gitignore index 4f208a0..d57c068 100644 --- a/.gitignore +++ b/.gitignore @@ -252,6 +252,7 @@ bin/pPowerMangerHost bin/pCCU bin/pccuTest bin/pCanBridge +bin/pMotor # CI test reports reports/ @@ -260,6 +261,7 @@ reports/ bin/PowerMangerTests bin/udpFeeder bin/fullFeeder +bin/pmotorTest # ============================================================ # 数据库与日志文件(全局忽略,一律不入库) diff --git a/docs/推进电机通信协议20260321.docx b/docs/推进电机通信协议20260321.docx new file mode 100644 index 0000000000000000000000000000000000000000..565f1e0fd173009e3852357698f40ea6d0989163 GIT binary patch literal 60792 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 literal 0 HcmV?d00001 diff --git a/missions/h100.moos b/missions/h100.moos index cec1897..f7bad4b 100644 --- a/missions/h100.moos +++ b/missions/h100.moos @@ -1,6 +1,6 @@ // MOOS file // 板卡 (RK3588 / Ubuntu 20.04) 专用 mission 配置 -// 五个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU / pCanBridge) +// 六个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU / pCanBridge / pMotor) // 共用本文件。 // 由 scripts/deploy.sh 推送到板卡 /root/work/h100/missions/h100.moos @@ -87,11 +87,34 @@ ProcessConfig = pCanBridge // CAN<->MOOSDB 多通道透传:CANET(TCP Server 模式) // 每通道一条独立 TCP 连接;工作端口:CAN0=4001,CAN1=4002,...,CAN7=4008 channel = CAN0,192.168.0.222,4001 + channel = CAN1,192.168.0.222,4002 // channel = CAN1,192.168.0.222,4002 - // CAN_TX 下行默认通道(pCCU 未指定 m_sSrcAux 时使用;可省略) - default_channel = CAN0 + // CAN_TX 下行默认通道(pCCU 未指定 m_sSrcAux 时使用;可省略) + default_channel = CAN0 - // CAN 帧 SQLite 落库路径(can_frame 表,channel 列区分通道) - dbpath = /root/work/h100/data/pCanBridge_data.db + // CAN 帧 SQLite 落库路径(can_frame 表,channel 列区分通道) + dbpath = /root/work/h100/data/pCanBridge_data.db +} + +ProcessConfig = pMotor +{ + AppTick = 4 + CommsTick = 4 + + // 推进电机控制器:CAN1 通道(经 pCanBridge 透传,docs/推进电机通信协议20260321.docx) + // 下行 500ms 周期指令帧 0x18EF2010(网页 /api/motor_cmd 控制启停/复位/转速), + // 上行订阅 CAN_0x* 解码故障/转速/母线电压/温度,网页 18081 展示 + can_channel = CAN1 + + // 冗余 CAN 通道(配置后指令帧同内容发 0x18EF2011;留空=不发送) + // red_channel = CAN2 + + cmd_period_ms = 500 + reset_hold_sec = 5 + + web_port = 18081 + web_enable = true + + logpath = /root/work/h100/data/pMotor.log } diff --git a/scripts/build-board.sh b/scripts/build-board.sh index 0268902..ea9483d 100755 --- a/scripts/build-board.sh +++ b/scripts/build-board.sh @@ -7,7 +7,7 @@ # # 用法: # ./scripts/build-board.sh # 同步+编译+部署(不重启) -# ./scripts/build-board.sh --start # 编译后重启全部服务(含 pCanBridge) +# ./scripts/build-board.sh --start # 编译后重启全部服务(含 pCanBridge/pMotor) # ./scripts/build-board.sh --clean # 板卡上重新 cmake 再编译 # ./scripts/build-board.sh --jobs 4 # 指定并行度(默认板卡 nproc) # @@ -68,7 +68,7 @@ DO_CLEAN=0 CCU_HOST="" CCU_PORT="" -SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge) +SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor) info() { printf '\033[1;36m[build-board]\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m[build-board]\033[0m %s\n' "$*"; } @@ -211,12 +211,13 @@ ${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_BIN} && \ cp -f ${BOARD_SRC}/bin/pPowerMangerHost ${BOARD_BIN}/pPowerMangerHost && \ cp -f ${BOARD_SRC}/bin/pCCU ${BOARD_BIN}/pCCU && \ cp -f ${BOARD_SRC}/bin/pCanBridge ${BOARD_BIN}/pCanBridge && \ - chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU ${BOARD_BIN}/pCanBridge" \ + cp -f ${BOARD_SRC}/bin/pMotor ${BOARD_BIN}/pMotor && \ + chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU ${BOARD_BIN}/pCanBridge ${BOARD_BIN}/pMotor" \ || { err "部署产物失败"; exit 1; } ok "产物已部署" #------------------------------------------------------------------- -# 4.5 安装/刷新 pCanBridge systemd unit(幂等,其它 4 个 unit 已装) +# 4.5 安装/刷新 pCanBridge systemd unit(幂等,其它 5 个 unit 已装) #------------------------------------------------------------------- info "安装 pCanBridge.service ..." ${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pCanBridge.service" </dev/null || true" ok "pCanBridge.service 已安装" +#------------------------------------------------------------------- +# 4.6 安装/刷新 pMotor systemd unit(幂等) +#------------------------------------------------------------------- +info "安装 pMotor.service ..." +${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pMotor.service" </dev/null || true" +ok "pMotor.service 已安装" + echo "" ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)" ${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ebb2c8e..726de24 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ add_subdirectory(pPMtest) add_subdirectory(pPowerMangerHost) add_subdirectory(pCCU) add_subdirectory(pCanBridge) +add_subdirectory(pMotor) ############################################################################## # END of CMakeLists.txt ############################################################################## diff --git a/src/pMotor/CMakeLists.txt b/src/pMotor/CMakeLists.txt new file mode 100644 index 0000000..b74ac62 --- /dev/null +++ b/src/pMotor/CMakeLists.txt @@ -0,0 +1,46 @@ +#-------------------------------------------------------- +# The CMakeLists.txt for: pMotor +# 推进电机操作程序:网页控制 <-> pCanBridge(CAN1) <-> 电机控制器 +#-------------------------------------------------------- + +if (${WIN32}) + SET(SYSTEM_LIBS wsock32) +else (${WIN32}) + SET(SYSTEM_LIBS m pthread) +endif (${WIN32}) + +# 复用仓库内 pPowerManger 的公共源码(相对路径引用,避免重复维护) +SET(PM_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../pPowerManger) + +SET(SHARED_SRC + ${PM_DIR}/logc/loguru.cpp + ${PM_DIR}/httpserver/mongoose.c +) + +SET(SRC + ${SHARED_SRC} + protocol/MotorCan.cpp + web/WebServer.cpp + Motor.cpp + Motor_Info.cpp + main.cpp +) + +ADD_EXECUTABLE(pMotor ${SRC}) + +TARGET_INCLUDE_DIRECTORIES(pMotor PRIVATE + ${PM_DIR} + ${PM_DIR}/httpserver + ${PM_DIR}/logc +) + +TARGET_LINK_LIBRARIES(pMotor + ${MOOS_LIBRARIES} + apputil + mbutil + m + pthread + jsoncpp + dl + ${SYSTEM_LIBS} +) diff --git a/src/pMotor/Motor.cpp b/src/pMotor/Motor.cpp new file mode 100644 index 0000000..5ce6f1f --- /dev/null +++ b/src/pMotor/Motor.cpp @@ -0,0 +1,467 @@ +#include "Motor.h" +#include "MBUtils.h" +#include "../pPowerManger/logc/loguru.hpp" +#include "json/json.h" +#include +#include + +using namespace motor; +using namespace std; + +//--------------------------------------------------------- +// Constructor / Destructor + +Motor::Motor() {} + +Motor::~Motor() { + if (m_web) { m_web->stop(); delete m_web; m_web = nullptr; } +} + +//--------------------------------------------------------- +// OnStartUp:读取配置并初始化各组件 + +bool Motor::OnStartUp() { + AppCastingMOOSApp::OnStartUp(); + + STRING_LIST sParams; + m_MissionReader.EnableVerbatimQuoting(false); + if (!m_MissionReader.GetConfiguration(GetAppName(), sParams)) + reportConfigWarning("No config block found for " + GetAppName()); + + STRING_LIST::iterator p; + for (p = sParams.begin(); p != sParams.end(); p++) { + string orig = *p; + string line = *p; + string param = stripBlankEnds(tolower(biteStringX(line, '='))); + string value = stripBlankEnds(line); + + bool handled = true; + if (param == "can_channel") m_canChannel = value; + else if (param == "red_channel") m_redChannel = value; + else if (param == "cmd_period_ms") m_cmdPeriodMs = atoi(value.c_str()); + else if (param == "reset_hold_sec") m_resetHoldSec = atof(value.c_str()); + else if (param == "web_port") m_webPort = atoi(value.c_str()); + else if (param == "web_enable") m_webEnable = (tolower(value) == "true" || value == "1"); + else if (param == "logpath") m_logPath = value; + else handled = false; + + if (!handled) + reportUnhandledConfigWarning(orig); + } + if (m_cmdPeriodMs < 50) m_cmdPeriodMs = 50; + + registerVariables(); + + // 日志文件 + if (m_logPath.empty()) m_logPath = "pMotor.log"; + loguru::add_file(m_logPath.c_str(), loguru::Append, loguru::Verbosity_MAX); + LOG_F(INFO, "pMotor log path: %s", m_logPath.c_str()); + + // 网页 + if (m_webEnable) { + m_web = new WebServer(); + m_web->setOnOpen([this]() { return buildSnapshot(); }); + m_web->setApiHandler([this](const std::string& uri, const std::string& query) { + return handleApi(uri, query); + }); + if (!m_web->start(m_webPort)) { + LOG_F(ERROR, "web server start failed on port %d", m_webPort); + delete m_web; + m_web = nullptr; + } + } + + LOG_F(INFO, "pMotor started: motor CAN via pCanBridge channel=%s (red_channel=%s), " + "cmd period=%dms, web=%s", + m_canChannel.c_str(), m_redChannel.c_str(), m_cmdPeriodMs, + m_web ? ("port " + to_string(m_webPort)).c_str() : "disabled"); + return true; +} + +//--------------------------------------------------------- +// OnConnectToServer + +bool Motor::OnConnectToServer() { + registerVariables(); + return true; +} + +//--------------------------------------------------------- +// registerVariables:订阅 pCanBridge 透传的全部 CAN 帧 + +void Motor::registerVariables() { + AppCastingMOOSApp::RegisterVariables(); + // 通配订阅必须用三参数重载(变量模式 + 来源模式) + Register("CAN_0x*", "*", 0); +} + +//--------------------------------------------------------- +// OnNewMail + +bool Motor::OnNewMail(MOOSMSG_LIST &NewMail) { + AppCastingMOOSApp::OnNewMail(NewMail); + MOOSMSG_LIST::iterator p; + for (p = NewMail.begin(); p != NewMail.end(); p++) { + CMOOSMsg &msg = *p; + if (msg.m_sKey.rfind("CAN_0x", 0) == 0) + handleCanMessage(msg); + } + return true; +} + +//--------------------------------------------------------- +// handleCanMessage:解码 pCanBridge 透传的 CAN 帧 +// +// 消息格式(pCanBridge 约定): +// m_sKey = "CAN_0x%08X"(CAN ID,扩展帧) +// m_sVal = 二进制数据域(8 字节) +// m_dfVal2 = 原始帧信息字节(bit7 FF / bit6 RTR / bit3~0 DLC) + +void Motor::handleCanMessage(CMOOSMsg& msg) { + // 从变量名解析 CAN ID(跳过前缀 "CAN_0x" 共 6 字符) + uint32_t canId = 0; + if (std::sscanf(msg.m_sKey.c_str() + 6, "%x", &canId) != 1) return; + if (!isCanMotorId(canId)) return; + + if (!(msg.IsBinary() && msg.GetBinaryDataSize() >= 8)) return; + const unsigned char* d = msg.GetBinaryData(); + if (!d) return; + + // 日志值在锁内取副本,避免与 Web 线程快照读取竞争 + uint32_t rx = 0; + int spd = 0; + int bus = 0; + size_t f1 = 0, f2 = 0; + { + std::lock_guard lock(m_mutex); + if (!decodeMotorFrame(canId, d, static_cast(msg.GetBinaryDataSize()), m_state)) + return; + m_state.rxCount++; + double now = MOOSTime(false); + switch (canId) { + case MOTOR_CAN_FAULT1: case MOTOR_CAN_FAULT1_RED: + m_state.branch1.lastRxTime = now; break; + case MOTOR_CAN_FAULT2: case MOTOR_CAN_FAULT2_RED: + m_state.branch2.lastRxTime = now; break; + case MOTOR_CAN_STATE3: case MOTOR_CAN_STATE3_RED: + m_state.state3LastRx = now; break; + case MOTOR_CAN_STATE4: case MOTOR_CAN_STATE4_RED: + m_state.state4LastRx = now; break; + default: break; + } + rx = m_state.rxCount; + spd = m_state.feedbackSpeed; + bus = m_state.busVoltage; + f1 = m_state.branch1.faults.size(); + f2 = m_state.branch2.faults.size(); + } + + // 节流日志:1s 一条 + double now = MOOSTime(); + if (now - m_lastRxLog >= 1.0) { + m_lastRxLog = now; + LOG_F(INFO, "[Motor/CAN] rx=%u speed=%d rpm bus=%dV faults(b1=%zu,b2=%zu)", + rx, spd, bus, f1, f2); + } +} + +//--------------------------------------------------------- +// Iterate:周期任务(AppTick 次/秒) + +bool Motor::Iterate() { + AppCastingMOOSApp::Iterate(); + + double now = MOOSTime(); + + // 网页指令出队并应用 + processCmdQueue(); + + // 周期发送指令帧(500ms,协议要求) + sendCmdFrame(now); + + // 网页快照 1Hz 推送 + if (m_web) { + static double lastWebPush = 0; + if (now - lastWebPush >= 1.0) { + lastWebPush = now; + m_web->broadcast(buildSnapshot()); + } + } + + AppCastingMOOSApp::PostReport(); + return true; +} + +//--------------------------------------------------------- +// handleApi:/api/motor_cmd + +std::string Motor::handleApi(const std::string& uri, const std::string& query) { + if (uri == "/api/motor_cmd") + return handleMotorCmdApi(query); + return ""; +} + +//--------------------------------------------------------- +// handleMotorCmdApi:电机控制指令入队(按协议指令帧字段独立下发,无联动) +// +// GET /api/motor_cmd?action=enable 使能命令=1(Byte0 bit0) +// GET /api/motor_cmd?action=disable 使能命令=0,期望转速清0(Byte0 bit0,协议"停机"示例) +// GET /api/motor_cmd?action=reset 复位命令=1(Byte0 bit1,仅故障态有效) +// GET /api/motor_cmd?action=speed&speed=1000 设定期望转速(Byte2/3,s16) +// Web 线程不可直接 Notify,指令入队后由 Iterate(MOOS 线程)应用。 + +std::string Motor::handleMotorCmdApi(const std::string& query) { + // 简易 query 解析(k=v&k=v) + auto getParam = [&query](const char* key, std::string& 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); + out = query.substr(p + k.size(), + (e == std::string::npos) ? std::string::npos + : e - p - k.size()); + return true; + }; + + std::string action, speedStr; + getParam("action", action); + getParam("speed", speedStr); + + MotorCmd c; + if (action == "enable") { + c.action = MOTOR_ACT_ENABLE; + } else if (action == "disable") { + c.action = MOTOR_ACT_DISABLE; + } else if (action == "reset") { + c.action = MOTOR_ACT_RESET; + } else if (action == "speed") { + c.action = MOTOR_ACT_SPEED; + c.speed = static_cast(atoi(speedStr.c_str())); + } else { + return "{\"ok\":false,\"error\":\"bad action (enable/disable/reset/speed)\"}"; + } + + { + std::lock_guard lock(m_mutex); + if (m_cmdQueue.size() >= 16) + return "{\"ok\":false,\"error\":\"queue full\"}"; + m_cmdQueue.push_back(c); + m_lastCmd = c; + m_lastCmdValid = true; + m_lastCmdTime = MOOSTime(false); + } + + LOG_F(INFO, "[Motor/CAN] 指令入队: action=%s speed=%d", action.c_str(), c.speed); + char buf[96]; + std::snprintf(buf, sizeof(buf), + "{\"ok\":true,\"action\":\"%s\",\"speed\":%d}", action.c_str(), c.speed); + return buf; +} + +//--------------------------------------------------------- +// processCmdQueue:出队网页指令并更新指令状态 + +void Motor::processCmdQueue() { + MotorCmd c; + double now = MOOSTime(); + { + std::lock_guard lock(m_mutex); + while (!m_cmdQueue.empty()) { + c = m_cmdQueue.front(); + m_cmdQueue.erase(m_cmdQueue.begin()); + + switch (c.action) { + case MOTOR_ACT_ENABLE: + m_enable = true; + break; + case MOTOR_ACT_DISABLE: + m_enable = false; + m_speed = 0; + m_resetUntil = 0; + break; + case MOTOR_ACT_SPEED: + m_speed = c.speed; + break; + case MOTOR_ACT_RESET: + m_resetUntil = now + m_resetHoldSec; + break; + default: + break; + } + LOG_F(INFO, "[Motor/CAN] 指令应用: action=%d speed=%d enable=%d", + c.action, m_speed, m_enable ? 1 : 0); + } + } +} + +//--------------------------------------------------------- +// sendCmdFrame:按周期编码并发送指令帧 +// 主通道 0x18EF2010;配置 red_channel 时同帧发 0x18EF2011 到冗余通道 + +void Motor::sendCmdFrame(double now) { + double lastTx = 0; + { + std::lock_guard lock(m_mutex); + if (now - m_lastTx < m_cmdPeriodMs / 1000.0) + return; + lastTx = m_lastTx; + } + if (now - lastTx < m_cmdPeriodMs / 1000.0) + return; + + uint8_t data[8]; + { + std::lock_guard lock(m_mutex); + bool reset = (now < m_resetUntil); + buildMotorCmdData(m_enable, reset, m_enable ? m_speed : 0, data); + m_lastTx = now; + } + + bool ok = postCanFrame(MOTOR_CAN_CMD, data, m_canChannel); + + int okCount = ok ? 1 : 0, errCount = ok ? 0 : 1; + if (!m_redChannel.empty()) { + bool okRed = postCanFrame(MOTOR_CAN_CMD_RED, data, m_redChannel); + okCount += okRed ? 1 : 0; + errCount += okRed ? 0 : 1; + } + { + std::lock_guard lock(m_mutex); + m_txCount += static_cast(okCount); + m_txErr += static_cast(errCount); + } +} + +//--------------------------------------------------------- +// postCanFrame:MOOS CAN_TX_0x%08X(二进制数据域,m_sSrcAux=目标通道) +// 链路:MOOS -> pCanBridge 订阅 -> 按通道路由 CanEndpoint(TCP) +// -> CANET -> CAN 总线 + +bool Motor::postCanFrame(uint32_t canId, const uint8_t data[8], const std::string& channel) { + char key[32]; + std::snprintf(key, sizeof(key), "CAN_TX_0x%08X", canId); + CMOOSMsg msg(MOOS_NOTIFY, key, 8u, data); + msg.SetSourceAux(channel); + bool ok = m_Comms.Post(msg); + if (!ok) + LOG_F(ERROR, "[Motor/CAN] 指令帧 0x%08X 投递 MOOS 失败 (通道=%s)", canId, channel.c_str()); + return ok; +} + +//--------------------------------------------------------- +// buildSnapshot:网页 JSON 快照(jsoncpp) + +namespace { + +void putI(Json::Value& j, const char* k, int v) { j[k] = Json::Value(v); } +void putU(Json::Value& j, const char* k, unsigned long v) { j[k] = Json::Value(static_cast(v)); } +void putD(Json::Value& j, const char* k, double v) { j[k] = Json::Value(v); } + +// age:距最后收到该帧的秒数;valid=false 时前端显示"从未收到" +void putAge(Json::Value& j, double lastRx, double now, bool valid) { + j["valid"] = valid ? 1 : 0; + j["age"] = valid ? (now - lastRx) : -1; +} + +Json::Value branchToJson(const MotorBranchState& br, double now) { + Json::Value j(Json::objectValue); + putAge(j, br.lastRxTime, now, br.valid); + Json::Value words(Json::arrayValue); + for (int i = 0; i < 4; ++i) words.append(static_cast(br.word[i])); + j["words"] = words; + Json::Value faults(Json::arrayValue); + for (size_t i = 0; i < br.faults.size(); ++i) faults.append(br.faults[i]); + j["faults"] = faults; + Json::Value alarms(Json::arrayValue); + for (size_t i = 0; i < br.alarms.size(); ++i) alarms.append(br.alarms[i]); + j["alarms"] = alarms; + return j; +} + +} // namespace + +std::string Motor::buildSnapshot() { + Json::Value root(Json::objectValue); + double now = MOOSTime(false); + + { + std::lock_guard lock(m_mutex); + + // 通信链路 + Json::Value link(Json::objectValue); + link["channel"] = m_canChannel; + link["redChannel"] = m_redChannel; + link["periodMs"] = m_cmdPeriodMs; + putU(link, "txCount", m_txCount); + putU(link, "txErr", m_txErr); + putU(link, "rxCount", m_state.rxCount); + link["lastTxValid"] = (m_lastTx > 0) ? 1 : 0; + putD(link, "lastTxAge", m_lastTx > 0 ? (now - m_lastTx) : -1); + root["link"] = link; + + // 指令状态 + Json::Value cmd(Json::objectValue); + putI(cmd, "enable", m_enable ? 1 : 0); + putI(cmd, "speed", m_speed); + putI(cmd, "resetActive", (now < m_resetUntil) ? 1 : 0); + cmd["lastCmdValid"] = m_lastCmdValid ? 1 : 0; + if (m_lastCmdValid) { + static const char* kActionName[] = {"", "enable", "disable", "speed", "reset"}; + int idx = (m_lastCmd.action >= 1 && m_lastCmd.action <= 4) ? m_lastCmd.action : 0; + cmd["lastCmdAction"] = kActionName[idx]; + putI(cmd, "lastCmdSpeed", m_lastCmd.speed); + putD(cmd, "lastCmdAge", m_lastCmdTime > 0 ? (now - m_lastCmdTime) : -1); + } + root["cmd"] = cmd; + + // 状态帧3:转速 / 母线电压 + Json::Value s3(Json::objectValue); + putAge(s3, m_state.state3LastRx, now, m_state.state3Valid); + putI(s3, "speed", m_state.feedbackSpeed); + putI(s3, "voltage", m_state.busVoltage); + root["state3"] = s3; + + // 状态帧4:温度 + Json::Value s4(Json::objectValue); + putAge(s4, m_state.state4LastRx, now, m_state.state4Valid); + putI(s4, "tA", m_state.tempA); + putI(s4, "tB", m_state.tempB); + putI(s4, "tC", m_state.tempC); + putI(s4, "tInv", m_state.tempInv); + putI(s4, "tFilter", m_state.tempFilter); + putI(s4, "tAir", m_state.tempAir); + root["state4"] = s4; + + // 故障/报警 + root["fault1"] = branchToJson(m_state.branch1, now); + root["fault2"] = branchToJson(m_state.branch2, now); + } + + Json::FastWriter writer; + return writer.write(root); +} + +//--------------------------------------------------------- +// buildReport + +bool Motor::buildReport() { + m_msgs << "============================================" << "\n"; + m_msgs << "pMotor 推进电机操作程序" << "\n"; + m_msgs << "============================================" << "\n"; + m_msgs << "CAN 通道: " << m_canChannel + << (m_redChannel.empty() ? "" : (" 冗余: " + m_redChannel)).c_str() + << " 指令周期: " << m_cmdPeriodMs << "ms" << "\n"; + m_msgs << "指令帧下发: " << m_txCount << " 成功 / " << m_txErr << " 失败" << "\n"; + { + std::lock_guard lock(m_mutex); + m_msgs << "电机报文接收: " << m_state.rxCount << "\n"; + m_msgs << "指令状态: enable=" << (m_enable ? 1 : 0) + << " speed=" << m_speed + << " reset=" << (MOOSTime(false) < m_resetUntil ? 1 : 0) << "\n"; + m_msgs << "反馈转速: " << m_state.feedbackSpeed + << " rpm 母线电压: " << m_state.busVoltage << "\n"; + m_msgs << "支路一故障: " << m_state.branch1.faults.size() + << " 条, 支路二故障: " << m_state.branch2.faults.size() << " 条" << "\n"; + } + return true; +} diff --git a/src/pMotor/Motor.h b/src/pMotor/Motor.h new file mode 100644 index 0000000..13219f4 --- /dev/null +++ b/src/pMotor/Motor.h @@ -0,0 +1,96 @@ +#ifndef PMOTOR_MOTOR_H +#define PMOTOR_MOTOR_H + +#define UNIX +#include "MOOS/libMOOS/Thirdparty/AppCasting/AppCastingMOOSApp.h" +#include "protocol/MotorCan.h" +#include "web/WebServer.h" +#include + +namespace motor { + +//============================================================================ +// Motor:pMotor 程序主类(MOOS 应用外壳)——推进电机操作程序。 +// +// 数据流: +// 上行:电机控制器 -> CAN1 -> CANET -> pCanBridge -> MOOS CAN_0x* +// -> pMotor 订阅解码(故障/转速/母线电压/温度)-> SystemData 合成快照 +// 下行:网页 /api/motor_cmd(启停/复位/设定转速) +// -> 指令入队 -> Iterate(MOOS 线程)按 500ms 周期编码指令帧 +// -> MOOS CAN_TX_0x18EF2010(m_sSrcAux=CAN1)-> pCanBridge +// -> CANET(CAN1) -> 电机控制器 +// 网页:WebSocket 周期推送 JSON 快照(1Hz)。 +// +// 协议定义见 docs/推进电机通信协议20260321.docx。 +//============================================================================ + +class Motor : public AppCastingMOOSApp { +public: + Motor(); + ~Motor(); + +protected: + bool OnNewMail(MOOSMSG_LIST &NewMail); + bool Iterate(); + bool OnConnectToServer(); + bool OnStartUp(); + bool buildReport(); + void registerVariables(); + +private: + // MOOS 订阅消息处理(CAN_0x* 由 pCanBridge 发布) + void handleCanMessage(CMOOSMsg& msg); + + // 网页 + std::string buildSnapshot(); + std::string handleApi(const std::string& uri, const std::string& query); + + // 控制指令:/api/motor_cmd 解析入队(Web 线程) + std::string handleMotorCmdApi(const std::string& query); + // 出队并应用控制指令(Iterate 调用) + void processCmdQueue(); + // 周期(默认 500ms)发送指令帧 0x18EF2010(+可选冗余 0x18EF2011) + void sendCmdFrame(double now); + // 经 MOOS CAN_TX_0x%08X 发布一帧二进制数据(m_sSrcAux=通道名) + bool postCanFrame(uint32_t canId, const uint8_t data[8], const std::string& channel); + + // 配置 + std::string m_canChannel = "CAN1"; // 指令帧下行 / 帧上报来源通道 + std::string m_redChannel; // 冗余 CAN 通道(空=不发送冗余帧) + int m_cmdPeriodMs = 500; // 指令帧周期 + double m_resetHoldSec = 5.0; // 复位位保持时长(秒) + int m_webPort = 18081; + bool m_webEnable = true; + std::string m_logPath = "pMotor.log"; + + // 指令状态(processCmdQueue 修改,sendCmdFrame 读取;m_mutex 保护) + mutable std::mutex m_mutex; + bool m_enable = false; + int16_t m_speed = 0; + double m_resetUntil = 0; // 复位位有效的截止时间(MOOSTime) + + // 指令队列(Web 线程入队,Iterate 出队) + std::vector m_cmdQueue; + // 最近一次网页指令(供网页展示) + MotorCmd m_lastCmd; + bool m_lastCmdValid = false; + double m_lastCmdTime = 0; + + // 下行统计 + unsigned long m_txCount = 0; + unsigned long m_txErr = 0; + double m_lastTx = 0; + + // CAN 帧接收日志节流 + double m_lastRxLog = 0; + + // 电机状态(OnNewMail/Iterate 均在 MOOS 线程访问;buildSnapshot 经 m_mutex 读取) + MotorState m_state; + + // 组件 + WebServer* m_web = nullptr; +}; + +} // namespace motor + +#endif // PMOTOR_MOTOR_H diff --git a/src/pMotor/Motor_Info.cpp b/src/pMotor/Motor_Info.cpp new file mode 100644 index 0000000..702b35b --- /dev/null +++ b/src/pMotor/Motor_Info.cpp @@ -0,0 +1,103 @@ +/****************************************************************/ +/* NAME: Motor_Info */ +/* FILE: Motor_Info.cpp */ +/****************************************************************/ + +#include +#include +#include "Motor_Info.h" +#include "ColorParse.h" +#include "ReleaseInfo.h" + +using namespace std; + +void showSynopsis() { + blk("SYNOPSIS: "); + blk("------------------------------------ "); + blk(" The pMotor application is the propulsion motor operation "); + blk(" program. It talks to the motor controller over CAN (via "); + blk(" pCanBridge pass-through, default channel CAN1): "); + blk(" subscribes motor state/fault frames (CAN_0x*), sends the "); + blk(" 500ms periodic command frame 0x18EF2010 (enable/reset/speed) "); + blk(" via MOOS CAN_TX_0x*, and provides a web page for control "); + blk(" and monitoring. Protocol: "); + blk(" docs/推进电机通信协议20260321.docx. "); + blk(" "); +} + +void showHelpAndExit() { + blk(" "); + blu("=============================================================== "); + blu("Usage: pMotor file.moos [OPTIONS] "); + blu("=============================================================== "); + blk(" "); + showSynopsis(); + blk(" "); + blk("Options: "); + mag(" --alias","= "); + blk(" Launch pMotor with the given process name "); + blk(" rather than pMotor. "); + mag(" --example, -e "); + blk(" Display example MOOS configuration block. "); + mag(" --help, -h "); + blk(" Display this help message. "); + mag(" --interface, -i "); + blk(" Display MOOS publications and subscriptions. "); + mag(" --version,-v "); + blk(" Display the release version of pMotor. "); + blk(" "); + blk("Note: If argv[2] does not otherwise match a known option, "); + blk(" then it will be interpreted as a run alias. This is "); + blk(" to support pAntler launching conventions. "); + blk(" "); + exit(0); +} + +void showExampleConfigAndExit() { + blk(" "); + blu("=============================================================== "); + blu("pMotor Example MOOS Configuration "); + blu("=============================================================== "); + blk(" "); + blk("ProcessConfig = pMotor "); + blk("{ "); + blk(" AppTick = 4 "); + blk(" CommsTick = 4 "); + blk(" "); + blk(" can_channel = CAN1 // 电机 CAN 通道(pCanBridge 配置名) "); + blk(" red_channel = // 冗余 CAN 通道(空=不发冗余帧) "); + blk(" cmd_period_ms = 500 // 指令帧周期(协议 500ms) "); + blk(" reset_hold_sec= 5 // 复位位保持时长(秒) "); + blk(" web_port = 18081 // 网页端口(避开8080/8090/18080) "); + blk(" web_enable = true // 是否启用网页 "); + blk(" logpath = pMotor.log // 日志路径 "); + blk("} "); + blk(" "); + exit(0); +} + +void showInterfaceAndExit() { + blk(" "); + blu("=============================================================== "); + blu("pMotor INTERFACE "); + blu("=============================================================== "); + blk(" "); + showSynopsis(); + blk(" "); + blk("SUBSCRIPTIONS: "); + blk("------------------------------------ "); + blk(" CAN_0x* = pCanBridge 透传的 CAN 帧(电机状态/故障) "); + blk(" "); + blk("PUBLICATIONS: "); + blk("------------------------------------ "); + blk(" CAN_TX_0x18EF2010 = 主控指令帧(二进制 8 字节数据域, "); + blk(" m_sSrcAux=CAN1,经 pCanBridge 下行) "); + blk(" CAN_TX_0x18EF2011 = 指令帧冗余(配置 red_channel 时发送) "); + blk(" "); + exit(0); +} + +void showReleaseInfoAndExit() { + showReleaseInfo("pMotor", "gpl"); + exit(0); +} diff --git a/src/pMotor/Motor_Info.h b/src/pMotor/Motor_Info.h new file mode 100644 index 0000000..6060618 --- /dev/null +++ b/src/pMotor/Motor_Info.h @@ -0,0 +1,15 @@ +/****************************************************************/ +/* NAME: Motor_Info */ +/* FILE: Motor_Info.h */ +/****************************************************************/ + +#ifndef PMOTOR_INFO_HEADER +#define PMOTOR_INFO_HEADER + +void showSynopsis(); +void showHelpAndExit(); +void showExampleConfigAndExit(); +void showInterfaceAndExit(); +void showReleaseInfoAndExit(); + +#endif diff --git a/src/pMotor/main.cpp b/src/pMotor/main.cpp new file mode 100644 index 0000000..71065fb --- /dev/null +++ b/src/pMotor/main.cpp @@ -0,0 +1,54 @@ +/************************************************************/ +/* NAME: pMotor */ +/* FILE: main.cpp */ +/************************************************************/ + +#include +#include "MBUtils.h" +#include "ColorParse.h" +#include "Motor.h" +#include "Motor_Info.h" +#include "../pPowerManger/logc/loguru.hpp" + +using namespace std; + +int main(int argc, char *argv[]) +{ + loguru::init(argc, argv); + + string mission_file; + string run_command = argv[0]; + // 默认以程序文件名(去掉路径)作为进程名,保证与 .moos 中 ProcessConfig 匹配 + { + size_t slash = run_command.find_last_of('/'); + if (slash != string::npos) run_command = run_command.substr(slash + 1); + } + + for (int i = 1; i < argc; i++) { + string argi = argv[i]; + if ((argi == "-v") || (argi == "--version") || (argi == "-version")) + showReleaseInfoAndExit(); + else if ((argi == "-e") || (argi == "--example") || (argi == "-example")) + showExampleConfigAndExit(); + else if ((argi == "-h") || (argi == "--help") || (argi == "-help")) + showHelpAndExit(); + else if ((argi == "-i") || (argi == "--interface")) + showInterfaceAndExit(); + else if (strEnds(argi, ".moos") || strEnds(argi, ".moos++")) + mission_file = argv[i]; + else if (strBegins(argi, "--alias=")) + run_command = argi.substr(8); + else if (i == 2) + run_command = argi; + } + + if (mission_file == "") + showHelpAndExit(); + + LOG_F(INFO, "pMotor launching as %s", run_command.c_str()); + + motor::Motor Motor; + Motor.Run(run_command.c_str(), mission_file.c_str()); + + return 0; +} diff --git a/src/pMotor/pMotor.moos b/src/pMotor/pMotor.moos new file mode 100644 index 0000000..6865e35 --- /dev/null +++ b/src/pMotor/pMotor.moos @@ -0,0 +1,37 @@ +// MOOS file +// pMotor 推进电机操作程序 配置示例 +// +// 功能:经 pCanBridge(CAN1 通道)与推进电机控制器交互 +// 下行:网页 /api/motor_cmd(启停/复位/转速)-> 500ms 周期指令帧 0x18EF2010 +// 上行:订阅 pCanBridge 透传的 CAN_0x*(故障/转速/母线电压/温度)-> 网页展示 +// +// 协议定义:docs/推进电机通信协议20260321.docx + +ServerHost = localhost +ServerPort = 9000 +Community = h100 + +ProcessConfig = pMotor +{ + AppTick = 4 + CommsTick = 4 + + // 电机 CAN 通道(须与 pCanBridge 的 channel 配置名一致) + can_channel = CAN1 + + // 冗余 CAN 通道(配置后指令帧同内容发 0x18EF2011 到该通道;空=不发送) + // red_channel = CAN2 + + // 指令帧周期 ms(协议 500ms) + cmd_period_ms = 500 + + // 复位位保持时长(秒):下发复位后 reset bit 保持高电平数秒, + // 仅故障状态下电机执行复位 + reset_hold_sec = 5 + + // 网页(避开 pCCU 8080 / pPowerManger 8090 / pPowerMangerHost 18080) + web_port = 18081 + web_enable = true + + logpath = pMotor.log +} diff --git a/src/pMotor/protocol/MotorCan.cpp b/src/pMotor/protocol/MotorCan.cpp new file mode 100644 index 0000000..213daca --- /dev/null +++ b/src/pMotor/protocol/MotorCan.cpp @@ -0,0 +1,219 @@ +#include "MotorCan.h" + +#include + +namespace motor { + +const char* kMotorWordName[4] = { + "故障字1 (Byte0|Byte1)", + "故障字2 (Byte2|Byte3)", + "故障字3 (Byte4|Byte5)", + "报警字 (Byte6|Byte7)", +}; + +namespace { + +// 故障/报警位描述(bit -> 中文描述),按字定义 +struct FaultDesc { + uint16_t bit; + const char* text; +}; + +//-------------------------------------------------------------------------- +// 故障字1(Byte0|Byte1):支路一 / 支路二 仅 bit7 不同 +//-------------------------------------------------------------------------- +const FaultDesc kW0Branch1[] = { + {0, "CPLD自检故障标志"}, + {1, "温度继电器过温故障"}, + {2, "A相绕组过温故障"}, + {3, "B相绕组过温故障"}, + {4, "C相绕组过温故障"}, + {5, "逆变组件底板过温故障"}, + {6, "输出滤波器过温故障"}, + {7, "筒内空气过温故障"}, + {8, "超速故障"}, + {9, "紧急停机故障"}, + {11, "协调通信丢失故障"}, + {12, "协调故障停机"}, +}; +const FaultDesc kW0Branch2[] = { + {0, "CPLD自检故障标志"}, + {1, "温度继电器过温故障"}, + {2, "A相绕组过温故障"}, + {3, "B相绕组过温故障"}, + {4, "C相绕组过温故障"}, + {5, "逆变组件底板过温故障"}, + {6, "输出滤波器过温故障"}, + // bit7 支路二为预留 + {8, "超速故障"}, + {9, "紧急停机故障"}, + {11, "协调通信丢失故障"}, + {12, "协调故障停机"}, +}; + +//-------------------------------------------------------------------------- +// 故障字2(Byte2|Byte3) +//-------------------------------------------------------------------------- +const FaultDesc kW1[] = { + {0, "逆变A1相驱动1(上管)故障"}, + {1, "逆变A1相驱动2(下管)故障"}, + {4, "逆变B1相驱动3(上管)故障"}, + {5, "逆变B1相驱动4(下管)故障"}, + {8, "逆变C1相驱动5(上管)故障"}, + {9, "逆变C1相驱动6(下管)故障"}, + {12, "硬件总故障"}, + {13, "驱动总故障"}, + {14, "采样总故障"}, + {15, "24V欠压故障"}, +}; + +//-------------------------------------------------------------------------- +// 故障字3(Byte4|Byte5) +//-------------------------------------------------------------------------- +const FaultDesc kW2[] = { + {0, "A相软件过流故障"}, + {1, "B相软件过流故障"}, + {2, "C相软件过流故障"}, + {3, "母线电压过压故障"}, + {4, "母线电压欠压故障"}, + {5, "输出缺相故障"}, + {6, "预充电故障"}, + {8, "A1相硬件过流故障"}, + {9, "C1相硬件过流故障"}, + {15, "母线过压硬件故障"}, +}; + +//-------------------------------------------------------------------------- +// 报警字(Byte6|Byte7) +//-------------------------------------------------------------------------- +const FaultDesc kW3[] = { + {0, "直流母线1电压偏高报警"}, + {1, "直流母线1电压偏低报警"}, + {2, "A相绕组温度偏高报警"}, + {3, "B相绕组温度偏高报警"}, + {4, "C相绕组温度偏高报警"}, + {5, "逆变组件底板温度偏高报警"}, + {6, "输出滤波器温度偏高报警"}, + {7, "筒内空气温度偏高报警"}, + {8, "遥控CAN通信中断报警"}, + {9, "485通信中断报警"}, + {10, "协调CAN通信中断报警"}, + {11, "参数设置失败报警"}, + {12, "flash读取失败"}, + {13, "直流母线2电压偏高报警"}, + {14, "直流母线2电压偏低报警"}, + {15, "协调光纤报警"}, +}; + +const FaultDesc* tableOf(int branch, int wordIdx, size_t& count) { + switch (wordIdx) { + case 0: + if (branch == 2) { count = sizeof(kW0Branch2) / sizeof(kW0Branch2[0]); return kW0Branch2; } + count = sizeof(kW0Branch1) / sizeof(kW0Branch1[0]); return kW0Branch1; + case 1: + count = sizeof(kW1) / sizeof(kW1[0]); return kW1; + case 2: + count = sizeof(kW2) / sizeof(kW2[0]); return kW2; + case 3: + count = sizeof(kW3) / sizeof(kW3[0]); return kW3; + default: + count = 0; return nullptr; + } +} + +// 16 位字(Intel:低字节在前) +uint16_t wordOf(const uint8_t* d, int byteIdx) { + return static_cast(d[byteIdx] | (static_cast(d[byteIdx + 1]) << 8)); +} + +// 重建某支路的故障/报警描述列表(word 更新后调用) +void rebuildBranchTexts(MotorBranchState& br) { + br.faults.clear(); + br.alarms.clear(); + for (int w = 0; w < 3; ++w) { + std::vector tmp; + motorFaultTexts(br.branch, w, br.word[w], tmp); + br.faults.insert(br.faults.end(), tmp.begin(), tmp.end()); + } + motorFaultTexts(br.branch, 3, br.word[3], br.alarms); +} + +} // namespace + +//-------------------------------------------------------------------------- +// motorFaultTexts + +int motorFaultTexts(int branch, int wordIdx, uint16_t bits, + std::vector& out) { + out.clear(); + if (branch < 1 || branch > 2) return 0; + size_t count = 0; + const FaultDesc* table = tableOf(branch, wordIdx, count); + if (!table) return 0; + for (size_t i = 0; i < count; ++i) { + if (bits & (1u << table[i].bit)) + out.push_back(table[i].text); + } + return static_cast(out.size()); +} + +//-------------------------------------------------------------------------- +// buildMotorCmdData:指令帧数据域编码 +// byte0 = enable(bit0) | reset(bit1) +// byte2/3 = speed s16(Intel 低字节在前) + +void buildMotorCmdData(bool enable, bool reset, int16_t speed, uint8_t out[8]) { + std::memset(out, 0, 8); + out[0] = static_cast((enable ? 0x01u : 0u) | (reset ? 0x02u : 0u)); + uint16_t sp = static_cast(speed); + out[2] = static_cast(sp & 0xFFu); + out[3] = static_cast((sp >> 8) & 0xFFu); +} + +//-------------------------------------------------------------------------- +// decodeMotorFrame:电机上行报文解析(部分更新合并进 st) + +bool decodeMotorFrame(uint32_t canId, const uint8_t* data, int dlc, MotorState& st) { + if (!data || dlc < 8) return false; + + switch (canId) { + case MOTOR_CAN_FAULT1: + case MOTOR_CAN_FAULT1_RED: + case MOTOR_CAN_FAULT2: + case MOTOR_CAN_FAULT2_RED: { + bool isBranch2 = (canId == MOTOR_CAN_FAULT2 || canId == MOTOR_CAN_FAULT2_RED); + MotorBranchState& br = isBranch2 ? st.branch2 : st.branch1; + br.branch = isBranch2 ? 2 : 1; + br.word[0] = wordOf(data, 0); + br.word[1] = wordOf(data, 2); + br.word[2] = wordOf(data, 4); + br.word[3] = wordOf(data, 6); + rebuildBranchTexts(br); + br.valid = true; + return true; + } + case MOTOR_CAN_STATE3: + case MOTOR_CAN_STATE3_RED: { + uint16_t sp = wordOf(data, 0); + st.feedbackSpeed = static_cast(sp); + st.busVoltage = wordOf(data, 2); + st.state3Valid = true; + return true; + } + case MOTOR_CAN_STATE4: + case MOTOR_CAN_STATE4_RED: { + st.tempA = static_cast(data[0]); + st.tempB = static_cast(data[1]); + st.tempC = static_cast(data[2]); + st.tempInv = static_cast(data[3]); + st.tempFilter = static_cast(data[4]); + st.tempAir = static_cast(data[5]); + st.state4Valid = true; + return true; + } + default: + return false; + } +} + +} // namespace motor diff --git a/src/pMotor/protocol/MotorCan.h b/src/pMotor/protocol/MotorCan.h new file mode 100644 index 0000000..a56cd34 --- /dev/null +++ b/src/pMotor/protocol/MotorCan.h @@ -0,0 +1,154 @@ +#ifndef PMOTOR_CAN_H +#define PMOTOR_CAN_H + +#include +#include +#include + +namespace motor { + +//============================================================================ +// 推进电机控制器 CAN 通信协议(docs/推进电机通信协议20260321.docx) +// +// 数据来源:pCanBridge 透传的 MOOS 消息 +// 变量名 CAN_0x%08X = CAN ID(29 位扩展帧),m_sVal = 二进制数据域(8 字节), +// m_dfVal2 = 原始帧信息字节。本模块只负责按报文 ID 解析/编码数据域字段。 +// +// 物理/链路层:CAN2.0B 扩展帧,250kbps(默认),数据域 8 字节, +// 多字节数值采用 Intel 编码(低字节在前,高字节在后)。 +// +// 【报文清单】 +// 下行(pMotor -> 电机控制器,周期 500ms): +// 0x18EF2010 主控指令帧(冗余 CAN 0x18EF2011) +// 0x18EF2110 主控限制帧(冗余 CAN 0x18EF2111,全预留,暂不发送) +// 上行(电机控制器 -> pMotor): +// 0x18EF1020 支路一故障报警状态帧1(200ms,冗余 CAN 0x18EF1021) +// 0x18EF1120 支路二故障报警状态帧2(200ms,冗余 CAN 0x18EF1121) +// 0x18EF1220 状态帧3:反馈转速/母线电压(100ms,冗余 0x18EF1221) +// 0x18EF1320 状态帧4:温度(100ms,冗余 0x18EF1321) +// +// 【指令帧 0x18EF2010】数据域 8 字节: +// byte0 bit0 = 使能命令(0 无效 1 有效,IGBT 开关;需先预充后使能) +// byte0 bit1 = 复位命令(0 无效 1 有效,仅故障状态下有效,故障支路复位) +// byte0 bit2 = 支路一通信状态(0 正常 1 中断) +// byte0 bit3 = 支路二通信状态(0 正常 1 中断) +// byte0 bit4~7 / byte1 / byte4~7 = 预留(发 0) +// byte2/3 = (转速模式)期望转速 s16(偏移 0,分辨率 1,Intel) +// 文档示例:启动并给定 1000 转速 -> 01 00 E8 03 00 00 00 00 +// 停机 -> 00 00 00 00 00 00 00 00 +// 复位 -> 02 00 00 00 00 00 00 00 +// +// 【状态帧3 0x18EF1220】byte0/1 反馈转速 s16(Intel),byte2/3 母线电压 u16(Intel) +// +// 【状态帧4 0x18EF1320】byte0~5 依次为 A/B/C 相绕组、逆变组件底板、 +// 输出滤波器、筒内空气温度,均为 s8(偏移 0,分辨率 1) +// +// 【故障帧 0x18EF1020/0x18EF1120】4 个 16 位字(byte0|1、byte2|3、byte4|5、byte6|7), +// 字内 bit 定义见 MotorCan.cpp 中描述表;前两帧仅支路一/支路二 +// byte0|1 的 bit7 不同(支路一=筒内空气过温故障,支路二=预留)。 +//============================================================================ + +// CAN ID 定义(29 位扩展帧) +enum MotorCanId : uint32_t { + MOTOR_CAN_CMD = 0x18EF2010u, // 主控指令帧(pMotor -> 电机) + MOTOR_CAN_CMD_RED = 0x18EF2011u, // 主控指令帧 冗余 CAN + MOTOR_CAN_LIMIT = 0x18EF2110u, // 主控限制帧(全预留,暂不使用) + MOTOR_CAN_LIMIT_RED = 0x18EF2111u, // 主控限制帧 冗余 CAN + MOTOR_CAN_FAULT1 = 0x18EF1020u, // 支路一故障报警状态帧1 + MOTOR_CAN_FAULT1_RED = 0x18EF1021u, // 支路一 冗余 CAN + MOTOR_CAN_FAULT2 = 0x18EF1120u, // 支路二故障报警状态帧2 + MOTOR_CAN_FAULT2_RED = 0x18EF1121u, // 支路二 冗余 CAN + MOTOR_CAN_STATE3 = 0x18EF1220u, // 状态帧3(转速/母线电压) + MOTOR_CAN_STATE3_RED = 0x18EF1221u, // 状态帧3 冗余 CAN + MOTOR_CAN_STATE4 = 0x18EF1320u, // 状态帧4(温度) + MOTOR_CAN_STATE4_RED = 0x18EF1321u, // 状态帧4 冗余 CAN +}; + +// 指令动作(网页 /api/motor_cmd -> 队列 -> Iterate 应用) +// 与协议指令帧字段一一对应,独立下发、无联动: +enum MotorAction { + MOTOR_ACT_ENABLE = 1, // 使能命令=1(Byte0 bit0,保持当前期望转速) + MOTOR_ACT_DISABLE = 2, // 使能命令=0(Byte0 bit0,协议"停机"示例:期望转速清 0) + MOTOR_ACT_SPEED = 3, // 期望转速(Byte2/3,speed 参数,使能状态保持不变) + MOTOR_ACT_RESET = 4, // 复位命令=1(Byte0 bit1,保持高 resetHold 秒,仅故障态有效) +}; + +// 网页下发的控制指令 +struct MotorCmd { + int action = 0; // MotorAction + int16_t speed = 0; // MOTOR_ACT_SPEED / MOTOR_ACT_START 有效 +}; + +// 单条电机上行消息的新鲜度 + 原始数据 +struct MotorBranchState { + int branch = 0; // 1 / 2 + uint16_t word[4] = {0, 0, 0, 0}; // 4 个 16 位字(故障1/故障2/故障3/报警) + std::vector faults; // 当前置位的故障描述(word0~2) + std::vector alarms; // 当前置位的报警描述(word3) + bool valid = false; // 是否收到过该支路故障帧 + double lastRxTime = 0; // 最近收到时刻(MOOSTime,调用方设置) +}; + +// 电机最新合成状态(各帧部分更新合并) +struct MotorState { + // 状态帧3 0x18EF1220 + int16_t feedbackSpeed = 0; // 反馈转速 + uint16_t busVoltage = 0; // 母线电压 + bool state3Valid = false; + double state3LastRx = 0; + // 状态帧4 0x18EF1320(s8,偏移 0) + int8_t tempA = 0, tempB = 0, tempC = 0; // A/B/C 相绕组温度 + int8_t tempInv = 0, tempFilter = 0, tempAir = 0; // 逆变底板/滤波器/筒内空气 + bool state4Valid = false; + double state4LastRx = 0; + // 故障/报警 + MotorBranchState branch1; + MotorBranchState branch2; + // 计数 + uint32_t rxCount = 0; // 命中电机报文的帧计数 +}; + +// 是否为电机协议定义的 CAN ID(含冗余 CAN 变体) +inline bool isCanMotorId(uint32_t canId) { + switch (canId) { + case MOTOR_CAN_CMD: + case MOTOR_CAN_CMD_RED: + case MOTOR_CAN_LIMIT: + case MOTOR_CAN_LIMIT_RED: + case MOTOR_CAN_FAULT1: + case MOTOR_CAN_FAULT1_RED: + case MOTOR_CAN_FAULT2: + case MOTOR_CAN_FAULT2_RED: + case MOTOR_CAN_STATE3: + case MOTOR_CAN_STATE3_RED: + case MOTOR_CAN_STATE4: + case MOTOR_CAN_STATE4_RED: + return true; + default: + return false; + } +} + +// 故障/报警字索引(帧内):0=byte0|1 1=byte2|3 2=byte4|5 3=byte6|7(报警) +extern const char* kMotorWordName[4]; + +// 按支路(1/2)与字索引查故障/报警描述表,把 bits 中置位的描述填入 out; +// 返回描述条数。wordIdx 非法或无置位返回 0。 +int motorFaultTexts(int branch, int wordIdx, uint16_t bits, + std::vector& out); + +// 编码指令帧数据域(8 字节,经 MOOS CAN_TX_0x%08X 下发 pCanBridge 发送): +// [0] = enable(bit0) | reset(bit1)(支路通信状态位恒 0) +// [1] = 0 [2]/[3] = speed s16 Intel(停用时调用方传 0) [4..7] = 0 +void buildMotorCmdData(bool enable, bool reset, int16_t speed, uint8_t out[8]); + +// 解析一帧 CAN 数据域;命中电机上行报文返回 true 并部分更新 st: +// 故障帧 -> 对应支路 word[4] + 故障/报警描述 +// 状态帧3 -> feedbackSpeed / busVoltage +// 状态帧4 -> 6 路温度 +// 非电机报文或 data/dlc 非法返回 false。lastRxTime 由调用方设置。 +bool decodeMotorFrame(uint32_t canId, const uint8_t* data, int dlc, MotorState& st); + +} // namespace motor + +#endif // PMOTOR_CAN_H diff --git a/src/pMotor/web/WebServer.cpp b/src/pMotor/web/WebServer.cpp new file mode 100644 index 0000000..afaf5e4 --- /dev/null +++ b/src/pMotor/web/WebServer.cpp @@ -0,0 +1,144 @@ +#include "WebServer.h" +#include "pages/index.h" +#include + +namespace motor { + +//--------------------------------------------------------- +// 事件处理:HTTP 路由 + WebSocket 收发 + +void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) { + if (ev == MG_EV_HTTP_MSG) { + struct mg_http_message* hm = (struct mg_http_message*)ev_data; + + // WebSocket 升级 + if (mg_match(hm->uri, mg_str("/ws"), NULL)) { + mg_ws_upgrade(c, hm, NULL); + return; + } + + // /api/* 接口:交给宿主程序处理,返回 JSON + std::string path(hm->uri.buf, hm->uri.len); + if (path.rfind("/api/", 0) == 0) { + std::string body; + if (m_apiHandler) { + std::string query(hm->query.buf, hm->query.len); + body = m_apiHandler(path, query); + } + if (!body.empty()) { + mg_http_reply(c, 200, "Content-Type: application/json\r\n", + "%.*s", (int)body.size(), body.c_str()); + } else { + mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n"); + } + return; + } + + // 电机控制页面 + if (path == "/" || path == "/index.html") { + mg_http_reply(c, 200, "Content-Type: text/html; charset=utf-8\r\n", + "%.*s", (int)INDEX_HTML.size(), INDEX_HTML.c_str()); + return; + } + + mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n"); + } else if (ev == MG_EV_WS_OPEN) { + // Web 线程内:先构建欢迎快照,再登记连接 + std::string welcome; + if (m_onOpen) welcome = m_onOpen(); + m_wsConnections.push_back(c); + if (!welcome.empty()) { + mg_ws_send(c, welcome.c_str(), welcome.size(), WEBSOCKET_OP_TEXT); + } + } else if (ev == MG_EV_CLOSE || ev == MG_EV_ERROR) { + for (auto it = m_wsConnections.begin(); it != m_wsConnections.end(); ++it) { + if (*it == c) { + m_wsConnections.erase(it); + break; + } + } + } else if (ev == MG_EV_WS_MSG) { + // 忽略客户端消息(控制走 /api/*) + } +} + +WebServer::WebServer() { + mg_mgr_init(&m_mgr); +} + +WebServer::~WebServer() { + stop(); + mg_mgr_free(&m_mgr); +} + +void WebServer::setOnOpen(std::function handler) { + m_onOpen = std::move(handler); +} + +void WebServer::setApiHandler(std::function handler) { + m_apiHandler = std::move(handler); +} + +bool WebServer::start(int port) { + if (m_running) return true; + m_port = port; + + std::string addr = "http://0.0.0.0:" + std::to_string(port); + mg_http_listen(&m_mgr, addr.c_str(), [](mg_connection* c, int ev, void* ev_data) { + WebServer* server = static_cast(c->fn_data); + if (server) server->handleEvent(c, ev, ev_data); + }, this); + + m_running = true; + m_thread = std::thread([this]() { serverThreadFunc(); }); + + std::cout << "pMotor web UI listening on " << addr << std::endl; + return true; +} + +void WebServer::stop() { + if (!m_running) return; + m_running = false; + if (m_thread.joinable()) m_thread.join(); + + // Web 线程已退出,此处触碰连接列表安全 + for (auto c : m_wsConnections) { + mg_ws_send(c, "", 0, WEBSOCKET_OP_CLOSE); + } + m_wsConnections.clear(); +} + +bool WebServer::serverThreadCB(void* pParam) { + WebServer* pThis = static_cast(pParam); + return pThis->serverThreadFunc(); +} + +bool WebServer::serverThreadFunc() { + while (m_running) { + mg_mgr_poll(&m_mgr, 200); + // 仅在本线程(poll 间隙)发送,避免跨线程操作 mongoose 连接 + flushPending(); + } + return true; +} + +void WebServer::flushPending() { + std::string text; + { + std::lock_guard lock(m_pendingMutex); + text = std::move(m_pending); + m_pending.clear(); + } + if (text.empty()) return; + for (auto c : m_wsConnections) { + mg_ws_send(c, text.c_str(), text.size(), WEBSOCKET_OP_TEXT); + } +} + +void WebServer::broadcast(const std::string& text) { + // 只投递到队列,实际发送在 Web 线程 flushPending() 中完成 + std::lock_guard lock(m_pendingMutex); + m_pending = text; +} + +} // namespace motor diff --git a/src/pMotor/web/WebServer.h b/src/pMotor/web/WebServer.h new file mode 100644 index 0000000..928a188 --- /dev/null +++ b/src/pMotor/web/WebServer.h @@ -0,0 +1,66 @@ +#ifndef PMOTOR_WEB_SERVER_H +#define PMOTOR_WEB_SERVER_H + +#define UNIX +#include "mongoose.h" +#include +#include +#include +#include +#include +#include + +namespace motor { + +//============================================================================ +// WebServer:HTTP + WebSocket 控制/监控服务器(基于 mongoose)。 +// +// 路由: +// / -> 内嵌电机控制页面 +// /ws -> WebSocket,推送 JSON 快照(由 Motor 周期调用 broadcast) +// /api/* -> 控制指令接口(由 Motor 处理,返回 JSON) +// 采用与仓库内 pCCU/WebServer、pPowerMangerHost/WebServer 一致的回调模式。 +//============================================================================ + +class WebServer { +public: + WebServer(); + ~WebServer(); + + bool start(int port); + void stop(); + bool isRunning() const { return m_running; } + + // 向所有 WebSocket 客户端广播文本 + // 线程安全:mongoose 非线程安全,本接口只把文本放入待发队列, + // 由 Web 线程在 mg_mgr_poll 返回后统一发送(禁止跨线程直接 mg_ws_send) + void broadcast(const std::string& text); + + // 新客户端连接时调用,返回初始快照 + void setOnOpen(std::function handler); + + // 处理 /api/* 请求,返回 JSON 响应体(空串表示 404) + void setApiHandler(std::function handler); + +private: + static bool serverThreadCB(void* pParam); + bool serverThreadFunc(); + void handleEvent(struct mg_connection* c, int ev, void* ev_data); + // Web 线程内:把待发队列中的快照推给所有 WS 客户端(仅本线程触碰连接列表) + void flushPending(); + + struct mg_mgr m_mgr; + int m_port = 0; + std::atomic m_running{false}; + std::thread m_thread; + + std::mutex m_pendingMutex; // 保护 m_pending + std::string m_pending; // 最新待广播快照(新快照覆盖旧快照) + std::vector m_wsConnections; // 仅 Web 线程访问 + std::function m_onOpen; + std::function m_apiHandler; +}; + +} // namespace motor + +#endif // PMOTOR_WEB_SERVER_H diff --git a/src/pMotor/web/pages/index.h b/src/pMotor/web/pages/index.h new file mode 100644 index 0000000..0eb42fc --- /dev/null +++ b/src/pMotor/web/pages/index.h @@ -0,0 +1,271 @@ +#ifndef PMOTOR_PAGE_INDEX_H +#define PMOTOR_PAGE_INDEX_H + +#include + +//============================================================================ +// pMotor 推进电机控制页面(纯前端,内嵌 HTML)。 +// +// - 通过 WebSocket /ws 接收服务器推送的 JSON 快照(每秒) +// - 控制:按协议指令帧字段独立下发(无联动按钮) +// 使能命令(Byte0 bit0) / 复位命令(Byte0 bit1) / 期望转速(Byte2/3) +// (fetch /api/motor_cmd -> pMotor 入队 -> MOOS CAN_TX -> pCanBridge -> CAN1) +// - 展示:反馈转速/母线电压、温度、支路一/支路二故障与报警(按位解析中文) +// - 协议定义:docs/推进电机通信协议20260321.docx +//============================================================================ + +namespace motor { +const std::string INDEX_HTML = R"HTML( + + + + +pMotor 推进电机控制 + + + +

pMotor 推进电机控制 连接中...

+
加载中...
+ + + + +)HTML"; +} // namespace motor + +#endif // PMOTOR_PAGE_INDEX_H diff --git a/src/pPowerManger/PowerManger.h b/src/pPowerManger/PowerManger.h index c8087b5..72bb224 100644 --- a/src/pPowerManger/PowerManger.h +++ b/src/pPowerManger/PowerManger.h @@ -168,7 +168,7 @@ class PowerManger : public AppCastingMOOSApp double GetPublicLastIterateTime() { return GetLastIterateTime(); } int GetPublicIterateCount() { return GetIterateCount(); } string GetServerHost() { return m_sServerHost; } - int GetServerPort() { return m_lServerPort; } + int GetServerPort() { return m_lServerPort; } public: //故障注入 void injectFault(unsigned type,unsigned int faultCode); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f318581..35fb5d1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -161,3 +161,23 @@ target_link_libraries(pccuTest pthread dl ) + +################################################pmotorTest(pMotor 单元测试)#### +# pMotor 推进电机 CAN 协议编解码单元测试(独立可执行,无 gtest 依赖) +set(MOTOR_DIR ${CMAKE_SOURCE_DIR}/src/pMotor) + +add_executable(pmotorTest + pmotor/pmotorTest.cpp + ${MOTOR_DIR}/protocol/MotorCan.cpp +) +target_include_directories(pmotorTest PRIVATE + ${MOTOR_DIR} +) +set_target_properties(pmotorTest PROPERTIES + CXX_STANDARD 11 + CXX_STANDARD_REQUIRED ON +) +target_link_libraries(pmotorTest + m + pthread +) diff --git a/test/pmotor/pmotorTest.cpp b/test/pmotor/pmotorTest.cpp new file mode 100644 index 0000000..75d3005 --- /dev/null +++ b/test/pmotor/pmotorTest.cpp @@ -0,0 +1,255 @@ +//============================================================================ +// pmotorTest:pMotor 推进电机 CAN 协议编解码单元测试 +// 无第三方测试库,独立可执行。参照 test/pccu/pccuTest.cpp 的轻量风格。 +// +// 协议依据:docs/推进电机通信协议20260321.docx +//============================================================================ + +#include +#include +#include +#include +#include + +#include "../../src/pMotor/protocol/MotorCan.h" + +using namespace motor; + +static int g_fail = 0; +static int g_pass = 0; + +#define CHECK(cond) do { \ + if (cond) { ++g_pass; } \ + else { ++g_fail; std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } \ +} while (0) + +#define CHECK_STREQ(a, b) do { \ + if (std::string(a) == std::string(b)) { ++g_pass; } \ + else { ++g_fail; std::printf("FAIL %s:%d \"%s\" != \"%s\"\n", __FILE__, __LINE__, std::string(a).c_str(), std::string(b).c_str()); } \ +} while (0) + +//-------------------------------------------------------------------------- +// 1. 指令帧编码(对照文档示例) + +static void testCmdEncode() { + uint8_t d[8]; + + // 文档示例:启动并给定 1000 转速 -> 01 00 E8 03 00 00 00 00 + buildMotorCmdData(true, false, 1000, d); + uint8_t expect1[] = {0x01, 0x00, 0xE8, 0x03, 0x00, 0x00, 0x00, 0x00}; + CHECK(std::memcmp(d, expect1, 8) == 0); + + // 文档示例:停机 -> 00 00 00 00 00 00 00 00 + buildMotorCmdData(false, false, 0, d); + uint8_t expect0[] = {0, 0, 0, 0, 0, 0, 0, 0}; + CHECK(std::memcmp(d, expect0, 8) == 0); + + // 文档示例:复位 -> 02 00 00 00 00 00 00 00 + buildMotorCmdData(false, true, 0, d); + uint8_t expect2[] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + CHECK(std::memcmp(d, expect2, 8) == 0); + + // 使能+复位 同时有效 -> bit0+bit1 = 0x03 + buildMotorCmdData(true, true, 0, d); + CHECK(d[0] == 0x03); + + // 负转速 s16 Intel:-100 = 0xFF9C -> byte2=0x9C byte3=0xFF + buildMotorCmdData(true, false, -100, d); + CHECK(d[0] == 0x01); + CHECK(d[2] == 0x9C && d[3] == 0xFF); + + // 边界转速:32767 / -32768 + buildMotorCmdData(true, false, 32767, d); + CHECK(d[2] == 0xFF && d[3] == 0x7F); + buildMotorCmdData(true, false, -32768, d); + CHECK(d[2] == 0x00 && d[3] == 0x80); + + // 支路通信状态位(bit2/bit3)恒 0(pMotor 不反馈通信状态) + buildMotorCmdData(true, false, 1, d); + CHECK((d[0] & 0x0C) == 0); +} + +//-------------------------------------------------------------------------- +// 2. CAN ID 识别 + +static void testCanId() { + // 12 条电机 ID 全部命中 + const uint32_t ids[] = { + MOTOR_CAN_CMD, MOTOR_CAN_CMD_RED, + MOTOR_CAN_LIMIT, MOTOR_CAN_LIMIT_RED, + MOTOR_CAN_FAULT1, MOTOR_CAN_FAULT1_RED, + MOTOR_CAN_FAULT2, MOTOR_CAN_FAULT2_RED, + MOTOR_CAN_STATE3, MOTOR_CAN_STATE3_RED, + MOTOR_CAN_STATE4, MOTOR_CAN_STATE4_RED, + }; + for (size_t i = 0; i < sizeof(ids) / sizeof(ids[0]); ++i) + CHECK(isCanMotorId(ids[i])); + + // 冗余 CAN 与主 CAN 仅末字节差 1 + CHECK(MOTOR_CAN_CMD_RED == MOTOR_CAN_CMD + 1); + CHECK(MOTOR_CAN_FAULT1_RED == MOTOR_CAN_FAULT1 + 1); + CHECK(MOTOR_CAN_FAULT2_RED == MOTOR_CAN_FAULT2 + 1); + CHECK(MOTOR_CAN_STATE3_RED == MOTOR_CAN_STATE3 + 1); + CHECK(MOTOR_CAN_STATE4_RED == MOTOR_CAN_STATE4 + 1); + + // 其他协议报文不命中(BCU 电池 / 配电) + CHECK(!isCanMotorId(0x10010000)); // BCU 概要 + CHECK(!isCanMotorId(0x103681FF)); // BCU 继电器控制 + CHECK(!isCanMotorId(0x1401A501)); // 配电控制指令 + CHECK(!isCanMotorId(0x18EF2012)); // 电机协议未定义的 ID +} + +//-------------------------------------------------------------------------- +// 3. 状态帧3 解码(0x18EF1220:反馈转速/母线电压) + +static void testState3Decode() { + MotorState st; + uint8_t d[8] = {0xE8, 0x03, 0xF4, 0x01, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE3, d, 8, st)); + CHECK(st.state3Valid); + CHECK(st.feedbackSpeed == 1000); // 0x03E8 Intel + CHECK(st.busVoltage == 500); // 0x01F4 Intel + + // 负转速 + uint8_t d2[8] = {0x9C, 0xFF, 0, 0, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE3, d2, 8, st)); + CHECK(st.feedbackSpeed == -100); + + // 冗余 CAN 同样可解码 + MotorState st2; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE3_RED, d, 8, st2)); + CHECK(st2.feedbackSpeed == 1000 && st2.busVoltage == 500); + + // 非法 dlc + MotorState st3; + CHECK(!decodeMotorFrame(MOTOR_CAN_STATE3, d, 4, st3)); + CHECK(!decodeMotorFrame(MOTOR_CAN_STATE3, nullptr, 8, st3)); +} + +//-------------------------------------------------------------------------- +// 4. 状态帧4 解码(0x18EF1320:温度) + +static void testState4Decode() { + MotorState st; + uint8_t d[8] = {26, 44, 62, 79, 90, 107, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE4, d, 8, st)); + CHECK(st.state4Valid); + CHECK(st.tempA == 26 && st.tempB == 44 && st.tempC == 62); + CHECK(st.tempInv == 79 && st.tempFilter == 90 && st.tempAir == 107); + + // 冗余 CAN + MotorState st2; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE4_RED, d, 8, st2)); + CHECK(st2.tempA == 26); +} + +//-------------------------------------------------------------------------- +// 5. 故障帧解码(0x18EF1020 支路一 / 0x18EF1120 支路二) + +static void testFaultDecode() { + MotorState st; + + // 支路一:word0 置 bit8(超速) + bit9(紧急停机) -> byte0|byte1<<8 = 0x0300; + // word3 置 bit8(遥控CAN通信中断报警) -> 0x0100 + uint8_t d1[8] = {0x00, 0x03, 0, 0, 0, 0, 0x00, 0x01}; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT1, d1, 8, st)); + CHECK(st.branch1.valid); + CHECK(st.branch1.word[0] == 0x0300); + CHECK(st.branch1.word[3] == 0x0100); + CHECK(st.branch1.faults.size() == 2); + CHECK_STREQ(st.branch1.faults[0], "超速故障"); + CHECK_STREQ(st.branch1.faults[1], "紧急停机故障"); + CHECK(st.branch1.alarms.size() == 1); + CHECK_STREQ(st.branch1.alarms[0], "遥控CAN通信中断报警"); + + // 支路一 word0 bit7 = 筒内空气过温故障 + uint8_t d2[8] = {0x80, 0x00, 0, 0, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT1, d2, 8, st)); + CHECK(st.branch1.faults.size() == 1); + CHECK_STREQ(st.branch1.faults[0], "筒内空气过温故障"); + + // 支路二:同 bit7 为预留,不应产生故障描述 + uint8_t d3[8] = {0x80, 0x00, 0, 0, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT2, d3, 8, st)); + CHECK(st.branch2.valid); + CHECK(st.branch2.faults.empty()); + CHECK(st.branch2.alarms.empty()); + + // 支路二 word1:bit0(逆变A1相驱动1上管) + word2 bit3(母线电压过压) + uint8_t d4[8] = {0, 0, 0x01, 0x00, 0x08, 0x00, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT2, d4, 8, st)); + CHECK(st.branch2.word[1] == 0x0001); + CHECK(st.branch2.word[2] == 0x0008); + CHECK(st.branch2.faults.size() == 2); + CHECK_STREQ(st.branch2.faults[0], "逆变A1相驱动1(上管)故障"); + CHECK_STREQ(st.branch2.faults[1], "母线电压过压故障"); + + // 冗余 CAN 同样可解码且归入对应支路 + MotorState st2; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT1_RED, d1, 8, st2)); + CHECK(st2.branch1.valid && !st2.branch2.valid); + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT2_RED, d4, 8, st2)); + CHECK(st2.branch2.valid && st2.branch2.faults.size() == 2); +} + +//-------------------------------------------------------------------------- +// 6. 故障描述查表 + +static void testFaultTable() { + std::vector out; + + // word1 bit12/13/14/15 + CHECK(motorFaultTexts(1, 1, (1u << 12) | (1u << 13) | (1u << 14) | (1u << 15), out) == 4); + CHECK_STREQ(out[0], "硬件总故障"); + CHECK_STREQ(out[1], "驱动总故障"); + CHECK_STREQ(out[2], "采样总故障"); + CHECK_STREQ(out[3], "24V欠压故障"); + + // word3 全位 16 条 + CHECK(motorFaultTexts(1, 3, 0xFFFF, out) == 16); + CHECK_STREQ(out[0], "直流母线1电压偏高报警"); + CHECK_STREQ(out[15], "协调光纤报警"); + + // 无置位 + CHECK(motorFaultTexts(1, 0, 0, out) == 0); + + // 非法参数 + CHECK(motorFaultTexts(0, 0, 1, out) == 0); + CHECK(motorFaultTexts(3, 0, 1, out) == 0); + CHECK(motorFaultTexts(1, 9, 1, out) == 0); +} + +//-------------------------------------------------------------------------- +// 7. 部分更新合并语义(故障帧重复上报,故障清除后描述随之清除) + +static void testPartialUpdate() { + MotorState st; + uint8_t d1[8] = {0x01, 0x00, 0, 0, 0, 0, 0, 0}; // 支路一 CPLD自检故障 (bit0) + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT1, d1, 8, st)); + CHECK(st.branch1.faults.size() == 1); + CHECK_STREQ(st.branch1.faults[0], "CPLD自检故障标志"); + + // 复位后故障字清零 -> 描述清空 + uint8_t d2[8] = {0, 0, 0, 0, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_FAULT1, d2, 8, st)); + CHECK(st.branch1.faults.empty()); + + // 状态帧不影响支路有效标志 + uint8_t d3[8] = {1, 0, 0, 0, 0, 0, 0, 0}; + CHECK(decodeMotorFrame(MOTOR_CAN_STATE3, d3, 8, st)); + CHECK(st.feedbackSpeed == 1); + CHECK(st.branch1.valid); // 仍为 true(部分更新语义) +} + +int main() { + testCmdEncode(); + testCanId(); + testState3Decode(); + testState4Decode(); + testFaultDecode(); + testFaultTable(); + testPartialUpdate(); + + std::printf("\n==== pmotorTest: %d passed, %d failed ====\n", g_pass, g_fail); + return g_fail ? 1 : 0; +}