Compare commits
20
Commits
2590282aed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c61ac8ccb9 | ||
|
|
f7d2b98dc1 | ||
|
|
e59f7e3d2e | ||
|
|
c610190c54 | ||
|
|
237e17ea50 | ||
|
|
91be306a92 | ||
|
|
2a34b79461 | ||
|
|
38c489caec | ||
|
|
b77dc8cf5a | ||
|
|
b33312c547 | ||
|
|
873a1a9b09 | ||
|
|
67e732045a | ||
|
|
90caacf9a2 | ||
|
|
0639b5904d | ||
|
|
5b8f0b1421 | ||
|
|
88bed850ee | ||
|
|
ee8819c610 | ||
|
|
e9a85c21fc | ||
|
|
b547772709 | ||
|
|
7e4d08808a |
+17
-16
@@ -241,18 +241,19 @@ CTestTestfile.cmake
|
||||
_deps
|
||||
|
||||
|
||||
# PowerManager specific log files
|
||||
bin/pPowerManger.log
|
||||
# PowerManager specific files
|
||||
bin/ccuState.txt
|
||||
*.db
|
||||
# pPowerMangerHost 反馈落盘数据库
|
||||
feedback_data.db
|
||||
|
||||
# PowerManager compiled binaries (built into bin/ by CMake)
|
||||
bin/pPowerManger
|
||||
bin/SQLiteTest
|
||||
bin/pPMtest
|
||||
bin/pPowerMangerHost
|
||||
bin/pCCU
|
||||
bin/pccuTest
|
||||
bin/pCanBridge
|
||||
bin/pMotor
|
||||
bin/pELoad
|
||||
|
||||
# CI test reports
|
||||
reports/
|
||||
@@ -261,28 +262,28 @@ reports/
|
||||
bin/PowerMangerTests
|
||||
bin/udpFeeder
|
||||
bin/fullFeeder
|
||||
bin/pmotorTest
|
||||
|
||||
# SQLite runtime artifacts
|
||||
# ============================================================
|
||||
# 数据库与日志文件(全局忽略,一律不入库)
|
||||
# *.db / *.db-* SQLite 数据库及 WAL/SHM/journal 伴生文件
|
||||
# (power_data / pccu_data / pCanBridge_data /
|
||||
# feedback_data / 测试库等)
|
||||
# *.log 运行日志(pCCU.log / pPowerManger.log 等)
|
||||
# ============================================================
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
*.log
|
||||
|
||||
# Cross-compiled aarch64 artifacts (scripts/cross-build-local.sh)
|
||||
bin/arm64/
|
||||
|
||||
# Board-native build dirs (build-arm64.sh / direct on-board builds)
|
||||
# Board-native build dirs (build-board.sh) / 历史本机构建目录
|
||||
build-arm64/
|
||||
build-nosa-test/
|
||||
|
||||
# CMake-generated web asset table (pPowerMangerHost)
|
||||
src/pPowerMangerHost/webassets_gen.h
|
||||
|
||||
# ============================================================
|
||||
# 本机编译产物(避免误提交二进制)
|
||||
# ============================================================
|
||||
bin/pCCU
|
||||
bin/pccuTest
|
||||
|
||||
# ============================================================
|
||||
# MS Office 临时锁文件 / 编辑器、备份残留
|
||||
# ============================================================
|
||||
|
||||
@@ -6,4 +6,6 @@ ProcessConfig = pPowerManger
|
||||
CommsTick = 4
|
||||
log_level = INFO
|
||||
log_file = /path/to/your/logfile.log
|
||||
// 本地输入端口(接收 CCU 数据);不配置则默认 5001
|
||||
// iport = 5001
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,651 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pPowerManger 代码审查与架构优化报告</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1419; --panel:#171e26; --panel2:#1d2630; --border:#2a3642;
|
||||
--text:#d8e0e8; --text-dim:#8fa0b0; --accent:#4da3ff; --accent2:#37c8a0;
|
||||
--p0:#ff5c5c; --p1:#ffb347; --p2:#6fbf73; --code-bg:#101820;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--text);font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;line-height:1.75;font-size:15px;}
|
||||
.layout{display:flex;max-width:1440px;margin:0 auto;}
|
||||
nav{position:sticky;top:0;height:100vh;width:250px;flex-shrink:0;overflow-y:auto;padding:28px 18px;border-right:1px solid var(--border);background:var(--panel);}
|
||||
nav h3{font-size:13px;color:var(--text-dim);letter-spacing:2px;margin-bottom:14px;}
|
||||
nav a{display:block;color:var(--text-dim);text-decoration:none;padding:5px 10px;border-radius:6px;font-size:13.5px;border-left:2px solid transparent;}
|
||||
nav a:hover{color:var(--accent);background:var(--panel2);}
|
||||
main{flex:1;padding:44px 56px 120px;max-width:1140px;}
|
||||
header.cover{border-bottom:1px solid var(--border);padding-bottom:30px;margin-bottom:36px;}
|
||||
header.cover .tag{color:var(--accent);font-size:13px;letter-spacing:3px;}
|
||||
header.cover h1{font-size:30px;margin:10px 0 8px;font-weight:600;}
|
||||
header.cover .meta{color:var(--text-dim);font-size:13.5px;}
|
||||
h2{font-size:22px;margin:52px 0 18px;padding-left:14px;border-left:4px solid var(--accent);font-weight:600;}
|
||||
h3{font-size:17px;margin:30px 0 12px;color:var(--accent);font-weight:600;}
|
||||
h4{font-size:15.5px;margin:20px 0 8px;color:var(--text);}
|
||||
p{margin:10px 0;color:var(--text);}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px 24px;margin:16px 0;}
|
||||
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px;}
|
||||
th{background:var(--panel2);color:var(--accent);text-align:left;padding:9px 12px;border:1px solid var(--border);font-weight:600;white-space:nowrap;}
|
||||
td{padding:8px 12px;border:1px solid var(--border);vertical-align:top;}
|
||||
tr:nth-child(even) td{background:rgba(255,255,255,0.02);}
|
||||
code{font-family:Consolas,"Courier New",monospace;background:var(--code-bg);color:#9fd3ff;padding:1px 6px;border-radius:4px;font-size:13px;}
|
||||
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:14px 18px;overflow-x:auto;margin:12px 0;}
|
||||
pre code{background:none;padding:0;color:#c8d8e8;font-size:13px;line-height:1.6;}
|
||||
.badge{display:inline-block;padding:1px 10px;border-radius:10px;font-size:12px;font-weight:600;margin-right:6px;white-space:nowrap;}
|
||||
.p0{background:rgba(255,92,92,.15);color:var(--p0);border:1px solid var(--p0);}
|
||||
.p1{background:rgba(255,179,71,.12);color:var(--p1);border:1px solid var(--p1);}
|
||||
.p2{background:rgba(111,191,115,.12);color:var(--p2);border:1px solid var(--p2);}
|
||||
.loc{color:var(--text-dim);font-size:12.5px;font-family:Consolas,monospace;}
|
||||
ul,ol{margin:8px 0 8px 24px;}
|
||||
li{margin:5px 0;}
|
||||
.flow{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:14px 0;font-size:13px;}
|
||||
.node{background:var(--panel2);border:1px solid var(--accent);border-radius:8px;padding:8px 14px;color:var(--text);}
|
||||
.node.green{border-color:var(--accent2);}
|
||||
.node.orange{border-color:var(--p1);}
|
||||
.arrow{color:var(--accent);font-weight:bold;}
|
||||
.note{border-left:3px solid var(--accent2);background:rgba(55,200,160,.06);padding:12px 18px;border-radius:0 8px 8px 0;margin:14px 0;font-size:14px;}
|
||||
.warn{border-left:3px solid var(--p1);background:rgba(255,179,71,.06);padding:12px 18px;border-radius:0 8px 8px 0;margin:14px 0;font-size:14px;}
|
||||
.danger{border-left:3px solid var(--p0);background:rgba(255,92,92,.07);padding:12px 18px;border-radius:0 8px 8px 0;margin:14px 0;font-size:14px;}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||
.kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0;}
|
||||
.kpi .item{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px;text-align:center;}
|
||||
.kpi .num{font-size:26px;font-weight:700;color:var(--accent);}
|
||||
.kpi .lbl{font-size:12.5px;color:var(--text-dim);margin-top:4px;}
|
||||
.phase{border-left:3px solid var(--accent);padding:6px 0 6px 20px;margin:18px 0;position:relative;}
|
||||
.phase::before{content:"";position:absolute;left:-7px;top:12px;width:11px;height:11px;border-radius:50%;background:var(--accent);}
|
||||
.phase h4{margin:0 0 4px;color:var(--accent);}
|
||||
.small{font-size:13px;color:var(--text-dim);}
|
||||
@media (max-width:1000px){nav{display:none;}main{padding:24px;}.kpi{grid-template-columns:repeat(2,1fr);}.grid2{grid-template-columns:1fr;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav>
|
||||
<h3>目录</h3>
|
||||
<a href="#s1">1 审查概述</a>
|
||||
<a href="#s2">2 现状架构分析</a>
|
||||
<a href="#s3">3 代码审查意见</a>
|
||||
<a href="#s3-1">3.1 确定性缺陷(Bug 级)</a>
|
||||
<a href="#s3-2">3.2 状态机与控制逻辑</a>
|
||||
<a href="#s3-3">3.3 通信逻辑</a>
|
||||
<a href="#s3-4">3.4 故障码体系</a>
|
||||
<a href="#s3-5">3.5 数据管理与并发</a>
|
||||
<a href="#s3-6">3.6 上位机与 Web 服务</a>
|
||||
<a href="#s3-7">3.7 构建工程与配置管理</a>
|
||||
<a href="#s4">4 架构优化方案</a>
|
||||
<a href="#s4-1">4.1 目标分层架构</a>
|
||||
<a href="#s4-2">4.2 状态机重构</a>
|
||||
<a href="#s4-3">4.3 通信层重构</a>
|
||||
<a href="#s4-4">4.4 故障码统一方案</a>
|
||||
<a href="#s4-5">4.5 上位机/下位机管理</a>
|
||||
<a href="#s5">5 设备上电管理与状态机转移重构详细设计</a>
|
||||
<a href="#s6">6 特性一致性保障策略</a>
|
||||
<a href="#s7">7 测试方案</a>
|
||||
<a href="#s8">8 实施路线图</a>
|
||||
<a href="#s9">9 附录:问题清单</a>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
<header class="cover">
|
||||
<div class="tag">CODE REVIEW & ARCHITECTURE OPTIMIZATION</div>
|
||||
<h1>pPowerManger 电源管理软件<br>代码审查与架构优化报告</h1>
|
||||
<div class="meta">项目:H100PowerManger(UUV 复合能源功率管理) | 范围:src/pPowerManger、src/pPowerMangerHost、test、ci | 日期:2026-09-03 | 版本:V1.0</div>
|
||||
</header>
|
||||
|
||||
<!-- ================= 1 ================= -->
|
||||
<h2 id="s1">1 审查概述</h2>
|
||||
<p>pPowerManger 是 H100 水下无人航行器复合能源系统的电源管理进程,运行于 MOOS 中间件之上,承担上位机指令接收、下位机(CCU/配电)设备管理、14 工况状态机调度、故障检测与上报、数据持久化及本地 Web 监视等职责。本次审查覆盖下位机核心代码约 2.4 万行(不含第三方 mongoose 库约 2.8 万行),重点评估代码架构、控制逻辑、通信逻辑、故障管理与测试体系,并给出以保持外部特性严格一致为前提的优化方案。</p>
|
||||
|
||||
<div class="kpi">
|
||||
<div class="item"><div class="num">14</div><div class="lbl">工况状态(tinyfsm)</div></div>
|
||||
<div class="item"><div class="num">4</div><div class="lbl">并发线程(无统一模型)</div></div>
|
||||
<div class="item"><div class="num">3</div><div class="lbl">套互不统一的故障编码</div></div>
|
||||
<div class="item"><div class="num">0</div><div class="lbl">状态机 / 上位机测试用例</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h4>总体结论</h4>
|
||||
<p>现有代码功能可用,但工程化程度不足,核心问题集中在五条主线:</p>
|
||||
<ol>
|
||||
<li><b>存在多个确定性缺陷</b>:Lifting(起吊准备)状态永远无法到达;浮力调节状态反馈因赋值误写为比较而永不更新;MOOS 邮件偏斜检查导致整批邮件被丢弃等(见 3.1)。</li>
|
||||
<li><b>状态机退化</b>:tinyfsm 仅被用作事件路由外壳,真正的转移逻辑集中在基类两个巨型 switch 中;无 guard 条件;故障检测与 FaultState 完全脱钩。</li>
|
||||
<li><b>通信层职责混杂</b>:协议知识(JSON 键名、设备 ID、帧格式)散落在 Driver、UpperCommManager、udpComm 三处;4 个线程对共享数据加锁纪律不一致,存在数据竞争与忙等阻塞。</li>
|
||||
<li><b>故障码体系三分天下</b>:电池故障表、配电故障表、0xLSSS 系统故障魔数三套编码并存,双故障存储导致单一事实源被破坏,上报与显示映射脱节。</li>
|
||||
<li><b>测试存在三大空白</b>:状态机迁移、故障注入、上位机链路均无测试,重构缺乏安全网。</li>
|
||||
</ol>
|
||||
<p>优化应以"先建测试基线、再做特性等价重构"为总原则,分四个阶段实施(见第 7、8 章)。</p>
|
||||
</div>
|
||||
|
||||
<!-- ================= 2 ================= -->
|
||||
<h2 id="s2">2 现状架构分析</h2>
|
||||
|
||||
<h3>2.1 进程与通信拓扑</h3>
|
||||
<div class="flow">
|
||||
<span class="node green">Web 页面(浏览器)</span><span class="arrow">⇄ WebSocket</span>
|
||||
<span class="node green">pPowerMangerHost(上位机桥)</span><span class="arrow">⇄ MOOSDB (TCP:9000, JSON 字符串变量)</span>
|
||||
<span class="node">pPowerManger(本进程)</span><span class="arrow">⇄ UDP 二进制帧 (5001↔7000)</span>
|
||||
<span class="node orange">pCCU / pCanBridge</span><span class="arrow">⇄ CAN</span>
|
||||
<span class="node orange">电池 / 燃料电池 / 配电设备</span>
|
||||
</div>
|
||||
<p>另有两条旁路:pPowerManger 内嵌 HTTP 服务(端口 8000/8090)直读内部对象提供本地监视页面;上位机 HostSim 自带 Web 服务(端口 18080)+ SQLite 历史库。上位机与下位机之间<b>不存在直接 socket 连接</b>,全部交互以 MOOSDB 上的 JSON 字符串变量为协议载体(<code>uPower_*_cmd</code> 下行、<code>uPower_*_fb</code> 上行)。</p>
|
||||
|
||||
<h3>2.2 下位机内部模块</h3>
|
||||
<table>
|
||||
<tr><th>模块</th><th>文件</th><th>规模</th><th>实际职责</th><th>主要问题</th></tr>
|
||||
<tr><td>主应用</td><td class="loc">PowerManger.h/.cpp</td><td class="loc">~680 行</td><td>MOOSApp 框架、线程拉起、全局对象持有</td><td>上帝对象,持有全部管理器指针并被 FSM 反向穿透</td></tr>
|
||||
<tr><td>状态机</td><td class="loc">fsm/PowerManagerFsm.cpp + 14 状态</td><td class="loc">~3700 行</td><td>事件路由 + 设备指令处理 + 故障编码 + 状态拷贝</td><td>上帝类;转移逻辑退化到基类 switch;无 guard</td></tr>
|
||||
<tr><td>上位机通信</td><td class="loc">UpperCommManager.cpp</td><td class="loc">261 行</td><td>MOOS 邮件 JSON 解析、周期状态广播</td><td>解析失败误报 Unknown key;无连接管理</td></tr>
|
||||
<tr><td>下位机通信</td><td class="loc">LowerCommManager.cpp</td><td class="loc">890 行</td><td>UDP 监听线程、帧校验入队、状态注入、断路器闭环操作</td><td>回调直改全局数据;忙等最长约 7 s;35 个操作函数重复</td></tr>
|
||||
<tr><td>协议编解码</td><td class="loc">udpcomm/udpComm.cpp, ccuUdpMsg.h</td><td class="loc">~600 行</td><td>UDP 二进制帧打包/解析、校验</td><td>硬编码帧长;死分支;每帧堆分配</td></tr>
|
||||
<tr><td>设备策略表</td><td class="loc">driver.cpp</td><td class="loc">1094 行</td><td>68 字段 DriverTable、9 张工况设备表、JSON 编解码</td><td>9 张表约 600 行逐字段重复赋值;错误码形同虚设</td></tr>
|
||||
<tr><td>系统数据</td><td class="loc">systemData.h, pmSysvariable.h</td><td class="loc">~2250 行</td><td>协议镜像结构体 + 全局数据池单例</td><td>头文件内实现;百字段全局池;锁纪律不一致</td></tr>
|
||||
<tr><td>故障码</td><td class="loc">faultCode.h</td><td class="loc">125 行</td><td>电池 54 项 + 配电 45 项故障表</td><td>与第三套 0xLSSS 魔数并存;上报/显示映射脱节</td></tr>
|
||||
<tr><td>本地 Web</td><td class="loc">httpserver/(mongoose + 内嵌页面)</td><td class="loc">~7000 行</td><td>dashboard/日志/测试注入/电池/燃料电池页面</td><td>与上位机 Web 重复建设;HTTP 回调越权直改状态</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>2.3 线程模型</h3>
|
||||
<table>
|
||||
<tr><th>线程</th><th>周期</th><th>主要工作</th><th>风险</th></tr>
|
||||
<tr><td>MOOS 主线程</td><td>4 Hz</td><td>OnNewMail → 指令解析 → FSM dispatch;Iterate → CycleTriggeredEvent → 故障检测</td><td>断路器闭环忙等约 7 s 会卡住整个邮件循环</td></tr>
|
||||
<tr><td>commLoop 线程</td><td>1 Hz</td><td>心跳/周期指令下发、队列清理</td><td>内含 usleep/MOOSPause;与监听线程并发清队列</td></tr>
|
||||
<tr><td>UDP 监听线程</td><td>阻塞读</td><td>收帧 → 校验入队 → 出队写 systemData / SQLite</td><td>直接写业务数据;退出时可能挂在 recv 上</td></tr>
|
||||
<tr><td>HTTP 线程</td><td>事件驱动</td><td>Web 页面与 REST</td><td>/fcs/control 回调里加锁直改 PowerManger 状态</td></tr>
|
||||
</table>
|
||||
<p>共享数据 <code>m_msCmd</code>、<code>m_depth</code>、<code>m_insData</code>、<code>m_thrustRpm</code> 等由 MOOS 线程无锁写、其余线程读;<code>m_stateMutex</code> 仅保护部分字段;<code>updateSystemData</code> 持 <code>m_dataMutex</code> 再取 <code>faultCodeMutex</code>,锁顺序仅靠注释约定(<span class="loc">PowerManger.h:92-93</span>)。整体属"能跑但不具备可论证的线程安全性"。</p>
|
||||
|
||||
<!-- ================= 3 ================= -->
|
||||
<h2 id="s3">3 代码审查意见</h2>
|
||||
|
||||
<h3 id="s3-1">3.1 确定性缺陷(Bug 级,建议立即修复)</h3>
|
||||
<p>以下问题经逐行核对确认,均有明确代码证据,与架构无关,应在重构前先以最小改动修复并补充回归用例。</p>
|
||||
<table>
|
||||
<tr><th>#</th><th>级别</th><th>问题</th><th>位置</th><th>影响与修复建议</th></tr>
|
||||
<tr>
|
||||
<td>B1</td><td><span class="badge p0">P0</span></td>
|
||||
<td><b>Lifting(起吊准备)状态永远无法到达</b>:<code>handleWorkCmd</code> 的 case 12 只有日志,缺少 <code>transit<Lifting>()</code> 与 <code>break</code>,直接坠入 default</td>
|
||||
<td class="loc">fsm/PowerManagerFsm.cpp:2934-2936</td>
|
||||
<td>上位机"起吊准备"指令被静默吞掉,Lifting 为死状态。补 <code>transit<Lifting>(); break;</code>。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B2</td><td><span class="badge p0">P0</span></td>
|
||||
<td><b>赋值误写为比较</b>:<code>s.buoyage == 1;</code>(共 4 处,值 1/3/2/0),语句无副作用</td>
|
||||
<td class="loc">fsm/PowerManagerFsm.cpp:1471,1475,1482,1486</td>
|
||||
<td>浮力调节(buoyage)状态反馈永远不更新,上报恒为初值。改为 <code>=</code>。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B3</td><td><span class="badge p0">P0</span></td>
|
||||
<td><b>MOOS 邮件整批丢弃</b>:OnNewMail 中任一邮件偏斜超差即 <code>return true</code>,同批其余邮件全部丢失</td>
|
||||
<td class="loc">PowerManger.cpp:121-125</td>
|
||||
<td>网络抖动时会成片丢失上位机指令。改为跳过该条、继续处理后续邮件。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B4</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>JSON 键名不匹配导致指令静默丢失</b>:发送端键 <code>"mastLiftingServo_uint8"</code>,接收端解析键 <code>"mastLiftingServo_uint8_uint8"</code></td>
|
||||
<td class="loc">driver.cpp:844 vs 924</td>
|
||||
<td>桅杆升降舵指令永远解析失败。统一键名,并增加"未知键/未命中"告警日志。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B5</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>故障检测函数重复执行</b>:<code>basicNavigationFault(); extendedNavigationFault();</code> 连续调用两遍</td>
|
||||
<td class="loc">fsm/PowerManagerFsm.cpp:52-55</td>
|
||||
<td>4 Hz 下双倍开销,且对带副作用的故障逻辑是隐患。删除重复行。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B6</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>三个断路器故障永不上报</b>:fbReserved/tyzReserved/reserved 调用的是无 faultCode 参数的重载,disfaultMap 中 code 10-12 成死码</td>
|
||||
<td class="loc">systemData.h:1026-1028</td>
|
||||
<td>补齐故障码入参,或删除死码并同步前端映射。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B7</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>数组越界风险</b>:故障注入 <code>batfaultMap[e.eventId].code</code> 对来自外部的 eventId 无边界检查;<code>systemData.h:524</code> 循环 <code>i<2</code> 越界访问 <code>reserved2[1]</code>(cppcheck error 级)</td>
|
||||
<td class="loc">fsm/PowerManagerFsm.cpp:3053;systemData.h:524</td>
|
||||
<td>注入接口可被越界触发,属安全隐患。加边界校验;修正循环上界。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B8</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>TestEvent 故障注入 switch 缺 break</b>:case 15 坠入 default;另有 <code>react(TestEvent)</code> 直接构造 FaultEvent 调故障函数,绕过事件机制</td>
|
||||
<td class="loc">fsm/PowerManagerFsm.cpp:2988-2989, 3084-3092</td>
|
||||
<td>注入 15 号故障时行为未定义。补 break,统一走事件派发。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B9</td><td><span class="badge p2">P2</span></td>
|
||||
<td><b>未初始化返回</b>(cppcheck error 级):<code>uPlan_taskStart.h:29</code> 返回未初始化 <code>mission</code>;<code>uExternComm_setDeviceSwitch.h:39</code> 可能返回未初始化 <code>cmd.cmd</code>;<code>uPower_pmState.h:14</code> 对含 <code>std::vector</code> 的结构体 memset</td>
|
||||
<td class="loc">upmsg/ 多个头文件</td>
|
||||
<td>协议层偶发脏数据。统一在结构体定义处给默认值,禁止对非 POD memset。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B10</td><td><span class="badge p2">P2</span></td>
|
||||
<td><b>注释与代码不符</b>:FaultState.cpp:46 注释"5 秒超时"实为 20 秒(:53),且与 Lifting.cpp:40、PowerManagerFsm.cpp 内 5 处同逻辑代码的 5 秒超时不一致;同一段超时代码共复制 7 份</td>
|
||||
<td class="loc">FaultState.cpp:46/53 等</td>
|
||||
<td>误导维护且各副本超时阈值漂移。随重构统一为命名常量。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B11</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>模板残留致每条正常指令误报 "Unhandled Mail"</b>:OnNewMail 的 for 循环内保留了 MOOS 模板代码 <code>if(key=="FOO")...else if(key!="APPCAST_REQ") reportRunWarning(...)</code>,除 APPCAST_REQ 外的所有邮件(含已正常处理的 uPower_*_cmd/fb)都被计入 run warning</td>
|
||||
<td class="loc">PowerManger.cpp:142-146</td>
|
||||
<td>AppCast 告警计数被污染、日志刷屏。删除该模板残留,仅在 processUpperMsg 返回 false 时告警。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B12</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>畸形 JSON 可致崩溃</b>:<code>loadDriverIdFromJson</code> 对 <code>root[field].asUInt()</code> 无类型检查(<code>JSON_USE_EXCEPTION=1</code>),调用处无 try/catch;<code>SafeReadUInt</code> 用 <code>std::cerr</code> 报错而非日志框架</td>
|
||||
<td class="loc">driver.cpp:935-936;UpperCommManager.cpp:48</td>
|
||||
<td>恶意/畸形指令使 jsoncpp 抛异常向上传播。统一走 SafeReadUInt 并捕获异常、改走日志。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B13</td><td><span class="badge p1">P1</span></td>
|
||||
<td><b>HTTP 服务在配置就绪前启动</b>:<code>m_httpServer->start()</code> 在构造函数调用,此时 mission 配置未加载、<code>m_db</code> 为 nullptr、UDP 未 bind,控制接口(/fcs/control 等)已对外可访问</td>
|
||||
<td class="loc">PowerManger.cpp:83-87</td>
|
||||
<td>存在"配置生效前已暴露控制面"窗口。将 start() 移到 OnStartUp 末尾(DB/端口就绪后)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>B14</td><td><span class="badge p2">P2</span></td>
|
||||
<td><b>死代码与孤儿成员</b>:<code>FsmLoop()</code>/<code>_FsmCB</code> 无任何线程启动;<code>m_deviceCmdQuenue</code> 及 push/pop/isEmpty 三接口无消费者;<code>buildReport()</code> 为 MOOS 模板占位;<code>SKEW_TOLERANCE 5</code> 宏未用而 <code>m_skew=10</code> 硬编码</td>
|
||||
<td class="loc">PowerManger.cpp:18/339;PowerManger.h:48/121</td>
|
||||
<td>误导维护。随阶段 3 一并清理,SKEW 阈值统一为命名常量。</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 id="s3-2">3.2 状态机与控制逻辑</h3>
|
||||
<h4>3.2.1 现状转移关系(经代码还原)</h4>
|
||||
<table>
|
||||
<tr><th>源状态</th><th>事件</th><th>目标状态</th><th>说明</th></tr>
|
||||
<tr><td>InitState</td><td>entry() 内直接切换</td><td>StandbyState</td><td>初始化完成即转</td></tr>
|
||||
<tr><td>任意状态</td><td>MasterCommandEvent(workCMD 1~11)</td><td>Standby / ShoreBasedReady / WaterBasedReady / RemoteControl / Cruise / HighSpeed / FloatDown / FloatAdjust / UnderwaterSurvey / SurfaceSurvey / DJMode</td><td class="loc">PowerManagerFsm.cpp:2887-2941;workCMD=12 无效(B1)</td></tr>
|
||||
<tr><td>任意状态</td><td>TaskStartEvent</td><td>THROW_LOAD→FaultState;FLOAT_UP/SAT_COMM 等→FloatDown;RECYCLE/SAIL→CruiseMode;HOVER→FloatAdjust;TOUR→UnderwaterSurvey</td><td class="loc">PowerManagerFsm.cpp:948-985</td></tr>
|
||||
<tr><td>任意状态</td><td>TaskStopEvent</td><td>RemoteControl</td><td class="loc">基类 react,cpp:13</td></tr>
|
||||
<tr><td>StandbyState</td><td>StandbyEvent</td><td>StandbyState(自切换)</td><td>语义存疑</td></tr>
|
||||
<tr><td>FaultState</td><td>—(无显式出口)</td><td>—</td><td>仅能借基类 handleWorkCmd 间接跳出</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>3.2.2 主要架构问题</h4>
|
||||
<div class="danger">
|
||||
<b>A1 状态机退化为"事件路由壳"。</b>真正的状态转移表不在各状态类中,而集中在基类 <code>handleWorkCmd</code> 与 <code>react(TaskStartEvent)</code> 两个巨型 switch;14 个状态类的 <code>react(MasterCommandEvent)</code> 是完全相同的复制粘贴。tinyfsm 提供的 <code>transit<S>(action, condition)</code> guard 重载(tinyfsm.hpp:151)全项目零使用——<b>任何状态下都可经 workCMD 直接跳进任何工况</b>(如巡航中直接切起吊),无任何合法性检查。
|
||||
</div>
|
||||
<div class="danger">
|
||||
<b>A2 故障检测与 FaultState 完全脱钩。</b>8 个 <code>xxxFault()</code> 检测函数只写故障码集合,从不触发状态迁移;FaultState 唯一入口是"抛载任务"(TaskStartEvent::THROW_LOAD),即 3 级故障(如深度失效 0x1302)发生时系统仍停留在原工况。FaultState 无显式出口、无故障恢复确认、无故障码清除联动。FaultEvent 无任何 dispatch 点,基类 <code>react(FaultEvent)</code> 为死代码。
|
||||
</div>
|
||||
<div class="warn">
|
||||
<b>A3 entry() 内长阻塞。</b>WaterBasedReady.cpp:50 与 Lifting.cpp:10 在 entry() 中 <code>MOOSPause(10000)</code>;ShoreBasedReady.cpp:24-35 while 轮询等待约 10 s;断路器操作闭环在事件处理路径上忙等约 7 s。状态迁移期间 FSM 无法响应任何新事件,上位机指令被积压在邮件队列。
|
||||
</div>
|
||||
<div class="warn">
|
||||
<b>A4 上帝类与全局穿透。</b>PowerManagerFsm.cpp(3096 行)集中了 10 个高度重复的设备指令处理函数、10 个 getXxxStatus 状态拷贝函数、8 组故障编码函数(约 200 个 0xLSSS 魔法故障码)。状态代码经 <code>static PowerManger* pm</code> 裸指针直接读写宿主几十个公有成员、直接操作断路器、直接 sendNotification,单向依赖原则被彻底打破。
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>A5 语义错位</b>:FloatDown 实际承载"上浮/下潜/卫星通信/卫星标定"四种业务;<code>handelPowerCmd</code>、<code>underWterDevices</code>、<code>mergencyLithiumBattery</code> 等拼写错误已进入接口。</li>
|
||||
<li><b>A6 死代码</b>:<code>PowerSystemState</code> 枚举(hpp:21)、<code>DeviceCmd</code> 事件(hpp:41)、<code>handleDeviceCmd</code> 空壳(cpp:2966)、<code>m_currentDriver</code>(hpp:87)、<code>checkError()</code> 被注释掏空(PowerManger.cpp:344-356)。</li>
|
||||
<li><b>A7 日志混乱</b>:基类兜底 react 用 <code>std::cout << "No Idear"</code>(hpp:94);entry/exit 大量使用 RTTI demangle + ANSI 转义打印,嵌入式目标上属无效开销;<code>printState</code> 用 <code>desp.find("故障")</code> 字符串匹配决定日志级别(PowerManger.cpp:359),极其脆弱。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s3-3">3.3 通信逻辑</h3>
|
||||
<div class="warn">
|
||||
<b>C1 上位机连接无管理。</b><code>LowerCommManager::m_connectState</code>(h:131)与 <code>PowerManger::connectState</code>(h:136)声明后从未使用;上位机方向无心跳、无在线检测、无断线告警,仅靠 MOOS 库自动重连。下位机方向有 30 s 超时检测(systemData.h:752-761),但超时后仅置标志位,未与故障系统联动。
|
||||
</div>
|
||||
<div class="warn">
|
||||
<b>C2 协议知识三处散养。</b>JSON 键名与设备 ID 映射(协议层知识)写在 Driver(业务策略层)里,与发送端 driver.cpp 各维护一份,已造成 B4 键名失配;UDP 帧格式在 udpComm,设备 ID 0-61 在 driver.cpp:908-930 与 DeviceId 枚举重复硬编码。<code>Driver</code> 实例在 PowerManger 与 UpperCommManager 各持一份,表状态易不一致。
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>C3 校验与错误处理形同虚设</b>:<code>loadDriverTableFromJson</code> 的 error_code 每次被覆盖、从不检查,恒返回 0(driver.cpp:962-1093);<code>sendCcuSetParmCmd</code> 无论发送成败都 return true(udpComm.cpp:609-619);帧长硬编码 18/6/8 且旁边就是 TODO(udpComm.cpp:467/491/515/539)。</li>
|
||||
<li><b>C4 死分支与静默吞帧</b>:<code>CCU_UDPMSG_START1 == DIS_MSG_HEAD1 == 0x40</code>,udpComm.cpp:47-56 两个 else-if 完全等价;未知 id 走 default 后仍 return true(udpComm.cpp:344-347)。</li>
|
||||
<li><b>C5 高频路径低效</b>:每帧堆分配(udpComm.cpp:462 等 5 处)、每个 UDP 包 cout 打印一次(LowerCommManager.cpp:381)、发送函数失败后仍 return true(LowerCommManager.cpp:142-144)。</li>
|
||||
<li><b>C6 废弃函数藏错误数据</b>:<code>sendDisSysCommand</code> 对 bus3 无视入参硬编码 <code>0x55/0xAA</code>(LowerCommManager.cpp:183-189),若被误用将下发错误指令。</li>
|
||||
<li><b>C7 双 Web 重复建设</b>:下位机 httpserver(8000)与上位机 HostSim Web(18080)都用 mongoose + /ws + 内嵌页面提供状态监视;下位机 /fcs/control 在 HTTP 回调中直改状态(httpserver.cpp:150),绕过 MOOS 消息机制,属越权控制通道。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s3-4">3.4 故障码体系</h3>
|
||||
<table>
|
||||
<tr><th>编码方案</th><th>定义位置</th><th>格式</th><th>使用方</th><th>问题</th></tr>
|
||||
<tr><td>电池故障表</td><td class="loc">faultCode.h:17-76,batfaultMap[54]</td><td>单字节 0x01-0xC9,带 1-4 级</td><td>仅故障注入使用</td><td>level 字段与描述矛盾(:35);注入越界风险(B7)</td></tr>
|
||||
<tr><td>配电故障表</td><td class="loc">faultCode.h:78-124,disfaultMap[45]</td><td>十进制 1-45</td><td>断路器故障上报</td><td>code 10-12 死码(B6)</td></tr>
|
||||
<tr><td>系统故障码</td><td class="loc">无定义文件,散落在 PowerManagerFsm.cpp:1676 起</td><td>16 位魔数 0xLSSS(高半字节=等级)</td><td>8 个检测函数 + getFaultLevel</td><td>约 200 个裸字面量无注释无枚举;等级提取依赖位运算巧合</td></tr>
|
||||
</table>
|
||||
<ul>
|
||||
<li><b>F1 双重故障存储</b>:真实故障进 <code>SystemData::sysFaultCodes</code>(set,自恢复即自动清除,无锁存/确认);注入故障进 <code>PowerManger::faultCode</code>(vector,不去重、只能整体 clear)。<code>getFaultLevel()</code>(PowerManger.cpp:448)与上报用的 <code>data.getFaultCodeLevel()</code>(uPower_pmState.h:50)读的是<b>不同集合</b>,同一件事故障等级可能不一致。</li>
|
||||
<li><b>F2 死赋值</b>:UpperCommManager.cpp:240 把 <code>getFaultCodes()</code> 赋给 state,但 <code>uPower_pmState.h:41-73 buildMsg()</code> 忽略该赋值,改用 <code>data.getFaultCode()</code>。</li>
|
||||
<li><b>F3 映射脱节</b>:故障码→中文描述的映射 C++ 侧仅用于注入,上报只发数字码;显示文本由前端 JS 重写一份(httpserver/fuelcell.h:834-840);0xLSSS 系统故障码<b>没有任何文本映射</b>。</li>
|
||||
<li><b>F4 条件编译陷阱(经核实比初版更严重)</b>:<code>DEBUG</code> 宏在全部构建中均未被定义(CMake 无 <code>-DDEBUG</code>),而 systemData.h:776 的 <code>#ifdef DEBUG</code> 依赖它——因此燃料电池 4 级故障字 <code>fc_fault_level_1..4</code> 在<b>所有构建</b>下都永不更新(恒为 0),并非仅"发布版";systemData.h:7 的 <code>#define DEBUFG</code> 是拼写错误且定义了无用宏。同理 <code>#ifdef _DEBUG</code>(UpperCommManager.cpp 全篇)在 Linux 下也永不生效,其调试追踪成员均为死代码。</li>
|
||||
<li><b>F5 无故障事件持久化</b>:SQLite 仅随帧存原始故障字段,无独立故障事件表,故障发生/恢复时间不可追溯,无法支撑事后分析。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s3-5">3.5 数据管理与并发</h3>
|
||||
<ul>
|
||||
<li><b>D1 全局数据池</b>:SystemData(systemData.h,1219 行头文件内实现)是百字段单例,字段基本是 pmSysvariable 协议镜像的 float 转换副本,<code>updateSystemData()</code> 逐字段拷贝数百行——协议每改一处需同步三处(pmSysvariable / systemData / updateSystemData)。构造函数 60+ 项手工初始化(:660-690)。</li>
|
||||
<li><b>D2 锁纪律不一致</b>:提供了部分加锁访问器(:694-733),但 FSM 大量裸读绕过锁(PowerManagerFsm.cpp:1813/1920/1930);TestEvent 直接无锁写 <code>fc_fault_level_1</code>(:3060);<code>msg_*_Update_time</code> 无锁写、另一把锁下读(LowerCommManager.cpp:229-233)。</li>
|
||||
<li><b>D3 整数截断风险</b>:对 double 使用未限定的 <code>abs()</code>(systemData.h:757),可能解析为 C 的 <code>int abs</code> 截断时间差,应使用 <code>std::fabs</code>。</li>
|
||||
<li><b>D4 双重时间基准</b>:commLoop 每 1 s <code>clearMsgQueue()</code>(LowerCommManager.cpp:124)与监听线程并发清队列,语义冗余且可能丢未消费帧。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s3-6">3.6 上位机与 Web 服务</h3>
|
||||
<p>上位机 HostSim 的设计相对干净:网页指令经 WebSocket 入 <code>m_cmdQueue</code>、Iterate 中 <code>drainCommands()</code> 转发 MOOSDB(HostSim.cpp:213-228);上行订阅 12 个反馈变量,dirty 后 <code>broadcast(buildSnapshot())</code> 推送,并以 SQLite 表 <code>fb_log</code> 保留 24 h 历史(FeedbackStore.cpp:62-69),提供 <code>/api/history</code>、<code>/api/rawlog</code>。可作为重构时"松耦合桥"的保留样板。</p>
|
||||
<ul>
|
||||
<li><b>H1 功能重叠</b>:与下位机 httpserver 在状态监视上重复;建议下位机 Web 退化为纯调试用途(仅本机回环监听),状态监视统一归上位机。</li>
|
||||
<li><b>H2 越权通道</b>:下位机 /fcs/control 直改内部状态,破坏"一切控制经 MOOS 指令"的单入口原则,应迁移为向 MOOSDB 发指令或加鉴权。</li>
|
||||
<li><b>H3 页面硬编码</b>:1912 行 data.h 等 7 个页面以 <code>R"(...)"</code> 字符串嵌在头文件,无构建期资源管线(上位机已有 webassets_gen.h 方案可复用)。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s3-7">3.7 构建工程与配置管理</h3>
|
||||
<ul>
|
||||
<li><b>E1 编译告警默认关闭、无 sanitizer 门禁</b>:CMakeLists 仅加 <code>-fPIC -g -Wdeprecated-declarations</code>,<code>-Wall</code> 需手动开 WALL_ON 且写法含语法错误(<code>"-Wall" -C++11</code>,CMakeLists.txt:86);Release 依赖 CMake 默认 <code>-O3</code> 未显式声明;无 ASan/UBSan/TSan 构建类型。建议:开启 <code>-Wall -Wextra</code> 并清零告警,新增 sanitizer 构建目标,测试二进制输出到 build 目录。</li>
|
||||
<li><b>E2 依赖不可复现、GLOB 反模式</b>:<code>FILE(GLOB ...)</code> 收集头/库目录(CMakeLists.txt:49/53),新增文件不触发重配;mongoose/sqlite3/loguru/jsoncpp 以源码 vendor 进 src 且无版本号与许可证记录;<code>CMAKE_MINIMUM_REQUIRED(VERSION 3.5)</code> 过老。建议 GLOB 改显式列表,第三方库固定版本并登记 LICENSE。</li>
|
||||
<li><b>E3 配置解析无校验</b>:OnStartUp 用 <code>atoi</code> 解析 ccuport/iport(PowerManger.cpp:229/233),非法值静默为 0,端口无 1-65535 范围检查;缺配置块仅 warning。建议显式校验非法值并告警。</li>
|
||||
<li><b>E4 SQLite 高频路径重复 prepare</b>:<code>insertGeneric</code> 每次插入重拼 SQL 并 prepare(SQLite.cpp:610-625),<code>onFrame</code> 每帧同样 prepare;建议缓存 prepared statement,故障事件表纳入 SCHEMA_VERSION 迁移机制。</li>
|
||||
<li><b>E5 日志/错误输出三套并存</b>:loguru、<code>std::cerr</code>(driver.cpp:887/904/957)、<code>std::cout</code>(LowerCommManager.cpp:373/381)混用,无法按级别关停。建议统一 loguru,cout/cerr 清零。</li>
|
||||
</ul>
|
||||
|
||||
<!-- ================= 4 ================= -->
|
||||
<h2 id="s4">4 架构优化方案</h2>
|
||||
<p>优化总原则:<b>外部特性严格不变</b>(MOOS 变量名与 JSON 契约、UDP 帧格式、Web 接口、故障码取值、状态迁移外部可观测序列均保持),<b>内部结构彻底重整</b>。所有重构以第 6 章的一致性保障与第 7 章的测试基线为前提。</p>
|
||||
|
||||
<h3 id="s4-1">4.1 目标分层架构</h3>
|
||||
<div class="card">
|
||||
<pre><code>┌─────────────────────────────────────────────────────────────┐
|
||||
│ 接口层 (Interface) UpperCommManager │ HttpServer(调试) │ ← 只做协议适配,不含业务判断
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 应用层 (Application) PowerManagerApp │ ← 协调者:指令→校验→派发到领域服务
|
||||
│ ├─ CommandService(指令接收、校验、应答) │
|
||||
│ ├─ FaultManager (故障检测、锁存、上报、事件持久化) │
|
||||
│ └─ StateMachineService(工况状态机,表驱动 + guard) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 领域层 (Domain) DeviceRegistry │ PowerPolicy(Driver表) │ ← 纯 C++,无 MOOS/网络依赖,可单测
|
||||
│ SystemState(线程安全的状态仓储) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 基础设施层 (Infra) UdpLink(收发) │ ProtocolCodec(编解码) │ ← 唯一知道帧格式/JSON键名的地方
|
||||
│ SQLiteStore │ MoosGateway │
|
||||
└─────────────────────────────────────────────────────────────┘</code></pre>
|
||||
</div>
|
||||
<ul>
|
||||
<li><b>依赖方向单向化</b>:接口层 → 应用层 → 领域层 ← 基础设施层(依赖倒置)。状态机不再持有 <code>PowerManger*</code> 裸指针,改为构造时注入 <code>ISystemState</code>、<code>IDeviceCommand</code>、<code>IFaultSink</code> 三个窄接口。</li>
|
||||
<li><b>线程模型统一</b>:确立"单线程事件循环 + 生产者队列"模型——UDP 监听线程、HTTP 线程只做<b>收包→解析→投递事件队列</b>,所有业务处理(FSM dispatch、故障检测、状态写入)在 MOOS Iterate 所在线程串行执行,从根上消除数据竞争。断路器闭环操作改为异步状态(发送→等待反馈事件→超时事件),消灭 7 s 忙等。</li>
|
||||
<li><b>Driver 拆分</b>:模式设备策略表(领域层 PowerPolicy)与 JSON 键名/设备 ID 映射(基础设施层 ProtocolCodec)分离;Driver 单例化并归属 CommandService,消除双实例不一致。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s4-2">4.2 状态机重构方案</h3>
|
||||
<h4>4.2.1 转移表显式化</h4>
|
||||
<p>将隐藏在两个 switch 中的转移逻辑提取为<b>静态转移表 + guard 函数</b>,作为唯一事实源,并据此生成文档与测试用例:</p>
|
||||
<pre><code>// fsm/TransitionTable.h —— 唯一事实源(示例,迁移时须以现状代码逐条核对生成)
|
||||
struct TransitionRule {
|
||||
StateId from; // ANY 表示任意源状态
|
||||
EventId event;
|
||||
StateId to;
|
||||
GuardFn guard; // nullptr 表示无条件(保持现状语义)
|
||||
const char* comment; // 对应原代码位置,便于审计
|
||||
};
|
||||
static const TransitionRule kRules[] = {
|
||||
{ ANY, EV_WORK_CMD, STANDBY, nullptr, "workCMD=1 Fsm.cpp:2891" },
|
||||
{ ANY, EV_WORK_CMD, LIFTING, nullptr, "workCMD=12 Fsm.cpp:2934(修复B1后生效)" },
|
||||
{ ANY, EV_TASK_START, FAULT, GuardThrowLoad, "THROW_LOAD Fsm.cpp:953" },
|
||||
{ FAULT, EV_FAULT_CLEARED, REMOTE_CTRL, GuardFaultAcked, "新增:故障确认后恢复" },
|
||||
...
|
||||
};</code></pre>
|
||||
<p>guard 第一阶段全部保持现状(无条件),仅把"可迁移性"显式化;第二阶段再与总体组确认后补充工况互斥规则(如 HighSpeed 与 DJMode 互斥需先回 Standby)。</p>
|
||||
|
||||
<h4>4.2.2 FaultState 与故障系统联动</h4>
|
||||
<ol>
|
||||
<li><b>入口</b>:FaultManager 检测到 3 级及以上故障(或抛载任务)→ 投递 <code>FaultEvent</code> → FSM 迁移 FaultState,保持现有"抛载进 FaultState"路径不变。</li>
|
||||
<li><b>entry 动作异步化</b>:断路器断开序列改为 FaultState 内部的子步骤计时状态(step + deadline),每拍 Iterate 推进,取代 MOOSPause(10000) 与 20 s 忙等;超时阈值统一为命名常量并修正注释。</li>
|
||||
<li><b>出口</b>:显式定义恢复路径——故障码集合清空且收到上位机确认指令(workCMD=4 RemoteControl,保持现有指令集不变)→ 退出 FaultState。该路径当前已可经基类 handleWorkCmd 间接实现,重构只是将其显式化并纳入转移表,<b>不改变外部行为</b>。</li>
|
||||
</ol>
|
||||
|
||||
<h4>4.2.3 代码组织</h4>
|
||||
<ul>
|
||||
<li>PowerManagerFsm.cpp 拆分:<code>DeviceCommandHandlers.cpp</code>(10 个设备指令函数,提取公共模板消除重复)、<code>StatusSnapshot.cpp</code>(10 个 getXxxStatus)、<code>FaultEncoding.cpp</code>(故障编码,配合 4.4 迁移到 FaultManager)。</li>
|
||||
<li>14 个状态类删除重复的 <code>react(MasterCommandEvent)</code>,统一由基类按转移表处理;状态类只保留 entry/exit 与状态特有事件。</li>
|
||||
<li>命名修正(handelPowerCmd→handlePowerCmd 等)仅在内部接口进行,MOOS 变量名、JSON 键名等外部标识<b>一律不改</b>。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s4-3">4.3 通信层重构方案</h3>
|
||||
<h4>4.3.1 协议单一事实源</h4>
|
||||
<p>建立 <code>protocol/</code> 目录,以一份定义文件同时生成/校验编解码两侧,杜绝键名失配(B4)类问题:</p>
|
||||
<ul>
|
||||
<li><code>protocol/MoosVariables.def</code>:全部 MOOS 变量名 + JSON schema(键名、类型、取值范围),UpperCommManager 与 Driver 的 JSON 编解码均引用之;增加"未知键/解析失败"统一告警(修复 C3、B4 的静默丢失)。</li>
|
||||
<li><code>protocol/DeviceId.h</code>:设备 ID 唯一枚举,driver.cpp:908-930 的 0-61 硬编码与 DeviceId 枚举合并;UDP 帧长由结构体 sizeof 计算,删除 18/6/8 魔法数。</li>
|
||||
<li>修正 udpComm.cpp:47-56 等价死分支与 default 静默吞帧:未知帧计入统计并日志告警。</li>
|
||||
</ul>
|
||||
<h4>4.3.2 上下行链路管理</h4>
|
||||
<table>
|
||||
<tr><th>链路</th><th>现状</th><th>优化后</th><th>外部行为变化</th></tr>
|
||||
<tr><td>上位机(MOOS)</td><td>无连接管理,1 s 周期广播状态</td><td>启用已声明未使用的 connectState:以"周期广播正常发出 + 收到上位机任何邮件"维护在线标志;掉线仅记录与上报,不触发控制动作</td><td>无(只新增可观测性)</td></tr>
|
||||
<tr><td>下位机(UDP)</td><td>30 s 超时置 isTimeout,超时母线发清零指令</td><td>超时事件接入 FaultManager(通信类故障码,新增码段,不占用现有码值);清零指令逻辑保持不变</td><td>故障上报新增通信超时码(经总体确认后启用)</td></tr>
|
||||
<tr><td>断路器闭环</td><td>事件路径忙等约 7 s + 600 ms 复位</td><td>异步化:发送→注册期望反馈→超时定时器,状态机以子状态跟踪;指令时序与重发次数严格保持现状</td><td>无(时序一致,仅不再阻塞线程)</td></tr>
|
||||
<tr><td>Web 通道</td><td>下位机 8000 全功能 + /fcs/control 越权直改</td><td>下位机 Web 保留页面但控制类接口改为转发 MOOS 指令(等价于上位机下发);上位机 Web 方案不变</td><td>接口路径与参数不变,仅内部实现改道</td></tr>
|
||||
</table>
|
||||
<h4>4.3.3 工程细节修复</h4>
|
||||
<ul>
|
||||
<li>发送路径消除每帧堆分配(栈对象 + sendto);删除每包 cout(LowerCommManager.cpp:381),统一走 loguru 并按级别可关。</li>
|
||||
<li>UDP 监听线程退出改为 socket 超时 + quit 标志,保证进程可干净退出。</li>
|
||||
<li>移除 <code>sendDisSysCommand</code> 废弃函数(含 0x55/0xAA 硬编码),或修正后纳入 ProtocolCodec。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="s4-4">4.4 故障码统一方案</h3>
|
||||
<div class="card">
|
||||
<h4>目标:一套编码、一个存储、一处映射</h4>
|
||||
<p><b>1. 统一故障码定义文件 <code>fault/FaultCodeDef.h</code></b>(生成式,唯一事实源):</p>
|
||||
<pre><code>// 每条故障一行:枚举名, 码值(保持现有取值不变), 等级, 来源域, 中文描述
|
||||
FAULT_DEF(BAT_CELL_OVERVOLT_L1, 0x01, 1, DOMAIN_BATTERY, "单体过压1级")
|
||||
FAULT_DEF(DIS_BREAKER_TRIP_MAIN, 1, 2, DOMAIN_DIS, "主断路器脱扣")
|
||||
FAULT_DEF(SYS_DEPTH_INVALID_L3, 0x1302, 3, DOMAIN_SYSTEM, "深度数据失效")
|
||||
// 0xLSSS 系统码约 200 个字面量全部收编为枚举,码值、等级语义与现状逐条核对一致</code></pre>
|
||||
<p>由此单文件自动生成:C++ 枚举、码值→描述映射表(供上报与前端共用,替代 fuelcell.h:834 的 JS 重写)、测试用的完整性校验数据。电池 0x01-0xC9、配电 1-45、系统 0xLSSS 三段码值全部原样保留,<b>上位机与前端所见故障码不变</b>。</p>
|
||||
<p><b>2. 合并故障存储为 FaultManager 单一集合</b>:</p>
|
||||
<ul>
|
||||
<li>统一存放真实故障与注入故障(注入故障带 source 标记区分),上报接口(uPower_pmState)与等级查询(getFaultLevel)改为读同一集合,消除 F1/F2 的双源不一致;对外 JSON 字段名与格式不变。</li>
|
||||
<li>锁存语义保持现状(自恢复即自动清除),但 FaultManager 内部记录"发生/恢复"事件并写入 SQLite 新增 <code>fault_event(ts, code, level, event, source)</code> 表——<b>新增表不影响现有表结构</b>。</li>
|
||||
<li>故障码访问统一加 <code>faultCodeMutex</code>,消除 TestEvent 无锁写。</li>
|
||||
</ul>
|
||||
<p><b>3. 修复编码层缺陷</b>:删除 systemData.h:776 的 <code>#ifdef DEBUG</code>(发布版也更新燃料电池 4 级故障字)、修正 DEBUFG 拼写、补齐 B6 三个断路器的故障码入参、faultCode.h:35 的 level 字段与描述对齐。</p>
|
||||
</div>
|
||||
|
||||
<h3 id="s4-5">4.5 上位机 / 下位机管理方案</h3>
|
||||
<table>
|
||||
<tr><th>主题</th><th>职责划分(优化后)</th></tr>
|
||||
<tr><td>控制通道</td><td><b>唯一入口</b>:一切控制指令(网页、调试页、测试注入)最终都变为 MOOSDB 上的 <code>uPower_*_cmd</code> JSON,由 CommandService 统一校验、应答、记录。下位机 HTTP 控制接口仅做转发。</td></tr>
|
||||
<tr><td>状态监视</td><td>上位机 HostSim 为主(跨机、带历史库 /api/history);下位机 Web 退化为本机调试页(日志查看、测试注入保留),dashboard/data 等监视页标注"调试用"。</td></tr>
|
||||
<tr><td>指令校验</td><td>CommandService 对 workCMD/powerCMD/modCMD 做范围校验 + 当前状态下合法性校验(按转移表),非法指令回执拒绝原因(新增 JSON 字段,缺省不发送,兼容旧上位机)。</td></tr>
|
||||
<tr><td>指令清空哨兵</td><td>workCMD=99 等魔法哨兵改为命名常量 <code>CMD_NONE=99</code>,取值不变,仅消除魔法数。</td></tr>
|
||||
<tr><td>页面资源</td><td>下位机 7 个硬编码页面迁移到 CMake 资源管线(复用上位机 webassets_gen.h 方案),HTML 独立成文件,便于前端维护;URL 与页面功能不变。</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- ================= 5(新增详细设计章) ================= -->
|
||||
<h2 id="s5">5 设备上电管理与状态机转移重构详细设计</h2>
|
||||
|
||||
<h3>5.1 总体设计判断</h3>
|
||||
<p>设备上电/管理与状态机转移是同一个问题的两面——<b>"声明"与"执行"混在一起</b>。9 张工况设备表(driver.cpp 约 600 行逐字段赋值)本质是"数据",却写成了代码;状态 entry() 里的上电时序本质是"流程",却硬编码成一串串 <code>setDeviceState + MOOSPause + operate_*_Breaker</code>;状态转移本质是"一张表",却隐式埋在 <code>handleWorkCmd</code>/<code>react(TaskStartEvent)</code> 两个 switch 里。重构的共同方向:<b>把数据从代码里剥离出来,把流程交给统一执行器,把转移显式化</b>。</p>
|
||||
|
||||
<h3>5.2 设备上电与管理重构</h3>
|
||||
<h4>5.2.1 现状代码证据</h4>
|
||||
<ul>
|
||||
<li><b>工况表即代码</b>:<code>Driver::getSTANDBYTable()</code>(driver.cpp:151-228)等 9 个函数,每个约 70 行 <code>driverTable->depth1 = 1;</code> 式逐字段赋值,<code>getCRUISE/getHIGH_SPEED/getASCEND_DESCEND</code> 内容几乎一致,且混用裸 0/1 与 <code>DRIVER_*</code> 宏。</li>
|
||||
<li><b>上电时序硬编码且阻塞</b>:<code>CruiseMode::entry()</code> 的典型模式是:查当前设备状态 → <code>operate_HVA_actuatorCircuit_Breaker(0)</code>(内部忙等最长 6 s)→ <code>MOOSPause(3)</code> → 连续 6 次 <code>setDeviceState</code> → 上报,每个状态 entry 都是这段逻辑的变体手写。</li>
|
||||
<li><b>断路器闭环时序散落</b>:<code>operateBreakerGeneric</code>(LowerCommManager.cpp:843-890)的"首次发包 → 100 ms 等待 → 轮询反馈 → 200 ms 重发 → 6 s 超时 → 3×200 ms 复位"被 35 个 <code>operate_*</code> 函数复用,但在事件处理路径上同步执行,阻塞 MOOS 线程。</li>
|
||||
<li><b>校验逻辑十份复制</b>:<code>WaterBasedReady::react(DevCmdExt)</code> 中的校验链(Debug 模式豁免 → 深度上电保护 → 前视声纳先关机再断电并 MOOSPause(10000) → 写设备状态)在 10 个 <code>handleXxxCmd</code> 中各有变体,细微偏差正是 B4(桅杆键名失配)类缺陷的温床。</li>
|
||||
</ul>
|
||||
|
||||
<h4>5.2.2 目标结构</h4>
|
||||
<div class="card">
|
||||
<pre><code>现状(分散) 目标(收敛)
|
||||
┌──────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ 9 张工况表 = 600 行赋值函数 │ │ ① 设备描述表 DeviceDef │
|
||||
│ entry() 硬编码时序+忙等6~20s │ 收敛 │ 68 设备 × 属性(域/断路器通道/ │
|
||||
│ 10 个 handleXxxCmd 校验复制 │ ───────────► │ 保护规则/9 工况默认值) │
|
||||
│ 35 个 operate_*_Breaker │ │ ② 功率时序执行器 PowerSequencer │
|
||||
└──────────────────────────────┘ │ 声明式步骤,异步分步执行 │
|
||||
│ ③ 统一指令管线:模式校验→深度 │
|
||||
│ 保护→前置动作→执行→回执 │
|
||||
│ ④ DeviceManager 唯一写口 │
|
||||
│ m_subDisSysCmd 只经它修改 │
|
||||
└────────────────────────────────┘</code></pre>
|
||||
</div>
|
||||
|
||||
<h4>5.2.3 重构四件事</h4>
|
||||
<ol>
|
||||
<li><b>工况表从函数变成数据。</b>9 个 <code>getXxxTable()</code> 函数替换为静态二维表 <code>kModeDeviceTable[9][68]</code>(或按设备分组的声明式定义),查表替代函数调用。迁移时用程序把现有 9 个函数的输出快照为 golden data,重构后逐字节比对——这是"特性严格一致"最直接的保证。</li>
|
||||
<li><b>上电时序从阻塞硬编码变成声明式步骤 + 异步执行器。</b>每个工况声明一个步骤序列,例如 CruiseMode:<code>[条件:任一bowCover≠0] → 断启闭机构断路器(超时6s) → 延时3s → 批量下电bowCover1~6 → 上报</code>。PowerSequencer 每拍 Iterate 推进一步,<code>operateBreakerGeneric</code> 的时序参数(100 ms 首等、200 ms 重发、6 s 超时、3×200 ms 复位)原样保留为步骤属性,但不再阻塞线程;entry() 只提交序列即返回,状态机始终可响应事件。</li>
|
||||
<li><b>设备指令校验收敛为一条管线。</b>所有设备指令走同一链路:模式校验(Normal 禁操作)→ 域保护(水下设备深度检查,Debug 豁免)→ 设备专属前置动作(forwardSonar 先关机再断电等,注册在设备描述表)→ 经 Sequencer 执行 → 回执。各状态只声明"本状态允许操作哪些设备域",不再各写一份校验。</li>
|
||||
<li><b>设备状态唯一写口。</b><code>m_subDisSysCmd</code> 现在被 FSM、HTTP 回调、测试注入直接修改,重构后只能经 DeviceManager 写入;外部可观测的写序列(日志、上报、SQLite 落库)保持一致。</li>
|
||||
</ol>
|
||||
|
||||
<h3>5.3 状态机转移重构</h3>
|
||||
<h4>5.3.1 核心问题:转移关系隐式化</h4>
|
||||
<p>转移关系不存在于任何一张表中,而是从两个 switch 的行为里"涌现"出来的——这正是 case 12 漏写 <code>transit</code> 导致 Lifting 不可达(B1)这类缺陷的结构性根源。重构目标是把转移机制从"隐式 switch"变为"显式表驱动":</p>
|
||||
<div class="card">
|
||||
<pre><code>现状:隐式转移 目标:显式转移
|
||||
MasterCommandEvent 到达 事件统一规范化
|
||||
│ │
|
||||
handleWorkCmd 巨型 switch 查转移表 TransitionRule[]
|
||||
(14 状态 × 14 份复制 react) (源状态×事件→目标,唯一事实源)
|
||||
│ │
|
||||
无 guard 直接 transit guard 校验
|
||||
(case 12 漏写 = Lifting 死状态) (一期空 guard 记日志,二期补互斥)
|
||||
│ │
|
||||
entry() 阻塞上电 CHANING 过渡子状态
|
||||
(期间 FSM 不响应任何事件) (上电序列异步执行,完成即落定)
|
||||
│ │
|
||||
FaultState 孤岛 FaultEvent 显式迁移
|
||||
(故障检测不触发迁移,无出口) (3 级以上故障进入,确认后退出)</code></pre>
|
||||
</div>
|
||||
|
||||
<h4>5.3.2 重构五步走</h4>
|
||||
<ol>
|
||||
<li><b>把转移关系从代码里"挖"出来变成表。</b>逐行核对 <code>handleWorkCmd</code>(workCMD 1~12)、<code>react(TaskStartEvent)</code>(THROW_LOAD/FLOAT_UP/RECYCLE/SAIL/HOVER/TOUR 等)、<code>react(TaskStopEvent)</code> 与 InitState 自迁移,生成 <code>TransitionRule[]</code> 静态表,每行标注原代码位置(如 <code>workCMD=5 → CruiseMode, Fsm.cpp:2903</code>)。该表同时是实现、文档与测试基准。<code>handleWorkCmd</code> 的 switch 删除,14 个状态中复制粘贴的 <code>react(MasterCommandEvent)</code> 全部删除,统一由基类查表处理;workCMD=99 清空哨兵语义保留,仅改为命名常量。</li>
|
||||
<li><b>guard 分两期落地。</b>一期所有 guard 为 nullptr(无条件),<b>严格保持现状转移语义</b>——这是特性一致的红线;基类在每次迁移时记录"源状态→事件→目标"日志。二期依据日志与总体组确认应禁止的迁移(如巡航中直接切 DJMode),再逐步补 guard。重构本身不引入行为变化,行为收紧是另一个独立、可评审的决策。</li>
|
||||
<li><b>entry() 阻塞上电改为 CHANING 过渡子状态。</b>现状代码已有雏形——每个 entry 开头 <code>workCondition = CHANING</code>、干完活才置为目标工况,只是"干活"方式是阻塞的。重构将其正式化:迁移发生时进入目标状态的过渡形态,上电序列交 PowerSequencer 异步执行,序列完成事件使状态落定;期间 FSM 照常响应事件(如 FaultEvent 可打断上电)。上位机看到的 workCondition 变化序列与现状完全一致。</li>
|
||||
<li><b>FaultState 接入主转移图。</b>故障检测发现 3 级以上故障 → FaultManager 派发 FaultEvent → 转移表新增 <code>ANY × FaultEvent → FaultState</code> 规则(保留现有 THROW_LOAD 入口不变);FaultState 增加显式出口:故障码集合清空 + 上位机确认(复用现有 workCMD=4 指令,不新增协议)→ RemoteControl;FaultState 内的断路器断开序列同样改为 Sequencer 步骤,消灭 20 s 忙等。</li>
|
||||
<li><b>状态类只做"差异"。</b>状态类只保留本状态特有内容:entry 提交的上电序列、特有事件处理(如 DJMode 专属指令)、exit 清理;公共行为上收基类。CruiseMode/HighSpeedMode 等任务态的共性(均响应 TaskStopEvent→RemoteControl)通过继承 MissionStateBase 表达,利用 tinyfsm 原生支持的状态继承。</li>
|
||||
</ol>
|
||||
|
||||
<h3>5.4 两块重构的衔接与线程模型</h3>
|
||||
<div class="note">
|
||||
衔接点在 CHANING 子状态:<b>状态机管"去哪里",PowerSequencer 管"到了之后按什么时序上电",DeviceManager 管"每一笔设备状态怎么写"</b>。三者通过事件队列串行协作,全部运行在 MOOS 主线程上,从根上消除现有四个线程交叉读写全局数据的问题。UDP 监听线程与 HTTP 线程退化为纯生产者:收包→解析→投递事件队列,不再直接触碰业务状态。
|
||||
</div>
|
||||
|
||||
<h3>5.5 迁移顺序与一致性验证</h3>
|
||||
<ol>
|
||||
<li><b>快照基线</b>:采集 9 张工况表输出、标准指令脚本下的状态上报时序,作为 golden data;</li>
|
||||
<li><b>设备描述表与指令管线</b>(风险最低,先行);</li>
|
||||
<li><b>PowerSequencer 替换阻塞上电</b>;</li>
|
||||
<li><b>转移表替换 switch</b>。</li>
|
||||
</ol>
|
||||
<p>每一步均以 golden data diff 为空为通过条件,diff 非空则不进入下一步。B1(Lifting 不可达)等已确认缺陷在挖掘转移表时即会暴露,按第 6 章约定单独登记为行为变更点修复。</p>
|
||||
|
||||
<!-- ================= 6 ================= -->
|
||||
<h2 id="s6">6 特性一致性保障策略</h2>
|
||||
<p>"优化后与原代码特性严格一致"需要通过可执行的基线来保证,而不是靠评审印象。建议按以下四层契约冻结外部行为:</p>
|
||||
<table>
|
||||
<tr><th>契约层</th><th>冻结内容</th><th>验证手段</th></tr>
|
||||
<tr><td>MOOS 接口契约</td><td>全部订阅/发布变量名、JSON 键名与类型、周期广播频率(1 s)、应答时序</td><td>契约测试:录制现有系统真实 MOOS 流量为"黄金报文集",重构后回放比对(键集合、类型、取值范围)</td></tr>
|
||||
<tr><td>UDP 帧契约</td><td>6 类反馈帧 ID、帧头 0x40 0x40、#pragma pack(1) 布局、校验算法、帧长</td><td>字节级编解码往返测试(现有 udp_feeder/full_feeder 扩展为参数化用例);结构体 static_assert 尺寸断言</td></tr>
|
||||
<tr><td>行为契约</td><td>状态迁移外部可观测序列(指令→状态上报序列)、断路器操作时序(重发间隔、6 s 超时、600 ms 复位)、故障码取值与上报内容</td><td>黄金主测试:对现状系统跑标准指令脚本,录制 <code>uPower_currentState_st</code> 等上报时序为基线;重构后同脚本回放,时序逐拍比对</td></tr>
|
||||
<tr><td>Web/存储契约</td><td>URL 路由、REST 参数、SQLite 现有表结构</td><td>HTTP 接口冒烟用例;数据库 schema 比对脚本</td></tr>
|
||||
</table>
|
||||
<div class="note">
|
||||
<b>已知缺陷的处理约定</b>:3.1 节 B1-B10 属"现状特性"的一部分还是"应修复的缺陷",须逐项与总体确认。建议原则:外部可观测且显然错误的行为(如 buoyage 恒不更新、Lifting 不可达、桅杆指令丢失)按缺陷修复并在报告中显式登记"行为变更点";纯内部问题(重复调用、死代码)直接修复,行为不变。所有行为变更点单独列入变更清单,重构完成时逐项回归确认。
|
||||
</div>
|
||||
|
||||
<!-- ================= 7 ================= -->
|
||||
<h2 id="s7">7 测试方案</h2>
|
||||
<h3>7.1 测试金字塔与补齐重点</h3>
|
||||
<table>
|
||||
<tr><th>层级</th><th>现状</th><th>补齐内容</th><th>框架/工具</th></tr>
|
||||
<tr><td>单元测试</td><td>GTest 56 例:Driver 表、SystemData、故障码表、upmsg 往返、UDP 校验和、pack 布局</td><td>① 状态机迁移测试:转移表全覆盖(每个规则至少 1 例)+ guard 边界;② FaultManager:检测→锁存→上报→清除全链路,含 B6/B7 回归;③ ProtocolCodec:JSON 键名完整性(防 B4 复发)、帧编解码参数化</td><td>GTest;领域层零 MOOS 依赖后可脱离 MOOSDB 运行</td></tr>
|
||||
<tr><td>集成测试</td><td>udp_feeder/full_feeder 字节级仿真 CCU+4 路配电 + check_db.py 验证落库</td><td>① 故障注入 feeder:丢包、坏校验和、越界值、故障位置位、30 s 静默(超时路径);② MOOS 端到端:上位机指令→状态迁移→上报序列黄金比对(第 6 章基线);③ 断路器闭环时序仿真</td><td>Python 脚本 + 现有 feeder 扩展;MOOSDB 冒烟沿用</td></tr>
|
||||
<tr><td>系统/回归</td><td>CI 有构建+冒烟,但集成失败不阻塞(build-test.sh:181),已知 std::bad_alloc 被容忍</td><td>① 集成失败纳入 ALL_OK 阻塞;② cppcheck/clang-tidy 接入 CI(error 级清零);③ 测试二进制输出改到 build 目录,清理 test/ 下 in-source 构建残留</td><td>Gitea Actions 现有流水线扩展</td></tr>
|
||||
<tr><td>上位机测试</td><td>零</td><td>HostSim 指令队列 drain、FeedbackStore 落盘/24 h 清理、/api/history 时序提取、WS 快照广播</td><td>GTest(队列/存储)+ Python(HTTP/WS 接口)</td></tr>
|
||||
</table>
|
||||
<h3>7.2 关键专项测试</h3>
|
||||
<ul>
|
||||
<li><b>状态机迁移矩阵</b>:以 4.2.1 转移表为基准自动生成用例骨架——14 状态 × 11 事件全组合标注"允许/拒绝/保持",重构前后两轮运行,输出 diff 必须为空(除已登记的行为变更点)。</li>
|
||||
<li><b>故障注入矩阵</b>:覆盖三级来源——UDP 层(坏帧/丢帧/超时)、设备层(断路器脱扣、绝缘低、BMS 各级故障位)、系统层(深度失效、功率不足等 0xLSSS 码);断言故障码上报内容、FaultState 迁移、SQLite fault_event 落库三处一致。</li>
|
||||
<li><b>并发压力</b>:UDP 监听线程 100 Hz 灌包 + MOOS 指令并发注入,ThreadSanitizer 构建跑 10 min,数据竞争报告清零;队列上限 100 的溢出行为固定为"丢最旧 + 计数告警"并测试。</li>
|
||||
<li><b>长稳</b>:feeder 连续 24 h 正常流量 + 周期故障注入,监控内存(消除每帧 new/delete 后应无增长)、句柄、落库行数。</li>
|
||||
</ul>
|
||||
<h3>7.3 验收标准</h3>
|
||||
<ol>
|
||||
<li>黄金报文比对:MOOS/UDP 契约测试 100% 通过,diff 为空;</li>
|
||||
<li>状态迁移矩阵:除已登记变更点(B1 修复后 Lifting 可达等)外零差异;</li>
|
||||
<li>新增单测覆盖率:fsm/、FaultManager、ProtocolCodec 行覆盖 ≥ 80%;</li>
|
||||
<li>cppcheck error 级清零,TSan 报告清零;</li>
|
||||
<li>CI 全链路(构建→单测→集成→黄金比对)绿灯且失败阻塞。</li>
|
||||
</ol>
|
||||
|
||||
<!-- ================= 8 ================= -->
|
||||
<h2 id="s8">8 实施路线图</h2>
|
||||
|
||||
<div class="phase">
|
||||
<h4>阶段 0:缺陷修复与测试基线(先行,约 2 周)</h4>
|
||||
<p class="small">最小改动修复 B1-B14 并逐项登记行为变更;开启 <code>-Wall -Wextra</code> 清零告警、接入 sanitizer 构建目标与 cppcheck(error 级阻塞);配置项端口校验;搭建黄金报文基线与状态迁移矩阵录制工具。<b>产出:行为基线库 + 变更清单 + 可运行的安全网。</b></p>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<h4>阶段 1:故障码与数据层统一(约 2-3 周)</h4>
|
||||
<p class="small">落地 FaultCodeDef.h 生成式定义;合并双故障存储为 FaultManager;新增 fault_event 持久化表;修复条件编译与死码。该阶段相对独立、风险最低,先行可立即改善可维护性。<b>产出:统一故障子系统 + 故障注入测试矩阵。</b></p>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<h4>阶段 2:通信层与线程模型(约 3-4 周)</h4>
|
||||
<p class="small">建立 protocol/ 单一事实源;UDP 监听线程改为纯生产者;断路器闭环异步化;连接状态管理启用;下位机 HTTP 控制接口改道 MOOS。<b>产出:单线程事件循环模型 + 契约测试全绿。</b></p>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<h4>阶段 3:状态机重构(约 3-4 周)</h4>
|
||||
<p class="small">转移表显式化;PowerManagerFsm 上帝类拆分;状态类去重;FaultState 出入路径显式化;entry 阻塞消除。每步以迁移矩阵 diff 为空为准入。<b>产出:表驱动状态机 + 迁移矩阵零差异报告。</b></p>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<h4>阶段 4:上位机与收尾(约 2 周)</h4>
|
||||
<p class="small">上位机测试补齐;下位机页面资源管线迁移;长稳与并发压力测试;文档更新(转移表自动生成状态图)。<b>产出:完整测试体系 + 验收报告。</b></p>
|
||||
</div>
|
||||
<div class="warn">
|
||||
<b>风险控制</b>:每阶段结束必须 CI 全绿(含黄金比对)方可合并;协议/故障码/状态序列的任何外部可见变更必须已在变更清单登记并经确认;保持 pCCU、pMotor、pELoad 等周边进程接口不动,重构范围严格限定在 pPowerManger 与 pPowerMangerHost。
|
||||
</div>
|
||||
|
||||
<!-- ================= 9 ================= -->
|
||||
<h2 id="s9">9 附录:问题清单汇总</h2>
|
||||
<table>
|
||||
<tr><th>编号</th><th>级别</th><th>类别</th><th>问题摘要</th><th>位置</th><th>处置</th></tr>
|
||||
<tr><td>B1</td><td><span class="badge p0">P0</span></td><td>Bug</td><td>Lifting 状态不可达(case 12 无 transit/break)</td><td class="loc">PowerManagerFsm.cpp:2934</td><td>阶段0修复(行为变更登记)</td></tr>
|
||||
<tr><td>B2</td><td><span class="badge p0">P0</span></td><td>Bug</td><td>s.buoyage == 赋值误写为比较 ×4</td><td class="loc">PowerManagerFsm.cpp:1471-1486</td><td>阶段0修复(行为变更登记)</td></tr>
|
||||
<tr><td>B3</td><td><span class="badge p0">P0</span></td><td>Bug</td><td>邮件偏斜 return 导致整批丢弃</td><td class="loc">PowerManger.cpp:121-125</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B4</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>桅杆升降舵 JSON 键名失配,指令静默丢失</td><td class="loc">driver.cpp:844/924</td><td>阶段0修复(行为变更登记)</td></tr>
|
||||
<tr><td>B5</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>故障检测函数重复调用两遍</td><td class="loc">PowerManagerFsm.cpp:52-55</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B6</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>3 个断路器故障码死码</td><td class="loc">systemData.h:1026-1028</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B7</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>故障注入数组越界 + systemData.h:524 越界</td><td class="loc">PowerManagerFsm.cpp:3053</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B8</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>TestEvent switch 缺 break;绕过事件机制</td><td class="loc">PowerManagerFsm.cpp:3084</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B9</td><td><span class="badge p2">P2</span></td><td>Bug</td><td>协议结构体未初始化返回 / memset 非 POD</td><td class="loc">upmsg/ 多处</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B10</td><td><span class="badge p2">P2</span></td><td>规范</td><td>超时常量与注释不符(FaultState 20s vs 其余 5s),复制 7 份</td><td class="loc">FaultState.cpp:46/53 等</td><td>阶段3统一</td></tr>
|
||||
<tr><td>B11</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>模板残留致每条正常指令误报 "Unhandled Mail" run warning</td><td class="loc">PowerManger.cpp:142-146</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B12</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>畸形 JSON 类型致 jsoncpp 抛异常(asUInt 无类型检查、无 catch)</td><td class="loc">driver.cpp:935 / UpperCommManager.cpp:48</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B13</td><td><span class="badge p1">P1</span></td><td>Bug</td><td>HTTP 服务在配置/DB 就绪前于构造函数启动,提前暴露控制面</td><td class="loc">PowerManger.cpp:83-87</td><td>阶段0修复</td></tr>
|
||||
<tr><td>B14</td><td><span class="badge p2">P2</span></td><td>规范</td><td>FsmLoop/_FsmCB、m_deviceCmdQuenue 无消费者;buildReport 占位;SKEW_TOLERANCE 宏未用</td><td class="loc">PowerManger.h:48/121 等</td><td>阶段3清理</td></tr>
|
||||
<tr><td>A1</td><td><span class="badge p0">P0</span></td><td>架构</td><td>状态机退化为路由壳,转移逻辑集中在基类 switch</td><td class="loc">fsm/ 全局</td><td>阶段3重构</td></tr>
|
||||
<tr><td>A2</td><td><span class="badge p0">P0</span></td><td>架构</td><td>故障检测与 FaultState 脱钩;FaultState 无出口</td><td class="loc">PowerManagerFsm.cpp:948-953</td><td>阶段1+3</td></tr>
|
||||
<tr><td>A3</td><td><span class="badge p1">P1</span></td><td>架构</td><td>entry() 内 MOOSPause/忙等最长 10-20 s</td><td class="loc">WaterBasedReady.cpp:50 等</td><td>阶段3异步化</td></tr>
|
||||
<tr><td>A4</td><td><span class="badge p1">P1</span></td><td>架构</td><td>3096 行上帝类;static pm 裸指针全局穿透</td><td class="loc">PowerManagerFsm.hpp:86</td><td>阶段3拆分</td></tr>
|
||||
<tr><td>A5-A7</td><td><span class="badge p2">P2</span></td><td>规范</td><td>语义错位/拼写错误/死代码/日志混乱</td><td class="loc">多处</td><td>阶段3清理</td></tr>
|
||||
<tr><td>C1</td><td><span class="badge p1">P1</span></td><td>通信</td><td>上位机无连接管理,connectState 死字段</td><td class="loc">LowerCommManager.h:131</td><td>阶段2启用</td></tr>
|
||||
<tr><td>C2</td><td><span class="badge p1">P1</span></td><td>通信</td><td>协议知识三处散养;Driver 双实例</td><td class="loc">driver.cpp / UpperCommManager.h:93</td><td>阶段2收敛</td></tr>
|
||||
<tr><td>C3-C6</td><td><span class="badge p1">P1</span></td><td>通信</td><td>错误码虚设/死分支/硬编码帧长/废弃函数错误数据</td><td class="loc">udpComm.cpp / driver.cpp</td><td>阶段2修复</td></tr>
|
||||
<tr><td>C5</td><td><span class="badge p2">P2</span></td><td>性能</td><td>每帧堆分配、每包 cout</td><td class="loc">udpComm.cpp:462 等</td><td>阶段2优化</td></tr>
|
||||
<tr><td>C7</td><td><span class="badge p1">P1</span></td><td>架构</td><td>双 Web 重复建设;/fcs/control 越权直改状态</td><td class="loc">httpserver.cpp:150</td><td>阶段2+4</td></tr>
|
||||
<tr><td>F1</td><td><span class="badge p0">P0</span></td><td>故障</td><td>双重故障存储,等级查询与上报读不同集合</td><td class="loc">PowerManger.cpp:448 / uPower_pmState.h:50</td><td>阶段1合并</td></tr>
|
||||
<tr><td>F2</td><td><span class="badge p1">P1</span></td><td>故障</td><td>上报死赋值</td><td class="loc">UpperCommManager.cpp:240</td><td>阶段1清理</td></tr>
|
||||
<tr><td>F3</td><td><span class="badge p1">P1</span></td><td>故障</td><td>三套编码并存;系统码无文本映射;前端 JS 重写映射</td><td class="loc">faultCode.h / fuelcell.h:834</td><td>阶段1统一</td></tr>
|
||||
<tr><td>F4</td><td><span class="badge p1">P1</span></td><td>故障</td><td>DEBUG 宏全构建未定义,4 级故障字恒为 0;DEBUFG 拼写;_DEBUG 死代码</td><td class="loc">systemData.h:7,776 等</td><td>阶段0修复</td></tr>
|
||||
<tr><td>F5</td><td><span class="badge p2">P2</span></td><td>故障</td><td>无故障事件持久化表</td><td class="loc">SQLite 层</td><td>阶段1新增</td></tr>
|
||||
<tr><td>D1-D4</td><td><span class="badge p1">P1</span></td><td>并发</td><td>全局数据池/锁纪律不一致/abs 截断/并发清队列</td><td class="loc">systemData.h 等</td><td>阶段2统一线程模型</td></tr>
|
||||
<tr><td>H1-H3</td><td><span class="badge p2">P2</span></td><td>上位机</td><td>功能重叠/越权通道/页面硬编码</td><td class="loc">HostSim / httpserver</td><td>阶段4</td></tr>
|
||||
<tr><td>E1</td><td><span class="badge p1">P1</span></td><td>构建</td><td>-Wall 未启用且写法错误;无 sanitizer;测试输出残留</td><td class="loc">CMakeLists.txt:80-86</td><td>阶段0接入</td></tr>
|
||||
<tr><td>E2</td><td><span class="badge p2">P2</span></td><td>构建</td><td>GLOB 反模式;第三方库无版本/许可证记录;CMAKE 版本过老</td><td class="loc">CMakeLists.txt:9,49,53</td><td>阶段0接入</td></tr>
|
||||
<tr><td>E3</td><td><span class="badge p1">P1</span></td><td>配置</td><td>ccuport/iport atoi 无校验、无范围检查</td><td class="loc">PowerManger.cpp:229/233</td><td>阶段0修复</td></tr>
|
||||
<tr><td>E4</td><td><span class="badge p2">P2</span></td><td>性能</td><td>SQLite insertGeneric/onFrame 高频重复 prepare</td><td class="loc">SQLite.cpp:610-625</td><td>阶段1优化</td></tr>
|
||||
<tr><td>E5</td><td><span class="badge p2">P2</span></td><td>规范</td><td>日志/错误输出三套并存(loguru/cerr/cout)</td><td class="loc">driver.cpp / LowerCommManager.cpp</td><td>阶段3统一</td></tr>
|
||||
<tr><td>T1</td><td><span class="badge p0">P0</span></td><td>测试</td><td>状态机/故障注入/上位机三大测试空白</td><td class="loc">test/</td><td>阶段0起持续</td></tr>
|
||||
<tr><td>T2</td><td><span class="badge p1">P1</span></td><td>CI</td><td>集成失败不阻塞;cppcheck 未接入;in-source 构建残留</td><td class="loc">ci/build-test.sh:181</td><td>阶段0</td></tr>
|
||||
</table>
|
||||
|
||||
<p class="small" style="margin-top:40px;border-top:1px solid var(--border);padding-top:16px;">
|
||||
本报告基于 2026-09-03 工作区代码静态审查生成,所有问题均标注文件与行号,关键缺陷(B1、B2、B3、B5)已经人工逐行复核。报告未对任何源代码做修改。
|
||||
</p>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+109
-7
@@ -1,7 +1,8 @@
|
||||
// MOOS file
|
||||
// 板卡 (RK3588 / Ubuntu 20.04) 专用 mission 配置
|
||||
// 四个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU)共用本文件。
|
||||
// 由 scripts/deploy.sh 推送到板卡 /root/work/h100/missions/h100.moos
|
||||
// 七个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU /
|
||||
// pCanBridge / pMotor / pELoad)共用本文件。
|
||||
// 由 scripts/build-board.sh 同步到板卡 /root/work/h100/missions/h100.moos
|
||||
|
||||
ServerHost = localhost
|
||||
ServerPort = 9000
|
||||
@@ -12,13 +13,17 @@ ProcessConfig = pPowerManger
|
||||
AppTick = 4
|
||||
CommsTick = 4
|
||||
log_level = INFO
|
||||
// 日志文件路径(目录需已存在,由 deploy.sh 创建)
|
||||
// 日志文件路径(目录需已存在,由 build-board.sh 部署时创建)
|
||||
logpath = /root/work/h100/data/pPowerManger.log
|
||||
// 数据库存储路径(目录需已存在,由 deploy.sh 创建)
|
||||
// 数据库存储路径(目录需已存在,由 build-board.sh 部署时创建)
|
||||
dbpath = /root/work/h100/data/power_data.db
|
||||
// CCU 地址(pPowerManger -> pCCU 的 UDP 链路);不配置则默认 127.0.0.1:7000
|
||||
ccuhost = 127.0.0.1
|
||||
ccuport = 7000
|
||||
// 本地输入端口(接收 CCU 数据);默认 5001。
|
||||
// 5001 让给 pCCU 的 rcu 链路接收真实CCU(192.168.0.140) 固定上报的
|
||||
// 配电反馈,故本进程接收端口改为 5002(pCCU 转发的反馈与状态报文发往此处)
|
||||
iport = 5002
|
||||
}
|
||||
|
||||
ProcessConfig = pPowerMangerHost
|
||||
@@ -45,15 +50,112 @@ ProcessConfig = pCCU
|
||||
fc_remote_port = 7000
|
||||
|
||||
// PM 链路(监听 pPowerManger 指令;向 pPowerManger 周期发送 PM 状态)
|
||||
// pPowerManger 与 pCCU 同机部署,pPowerManger 绑定 0.0.0.0:5001,
|
||||
// 故 PM 状态发送目标用回环地址即可送达。
|
||||
// pPowerManger 与 pCCU 同机部署;pPowerManger 接收端口为 5002(iport),
|
||||
// 故 PM 状态发送目标用 127.0.0.1:5002。
|
||||
pm_local_port = 7000
|
||||
pm_remote_ip = 127.0.0.1
|
||||
pm_remote_port = 5001
|
||||
pm_remote_port = 5002
|
||||
|
||||
//======== 真实CCU 桥接链路(配电协议) ========
|
||||
// 真实CCU(192.168.0.140) 设备侧固定上报到 192.168.0.223:5001
|
||||
// (与 pPowerManger 默认 CCU 配置一致:CCUPORT=7000 为其监听口,
|
||||
// 上报目标为控制主机 5001),设备侧不可修改。
|
||||
// 故 pCCU 的 rcu 链路直接绑定 5001 接收其反馈:
|
||||
// 配电反馈 0x0005~0x0008:原帧转发 pPowerManger(127.0.0.1:5002) + 网页显示
|
||||
// 配电指令 0x0001~0x0004:转发目标为真实CCU 监听口 192.168.0.140:7000
|
||||
// 其余帧(旧协议 0x20 0x20 状态帧、无效帧)落库忽略
|
||||
rcu_enable = true
|
||||
rcu_local_port = 5001
|
||||
rcu_remote_ip = 192.168.0.140
|
||||
rcu_remote_port = 7000
|
||||
|
||||
//======== 锂电池 ========
|
||||
// CAN 总线 BMS 协议(docs/BMS_协议字段定义.xlsx),
|
||||
// 经 pCanBridge 发布的 CAN_0x* 消息透传,无需额外配置。
|
||||
dbpath = /root/work/h100/data/pccu_data.db
|
||||
logpath = /root/work/h100/data/pCCU.log
|
||||
|
||||
web_port = 8080
|
||||
web_enable = true
|
||||
}
|
||||
|
||||
ProcessConfig = pCanBridge
|
||||
{
|
||||
AppTick = 4
|
||||
CommsTick = 4
|
||||
|
||||
// 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 帧 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
|
||||
}
|
||||
|
||||
ProcessConfig = pELoad
|
||||
{
|
||||
AppTick = 10
|
||||
CommsTick = 4
|
||||
|
||||
//======== 电子负载(IT6000C)以太网地址 ========
|
||||
// 仪器 LAN 配置的 IP 与 Raw Socket 端口号(出厂默认 30000)
|
||||
eload_ip = 192.168.200.100
|
||||
eload_port = 30000
|
||||
|
||||
//======== 功率控制 ========
|
||||
// MOOS 功率消息变量名(实时电机功率,W,正值=负载吸收)
|
||||
power_var = MOTOR_POWER
|
||||
ctrl_period_ms = 100
|
||||
power_stale_sec = 3
|
||||
current_sign = negative
|
||||
nominal_volt = 500
|
||||
max_power = 0
|
||||
max_current = 0
|
||||
slew_limit_wps = 0
|
||||
func_priority = cc
|
||||
|
||||
// CV 优先时的目标电压(V),func_priority=cv 时生效(恒压吸收)
|
||||
volt_set = 0
|
||||
|
||||
//======== 数据存储 ========
|
||||
dbpath = /root/work/h100/data/pELoad.db
|
||||
log_keep_hours = 72
|
||||
meas_log_period_ms = 100
|
||||
|
||||
// 测量命令来源:fetch=FETCh(读设备表头缓存,不触发测量,响应快,推荐);
|
||||
// meas=MEAS(每次触发设备重新测量,响应慢,易超时)
|
||||
meas_source = fetch
|
||||
|
||||
//======== 网页(避开 8080/8090/18080/18081)========
|
||||
web_port = 18082
|
||||
web_enable = true
|
||||
|
||||
logpath = /root/work/h100/data/pELoad.log
|
||||
}
|
||||
|
||||
@@ -5,4 +5,6 @@ ProcessConfig = pPowerManger
|
||||
// CCU 地址;不配置则默认 127.0.0.1:7000
|
||||
// ccuhost = 127.0.0.1
|
||||
// ccuport = 7000
|
||||
// 本地输入端口(接收 CCU 数据);不配置则默认 5001
|
||||
// iport = 5001
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
#=======================================================================
|
||||
# FILE: scripts/build-arm64-image.sh
|
||||
# DESC: 一次性构建 arm64 CI 镜像(MOOS-IvP + jsoncpp + GTest)。
|
||||
# 构建产物缓存后,日常交叉编译只需 ./scripts/build-arm64.sh。
|
||||
#
|
||||
# 用法:
|
||||
# CI_TOKEN=xxx ./scripts/build-arm64-image.sh # 构建(已存在则跳过)
|
||||
# CI_TOKEN=xxx ./scripts/build-arm64-image.sh --force # 强制重新构建
|
||||
#
|
||||
# 环境变量:
|
||||
# CI_TOKEN 内网 Gitea token(拉取 moos-ivp / googletest 镜像,兼容 GITEA_TOKEN)
|
||||
#=======================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
|
||||
|
||||
IMAGE="h100-power-manager-ci:arm64"
|
||||
GITEA_TOKEN="${CI_TOKEN:-${GITEA_TOKEN:-}}"
|
||||
FORCE=0
|
||||
[ "${1:-}" = "--force" ] && FORCE=1
|
||||
|
||||
if [ -z "${GITEA_TOKEN}" ]; then
|
||||
echo "错误: 缺少内网 Gitea token。" >&2
|
||||
echo " 请设置: CI_TOKEN=xxx ./scripts/build-arm64-image.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "错误: 未找到 docker" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "错误: 无法连接 docker daemon(是否需要用 sudo?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker buildx version >/dev/null 2>&1; then
|
||||
echo "错误: 未找到 buildx。请安装: sudo apt-get install -y docker-buildx" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${FORCE}" = "0" ] && docker image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||
echo ">>> 已存在镜像 ${IMAGE},跳过构建(如需重建: ${0} --force)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "${FORCE}" = "1" ]; then
|
||||
echo ">>> --force: 重新构建镜像 ${IMAGE}"
|
||||
fi
|
||||
|
||||
CI_TOKEN="${GITEA_TOKEN}" docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--load \
|
||||
--secret id=gitea_token,env=CI_TOKEN \
|
||||
-t "${IMAGE}" -f "${PROJECT_ROOT}/ci/Dockerfile" "${PROJECT_ROOT}"
|
||||
|
||||
echo ">>> 镜像构建完成: ${IMAGE}"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
#=======================================================================
|
||||
# FILE: scripts/build-arm64.sh
|
||||
# DESC: 交叉编译为 RK3588 (aarch64) / Ubuntu 22.04 二进制,用法同 build.sh。
|
||||
# 在 arm64 镜像(h100-power-manager-ci:arm64)内跑 cmake + make,
|
||||
# 产物输出到 bin/arm64/,与本地 x86_64 的 bin/、build/ 互不影响。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/build-arm64.sh
|
||||
#
|
||||
# 前置(一次性,构建 arm64 镜像):
|
||||
# CI_TOKEN=xxx ./scripts/build-arm64-image.sh
|
||||
#=======================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
|
||||
|
||||
IMAGE="h100-power-manager-ci:arm64"
|
||||
JOBS="${JOBS:-$(nproc)}"
|
||||
|
||||
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||
echo "错误: 本地不存在镜像 ${IMAGE}" >&2
|
||||
echo " 请先执行: CI_TOKEN=xxx ./scripts/build-arm64-image.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${PROJECT_ROOT}/build-arm64" "${PROJECT_ROOT}/bin/arm64"
|
||||
|
||||
docker run --rm --platform linux/arm64 \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
-v "${PROJECT_ROOT}":/src \
|
||||
-w /src/build-arm64 \
|
||||
"${IMAGE}" \
|
||||
/bin/sh -c "cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DEXECUTABLE_OUTPUT_PATH=/src/bin/arm64 \
|
||||
-DRUNTIME_OUTPUT_DIRECTORY=/src/bin/arm64 .. && make -j${JOBS}"
|
||||
|
||||
echo ""
|
||||
echo ">>> 完成。产物:"
|
||||
file "${PROJECT_ROOT}/bin/arm64/pPowerManger"
|
||||
+76
-3
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/build-board.sh # 同步+编译+部署(不重启)
|
||||
# ./scripts/build-board.sh --start # 编译后重启 3 个服务
|
||||
# ./scripts/build-board.sh --start # 编译后重启全部服务(含 pCanBridge/pMotor/pELoad)
|
||||
# ./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)
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor pELoad)
|
||||
|
||||
info() { printf '\033[1;36m[build-board]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[build-board]\033[0m %s\n' "$*"; }
|
||||
@@ -210,10 +210,83 @@ ${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_BIN} && \
|
||||
cp -f ${BOARD_SRC}/bin/pPowerManger ${BOARD_BIN}/pPowerManger && \
|
||||
cp -f ${BOARD_SRC}/bin/pPowerMangerHost ${BOARD_BIN}/pPowerMangerHost && \
|
||||
cp -f ${BOARD_SRC}/bin/pCCU ${BOARD_BIN}/pCCU && \
|
||||
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU" \
|
||||
cp -f ${BOARD_SRC}/bin/pCanBridge ${BOARD_BIN}/pCanBridge && \
|
||||
cp -f ${BOARD_SRC}/bin/pMotor ${BOARD_BIN}/pMotor && \
|
||||
cp -f ${BOARD_SRC}/bin/pELoad ${BOARD_BIN}/pELoad && \
|
||||
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU ${BOARD_BIN}/pCanBridge ${BOARD_BIN}/pMotor ${BOARD_BIN}/pELoad" \
|
||||
|| { err "部署产物失败"; exit 1; }
|
||||
ok "产物已部署"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 4.5 安装/刷新 pCanBridge systemd unit(幂等,其它 5 个 unit 已装)
|
||||
#-------------------------------------------------------------------
|
||||
info "安装 pCanBridge.service ..."
|
||||
${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pCanBridge.service" <<EOF
|
||||
[Unit]
|
||||
Description=pCanBridge for H100 Power Manager
|
||||
After=moosdb.service
|
||||
Requires=moosdb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${BOARD_BIN}
|
||||
ExecStart=${BOARD_BIN}/pCanBridge --alias=pCanBridge /root/work/h100/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed pCanBridge 2>/dev/null || true"
|
||||
ok "pCanBridge.service 已安装"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 4.6 安装/刷新 pMotor systemd unit(幂等)
|
||||
#-------------------------------------------------------------------
|
||||
info "安装 pMotor.service ..."
|
||||
${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pMotor.service" <<EOF
|
||||
[Unit]
|
||||
Description=pMotor Propulsion Motor for H100 Power Manager
|
||||
After=moosdb.service pCanBridge.service
|
||||
Requires=moosdb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${BOARD_BIN}
|
||||
ExecStart=${BOARD_BIN}/pMotor --alias=pMotor /root/work/h100/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed pMotor 2>/dev/null || true"
|
||||
ok "pMotor.service 已安装"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 4.7 安装/刷新 pELoad systemd unit(幂等)
|
||||
# 电子负载(IT6000C)控制程序:以太网直连设备,不依赖 pCanBridge
|
||||
#-------------------------------------------------------------------
|
||||
info "安装 pELoad.service ..."
|
||||
${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pELoad.service" <<EOF
|
||||
[Unit]
|
||||
Description=pELoad Electronic Load (IT6000C) for H100 Power Manager
|
||||
After=moosdb.service
|
||||
Requires=moosdb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${BOARD_BIN}
|
||||
ExecStart=${BOARD_BIN}/pELoad --alias=pELoad /root/work/h100/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed pELoad 2>/dev/null || true"
|
||||
ok "pELoad.service 已安装"
|
||||
|
||||
echo ""
|
||||
ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)"
|
||||
${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60"
|
||||
|
||||
+23
-14
@@ -3,14 +3,16 @@
|
||||
# FILE: scripts/clean-data.sh
|
||||
# DESC: 清理目标板卡 (RK3588 / Ubuntu 22.04) 上的数据库与日志文件。
|
||||
# 默认清理板卡运行目录 /root/work/h100/data/ 下的:
|
||||
# power_data.db (+ -wal/-shm) 主机功率数据库
|
||||
# feedback_data.db 反馈落盘数据库
|
||||
# pPowerManger.log 主机运行日志
|
||||
# 默认先停止 4 个服务再清理(避免 SQLite WAL 仍在写入)。
|
||||
# power_data.db (+ -wal/-shm) 主机功率数据库
|
||||
# feedback_data.db 反馈落盘数据库
|
||||
# pccu_data.db (+ -wal/-shm) pCCU 帧日志数据库
|
||||
# pCanBridge_data.db (+ -wal/-shm) pCanBridge CAN 帧数据库
|
||||
# pPowerManger.log / pCCU.log 运行日志
|
||||
# 默认先停止各服务再清理(避免 SQLite WAL 仍在写入)。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/clean-data.sh # 停服务 -> 清理 data 目录(不重启)
|
||||
# ./scripts/clean-data.sh --start # 清理后重新启动 4 个服务
|
||||
# ./scripts/clean-data.sh --start # 清理后重新启动各服务
|
||||
# ./scripts/clean-data.sh --no-stop # 不停服务直接清理(慎用)
|
||||
# ./scripts/clean-data.sh --stale # 额外清理 bin/ 与 src/bin/ 下的旧 db/log
|
||||
#
|
||||
@@ -23,7 +25,7 @@
|
||||
# --stale 额外清理历史遗留的 bin/、src/bin/ 下旧数据库与日志
|
||||
# -h, --help 显示帮助
|
||||
#
|
||||
# 前置: 板卡已配免密 SSH(见 scripts/deploy.sh setup-ssh)。
|
||||
# 前置: 板卡已配免密 SSH(ssh-copy-id root@<host>)。
|
||||
#=======================================================================
|
||||
|
||||
set -uo pipefail
|
||||
@@ -38,10 +40,16 @@ DO_START=0
|
||||
DO_STOP=1
|
||||
DO_STALE=0
|
||||
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU)
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge)
|
||||
|
||||
# 板卡 data 目录下需要清理的文件(含 SQLite WAL/SHM)
|
||||
DATA_FILES=(power_data.db power_data.db-wal power_data.db-shm feedback_data.db pPowerManger.log pccu_data.db pCCU.log)
|
||||
DATA_FILES=(
|
||||
power_data.db power_data.db-wal power_data.db-shm
|
||||
feedback_data.db
|
||||
pccu_data.db pccu_data.db-wal pccu_data.db-shm
|
||||
pCanBridge_data.db pCanBridge_data.db-wal pCanBridge_data.db-shm
|
||||
pPowerManger.log pCCU.log
|
||||
)
|
||||
|
||||
# 历史遗留目录(旧部署位置),仅当 --stale 时清理
|
||||
STALE_DIRS=("${BOARD_DIR}/bin" "${BOARD_DIR}/src/bin")
|
||||
@@ -52,7 +60,7 @@ warn() { printf '\033[1;33m[clean-data]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[clean-data]\033[0m %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -82,7 +90,7 @@ SSH="ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||
#-------------------------------------------------------------------
|
||||
if ! ${SSH} "${SSH_TARGET}" 'true' 2>/dev/null; then
|
||||
err "无法免密 SSH 到 ${SSH_TARGET}"
|
||||
err "请先执行: ./scripts/deploy.sh setup-ssh"
|
||||
err "请先配置免密登录: ssh-copy-id ${SSH_TARGET}"
|
||||
exit 1
|
||||
fi
|
||||
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
@@ -92,7 +100,8 @@ ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
#-------------------------------------------------------------------
|
||||
if [ "${DO_STOP}" = "1" ]; then
|
||||
info "停止服务 ${SERVICES[*]} ..."
|
||||
${SSH} "${SSH_TARGET}" "systemctl stop pPowerMangerHost pPowerManger moosdb 2>/dev/null; true"
|
||||
# 先停应用,最后停 moosdb
|
||||
${SSH} "${SSH_TARGET}" "systemctl stop pCanBridge pPowerMangerHost pPowerManger pCCU moosdb 2>/dev/null; true"
|
||||
ok "服务已停止"
|
||||
else
|
||||
warn "--no-stop:不停服务直接清理,可能产生脏数据(SQLite 仍在写入)"
|
||||
@@ -123,15 +132,15 @@ fi
|
||||
# 5. 可选: 重启服务
|
||||
#-------------------------------------------------------------------
|
||||
if [ "${DO_START}" = "1" ]; then
|
||||
info "启动服务 (moosdb -> pPowerManger / pPowerMangerHost / pCCU) ..."
|
||||
info "启动服务 (moosdb -> pPowerManger / pPowerMangerHost / pCCU / pCanBridge) ..."
|
||||
${SSH} "${SSH_TARGET}" "systemctl start moosdb && sleep 1 && \
|
||||
systemctl start pPowerManger pPowerMangerHost pCCU" \
|
||||
systemctl start pPowerManger pPowerMangerHost pCCU pCanBridge" \
|
||||
|| { err "启动失败"; exit 1; }
|
||||
ok "服务已启动"
|
||||
else
|
||||
echo ""
|
||||
info "清理完成,服务未启动。需要重启请加 --start 或手动:"
|
||||
echo " systemctl start moosdb pPowerManger pPowerMangerHost pCCU"
|
||||
echo " systemctl start moosdb pPowerManger pPowerMangerHost pCCU pCanBridge"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/bin/bash
|
||||
#=======================================================================
|
||||
# FILE: scripts/deploy.sh
|
||||
# DESC: 部署 pPowerManger / pPowerMangerHost / pCCU(aarch64 二进制 +
|
||||
# mission)到目标板卡 (RK3588 / Ubuntu 22.04),并用四个独立
|
||||
# systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU)
|
||||
# 管理。只安装服务 + daemon-reload,默认不启动、不自启。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/deploy.sh setup-ssh 一次性:生成 ed25519 key + ssh-copy-id
|
||||
# ./scripts/deploy.sh 默认:推送二进制 + mission + 装 4 个
|
||||
# systemd 服务 + daemon-reload(不启动)
|
||||
# ./scripts/deploy.sh --start 部署后按依赖顺序启动服务
|
||||
# ./scripts/deploy.sh status 板卡 4 个服务状态
|
||||
# ./scripts/deploy.sh stop 停止 4 个服务
|
||||
# ./scripts/deploy.sh help 显示帮助
|
||||
#
|
||||
# 参数:
|
||||
# --host <ip> 目标主机(默认 192.168.0.223)
|
||||
# --user <user> 登录用户(默认 root)
|
||||
# --board-dir <dir> 板卡部署根目录(默认 /root/work/h100)
|
||||
# --start 部署后立即启动服务
|
||||
# --ccu-host <ip> 覆盖 mission 中 pPowerManger 的 CCU 地址 (ccuhost)
|
||||
# --ccu-port <n> 覆盖 mission 中 pPowerManger 的 CCU 端口 (ccuport)
|
||||
#
|
||||
# 板卡布局:
|
||||
# /root/work/h100/bin/ aarch64 二进制
|
||||
# /root/work/h100/missions/ 板卡 mission h100.moos
|
||||
# /root/work/h100/data/ 数据库存储目录 (power_data.db 等)
|
||||
# /etc/systemd/system/ moosdb.service / pPowerManger.service /
|
||||
# pPowerMangerHost.service / pCCU.service
|
||||
#=======================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
|
||||
|
||||
HOST="192.168.0.223"
|
||||
USER="root"
|
||||
BOARD_DIR="/root/work/h100"
|
||||
DO_START=0
|
||||
CCU_HOST=""
|
||||
CCU_PORT=""
|
||||
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU)
|
||||
|
||||
info() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 参数解析
|
||||
#-------------------------------------------------------------------
|
||||
ACTION=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) [ $# -ge 2 ] || { err "--host 需要参数"; exit 1; }; HOST="$2"; shift 2 ;;
|
||||
--user) [ $# -ge 2 ] || { err "--user 需要参数"; exit 1; }; USER="$2"; shift 2 ;;
|
||||
--board-dir)[ $# -ge 2 ] || { err "--board-dir 需要参数"; exit 1; }; BOARD_DIR="$2"; shift 2 ;;
|
||||
--start) DO_START=1; shift ;;
|
||||
--ccu-host) [ $# -ge 2 ] || { err "--ccu-host 需要参数"; exit 1; }; CCU_HOST="$2"; shift 2 ;;
|
||||
--ccu-port) [ $# -ge 2 ] || { err "--ccu-port 需要参数"; exit 1; }; CCU_PORT="$2"; shift 2 ;;
|
||||
setup-ssh|status|stop|help|-h|--help) ACTION="$1"; shift ;;
|
||||
"") shift ;;
|
||||
*) err "未知参数: $1(见 help)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "${ACTION}" ] || ACTION="deploy"
|
||||
|
||||
case "${CCU_PORT}" in
|
||||
""|*[!0-9]*) [ -z "${CCU_PORT}" ] || { err "--ccu-port 必须是数字端口: ${CCU_PORT}"; exit 1; } ;;
|
||||
esac
|
||||
|
||||
SSH_TARGET="${USER}@${HOST}"
|
||||
SSH="ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||
SCP="scp -o BatchMode=yes -o ConnectTimeout=5"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令: setup-ssh
|
||||
#-------------------------------------------------------------------
|
||||
cmd_setup_ssh() {
|
||||
local key="$HOME/.ssh/id_ed25519"
|
||||
if [ ! -f "${key}" ]; then
|
||||
info "未发现 SSH key,生成 ed25519 key ..."
|
||||
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
||||
ssh-keygen -t ed25519 -N '' -f "${key}"
|
||||
fi
|
||||
info "上传公钥到 ${SSH_TARGET}(会提示输入密码,仅此一次)..."
|
||||
ssh-copy-id -o ConnectTimeout=5 "${SSH_TARGET}"
|
||||
ok "免密 SSH 已配置。验证: ssh ${SSH_TARGET}"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 前置检查
|
||||
#-------------------------------------------------------------------
|
||||
check_local() {
|
||||
local missing=0
|
||||
for b in pPowerManger pPowerMangerHost pCCU; do
|
||||
if [ ! -f "${PROJECT_ROOT}/bin/arm64/${b}" ]; then
|
||||
err "缺少本地 aarch64 二进制: bin/arm64/${b}(先运行 ./scripts/build-arm64.sh)"; missing=1
|
||||
elif ! file "${PROJECT_ROOT}/bin/arm64/${b}" | grep -q "aarch64"; then
|
||||
err "bin/arm64/${b} 不是 aarch64 二进制"; missing=1
|
||||
fi
|
||||
done
|
||||
if [ ! -f "${PROJECT_ROOT}/missions/h100.moos" ]; then
|
||||
err "缺少板卡 mission: missions/h100.moos"; missing=1
|
||||
fi
|
||||
[ "${missing}" -eq 0 ] || return 1
|
||||
ok "本地产物校验通过 (aarch64 + mission)"
|
||||
}
|
||||
|
||||
check_ssh() {
|
||||
if ! ${SSH} "${SSH_TARGET}" 'true' 2>/dev/null; then
|
||||
err "无法免密 SSH 到 ${SSH_TARGET}"
|
||||
err "请先执行: ./scripts/deploy.sh setup-ssh"
|
||||
return 1
|
||||
fi
|
||||
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 生成 systemd unit(含板卡绝对路径)
|
||||
#-------------------------------------------------------------------
|
||||
gen_moosdb_unit() {
|
||||
# MOOSDB 路径在板卡探测;探测失败给出提示
|
||||
local moosdb_bin
|
||||
moosdb_bin="$(${SSH} "${SSH_TARGET}" 'command -v MOOSDB || true' 2>/dev/null)"
|
||||
if [ -z "${moosdb_bin}" ]; then
|
||||
moosdb_bin="$(command -v MOOSDB || true)"
|
||||
warn "板卡未在 PATH 找到 MOOSDB,回退本机路径 ${moosdb_bin:-"(未找到)"}"
|
||||
[ -n "${moosdb_bin}" ] || { err "MOOSDB 路径未知,无法生成 moosdb.service"; return 1; }
|
||||
fi
|
||||
ok "MOOSDB 路径: ${moosdb_bin}" >&2
|
||||
|
||||
cat <<EOF
|
||||
[Unit]
|
||||
Description=MOOSDB for H100 Power Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${moosdb_bin} --moos_no_colour ${BOARD_DIR}/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
gen_app_unit() {
|
||||
local app="$1" deps=""
|
||||
[ "${app}" = "moosdb" ] || deps="After=moosdb.service\nRequires=moosdb.service"
|
||||
cat <<EOF
|
||||
[Unit]
|
||||
Description=${app} for H100 Power Manager
|
||||
$(printf '%b' "${deps}")
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${BOARD_DIR}/bin
|
||||
ExecStart=${BOARD_DIR}/bin/${app} --alias=${app} ${BOARD_DIR}/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# mission 中插入/替换 "key = value"(键已存在则替换,否则插到第一个
|
||||
# 顶层 '}' 之前,即 pPowerManger 配置块末尾)
|
||||
#-------------------------------------------------------------------
|
||||
upsert_moos_param() {
|
||||
local f="$1" key="$2" val="$3"
|
||||
if grep -qE "^[[:space:]]*${key}[[:space:]]*=" "${f}"; then
|
||||
sed -i -E "s|^([[:space:]]*)${key}[[:space:]]*=.*|\1${key} = ${val}|" "${f}"
|
||||
else
|
||||
awk -v kv=" ${key} = ${val}" \
|
||||
'!d && /^[[:space:]]*}[[:space:]]*$/ { print kv; d=1 } { print }' \
|
||||
"${f}" > "${f}.tmp" && mv "${f}.tmp" "${f}"
|
||||
fi
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令: deploy
|
||||
#-------------------------------------------------------------------
|
||||
cmd_deploy() {
|
||||
check_local || return 1
|
||||
check_ssh || return 1
|
||||
|
||||
# 1. 推送二进制 + mission
|
||||
info "创建板卡目录 ${BOARD_DIR}/{bin,missions,data} ..."
|
||||
${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_DIR}/bin ${BOARD_DIR}/missions ${BOARD_DIR}/data" || { err "创建目录失败"; return 1; }
|
||||
|
||||
info "推送 aarch64 二进制 ..."
|
||||
${SCP} -q "${PROJECT_ROOT}/bin/arm64/pPowerManger" \
|
||||
"${PROJECT_ROOT}/bin/arm64/pPowerMangerHost" \
|
||||
"${PROJECT_ROOT}/bin/arm64/pCCU" \
|
||||
"${SSH_TARGET}:${BOARD_DIR}/bin/" || { err "推送二进制失败"; return 1; }
|
||||
|
||||
info "推送 mission h100.moos ..."
|
||||
local mission_src="${PROJECT_ROOT}/missions/h100.moos"
|
||||
local mission_push="${mission_src}" tmp_moos=""
|
||||
if [ -n "${CCU_HOST}" ] || [ -n "${CCU_PORT}" ]; then
|
||||
tmp_moos="$(mktemp)"
|
||||
cp "${mission_src}" "${tmp_moos}"
|
||||
[ -n "${CCU_HOST}" ] && upsert_moos_param "${tmp_moos}" ccuhost "${CCU_HOST}"
|
||||
[ -n "${CCU_PORT}" ] && upsert_moos_param "${tmp_moos}" ccuport "${CCU_PORT}"
|
||||
mission_push="${tmp_moos}"
|
||||
info "CCU 地址覆盖: ccuhost=${CCU_HOST:-<保持原值>} ccuport=${CCU_PORT:-<保持原值>}"
|
||||
fi
|
||||
${SCP} -q "${mission_push}" \
|
||||
"${SSH_TARGET}:${BOARD_DIR}/missions/" || { err "推送 mission 失败"; [ -n "${tmp_moos}" ] && rm -f "${tmp_moos}"; return 1; }
|
||||
[ -n "${tmp_moos}" ] && rm -f "${tmp_moos}"
|
||||
|
||||
${SSH} "${SSH_TARGET}" "chmod +x ${BOARD_DIR}/bin/pPowerManger ${BOARD_DIR}/bin/pPowerMangerHost ${BOARD_DIR}/bin/pCCU"
|
||||
ok "二进制与 mission 已推送"
|
||||
|
||||
# 2. 生成并安装 4 个 systemd unit
|
||||
local unit
|
||||
for s in "${SERVICES[@]}"; do
|
||||
info "生成 ${s}.service ..."
|
||||
if [ "${s}" = "moosdb" ]; then
|
||||
unit="$(gen_moosdb_unit)" || return 1
|
||||
else
|
||||
unit="$(gen_app_unit "${s}")"
|
||||
fi
|
||||
printf '%s\n' "${unit}" | ${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/${s}.service"
|
||||
ok "${s}.service 已安装"
|
||||
done
|
||||
|
||||
# 3. daemon-reload(不 enable,不自启)
|
||||
info "systemctl daemon-reload ..."
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed ${SERVICES[*]} 2>/dev/null || true"
|
||||
ok "服务已安装但未启用自启(systemctl is-enabled 应输出 disabled)"
|
||||
|
||||
# 4. 可选启动
|
||||
if [ "${DO_START}" = "1" ]; then
|
||||
cmd_start
|
||||
else
|
||||
echo ""
|
||||
info "部署完成。手动启动(或加 --start):"
|
||||
echo " systemctl start moosdb pPowerManger pPowerMangerHost pCCU"
|
||||
echo " 浏览器: http://${HOST}:18080 (上位机模拟)"
|
||||
echo " http://${HOST}:8090 (pPowerManger)"
|
||||
echo " http://${HOST}:8080 (pCCU)"
|
||||
fi
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令: start / status / stop
|
||||
#-------------------------------------------------------------------
|
||||
cmd_start() {
|
||||
info "启动服务 (moosdb -> pPowerManger / pPowerMangerHost / pCCU) ..."
|
||||
${SSH} "${SSH_TARGET}" "systemctl start moosdb && sleep 1 && \
|
||||
systemctl start pPowerManger pPowerMangerHost pCCU" || { err "启动失败"; return 1; }
|
||||
ok "服务已启动"
|
||||
cmd_status
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
${SSH} "${SSH_TARGET}" "systemctl --no-pager status ${SERVICES[*]}" || true
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
${SSH} "${SSH_TARGET}" "systemctl stop pPowerMangerHost pPowerManger pCCU moosdb" && \
|
||||
ok "服务已停止" || err "停止服务失败"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令入口
|
||||
#-------------------------------------------------------------------
|
||||
case "${ACTION}" in
|
||||
deploy) cmd_deploy ;;
|
||||
setup-ssh) cmd_setup_ssh ;;
|
||||
status) cmd_status ;;
|
||||
stop) cmd_stop ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
+18
-10
@@ -3,10 +3,12 @@
|
||||
# FILE: scripts/fetch-data.sh
|
||||
# DESC: 从目标板卡 (RK3588 / Ubuntu 22.04) 下载数据库与日志到本地。
|
||||
# 默认抓取板卡运行目录 /root/work/h100/data/ 下的:
|
||||
# power_data.db (+ -wal/-shm) 主机功率数据库
|
||||
# feedback_data.db 反馈落盘数据库
|
||||
# pPowerManger.log 主机运行日志
|
||||
# 可选 --journal 一并导出四个 systemd 服务的 journal 日志。
|
||||
# power_data.db (+ -wal/-shm) 主机功率数据库
|
||||
# feedback_data.db 反馈落盘数据库
|
||||
# pccu_data.db (+ -wal/-shm) pCCU 帧日志数据库
|
||||
# pCanBridge_data.db (+ -wal/-shm) pCanBridge CAN 帧数据库
|
||||
# pPowerManger.log / pCCU.log 运行日志
|
||||
# 可选 --journal 一并导出各 systemd 服务的 journal 日志。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/fetch-data.sh # 下载 db + 日志到本地 data/board-<时间>/
|
||||
@@ -18,10 +20,10 @@
|
||||
# --user <user> 登录用户(默认 root)
|
||||
# --board-dir <dir> 板卡部署根目录(默认 /root/work/h100)
|
||||
# --out-dir <dir> 本地保存目录(默认 data/board-<时间>)
|
||||
# --journal 额外导出 4 个服务的 systemd journal 日志
|
||||
# --journal 额外导出各服务的 systemd journal 日志
|
||||
# -h, --help 显示帮助
|
||||
#
|
||||
# 前置: 板卡已配免密 SSH(见 scripts/deploy.sh setup-ssh)。
|
||||
# 前置: 板卡已配免密 SSH(ssh-copy-id root@<host>)。
|
||||
#=======================================================================
|
||||
|
||||
set -uo pipefail
|
||||
@@ -35,10 +37,16 @@ BOARD_DIR="/root/work/h100"
|
||||
OUT_DIR=""
|
||||
DO_JOURNAL=0
|
||||
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU)
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge)
|
||||
|
||||
# 板卡 data 目录下需要下载的文件(含 SQLite WAL/SHM 以保证数据完整)
|
||||
DATA_FILES=(power_data.db power_data.db-wal power_data.db-shm feedback_data.db pPowerManger.log pccu_data.db pCCU.log)
|
||||
DATA_FILES=(
|
||||
power_data.db power_data.db-wal power_data.db-shm
|
||||
feedback_data.db
|
||||
pccu_data.db pccu_data.db-wal pccu_data.db-shm
|
||||
pCanBridge_data.db pCanBridge_data.db-wal pCanBridge_data.db-shm
|
||||
pPowerManger.log pCCU.log
|
||||
)
|
||||
|
||||
info() { printf '\033[1;36m[fetch-data]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[fetch-data]\033[0m %s\n' "$*"; }
|
||||
@@ -46,7 +54,7 @@ warn() { printf '\033[1;33m[fetch-data]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[fetch-data]\033[0m %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -74,7 +82,7 @@ SCP="scp -o BatchMode=yes -o ConnectTimeout=5"
|
||||
#-------------------------------------------------------------------
|
||||
if ! ${SSH} "${SSH_TARGET}" 'true' 2>/dev/null; then
|
||||
err "无法免密 SSH 到 ${SSH_TARGET}"
|
||||
err "请先执行: ./scripts/deploy.sh setup-ssh"
|
||||
err "请先配置免密登录: ssh-copy-id ${SSH_TARGET}"
|
||||
exit 1
|
||||
fi
|
||||
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
#=======================================================================
|
||||
# FILE: scripts/sync-board-time.sh
|
||||
# DESC: 校正 223 板卡(RK3588,默认 192.168.0.223)的系统时间:以
|
||||
# 本机时间(或 --time 指定时间)为基准,经 SSH 设置板卡系统时
|
||||
# 钟,并尽力写入硬件时钟 (RTC)。板卡网络隔离无 NTP,用本脚本对时。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/sync-board-time.sh # 用本机时间校正板卡
|
||||
# ./scripts/sync-board-time.sh status # 只查看时间与偏差
|
||||
# ./scripts/sync-board-time.sh --time "2026-09-01 12:00:00"
|
||||
# # 用指定时间校正(本地时区)
|
||||
#
|
||||
# 参数:
|
||||
# --host <ip> 目标主机(默认 192.168.0.223)
|
||||
# --user <user> 登录用户(默认 root)
|
||||
# --time <str> 手动指定基准时间(date 可解析的格式,按本地时区解释)
|
||||
# --keep-ntp 不执行 timedatectl set-ntp false
|
||||
# --no-rtc 不写硬件时钟(跳过 hwclock --systohc)
|
||||
# -h, --help 显示帮助
|
||||
#
|
||||
# 说明:
|
||||
# - 需要 root 登录与免密 SSH(ssh-copy-id root@<host>)。
|
||||
# - 对时经 SSH 往返时延 (RTT) 折半补偿,精度约 ±0.1s(受网络抖动影响)。
|
||||
# - 板卡若无可用 RTC,hwclock 失败仅告警;重启后时间可能回跳。
|
||||
# - 对时会调整系统时钟,运行中的服务日志时间戳会跳变,属正常现象。
|
||||
#=======================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
HOST="192.168.0.223"
|
||||
USER="root"
|
||||
SET_TIME=""
|
||||
KEEP_NTP=0
|
||||
NO_RTC=0
|
||||
|
||||
info() { printf '\033[1;36m[time]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[time]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[time]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[time]\033[0m %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 参数解析
|
||||
#-------------------------------------------------------------------
|
||||
ACTION=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) [ $# -ge 2 ] || { err "--host 需要参数"; exit 1; }; HOST="$2"; shift 2 ;;
|
||||
--user) [ $# -ge 2 ] || { err "--user 需要参数"; exit 1; }; USER="$2"; shift 2 ;;
|
||||
--time) [ $# -ge 2 ] || { err "--time 需要参数"; exit 1; }; SET_TIME="$2"; shift 2 ;;
|
||||
--keep-ntp) KEEP_NTP=1; shift ;;
|
||||
--no-rtc) NO_RTC=1; shift ;;
|
||||
status|help|-h|--help) ACTION="$1"; shift ;;
|
||||
"") shift ;;
|
||||
*) err "未知参数: $1(见 help)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "${ACTION}" ] || ACTION="sync"
|
||||
|
||||
SSH_TARGET="${USER}@${HOST}"
|
||||
SSH="ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 读取板卡时间并测 RTT
|
||||
# 输出: "<epoch>|<板卡可读时间> <t0> <t1>"(t0/t1 为本机采样时刻)
|
||||
#-------------------------------------------------------------------
|
||||
read_board_time() {
|
||||
local t0 t1 out
|
||||
t0="$(date +%s.%N)"
|
||||
out="$(${SSH} "${SSH_TARGET}" 'date "+%s.%N|%F %T %Z"' 2>/dev/null)" || {
|
||||
err "无法免密 SSH 到 ${SSH_TARGET}(先配置免密: ssh-copy-id ${SSH_TARGET})" >&2
|
||||
return 1
|
||||
}
|
||||
t1="$(date +%s.%N)"
|
||||
printf '%s %.3f %.3f\n' "${out}" "${t0}" "${t1}"
|
||||
}
|
||||
|
||||
# 从 read_board_time 输出解析: set -- <out> 后取 $1/$2/$3
|
||||
parse_board_time() {
|
||||
B_EPOCH="${1%%|*}"
|
||||
B_DATE="${1#*|}"
|
||||
T0="$2"
|
||||
T1="$3"
|
||||
MID="$(awk -v t0="${T0}" -v t1="${T1}" 'BEGIN{printf "%.3f", t0 + (t1-t0)/2}')"
|
||||
DIFF="$(awk -v b="${B_EPOCH}" -v m="${MID}" 'BEGIN{printf "%+.3f", b - m}')"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令: status
|
||||
#-------------------------------------------------------------------
|
||||
cmd_status() {
|
||||
local out
|
||||
out="$(read_board_time)" || return 1
|
||||
parse_board_time ${out}
|
||||
local rtt
|
||||
rtt="$(awk -v t0="${T0}" -v t1="${T1}" 'BEGIN{printf "%.3f", t1-t0}')"
|
||||
printf '本机时间: %s (RTT %.3fs)\n' "$(date -d "@${MID}" '+%F %T')" "${rtt}"
|
||||
printf '板卡时间: %s\n' "${B_DATE}"
|
||||
printf '偏差: 板卡比本机快 %s 秒\n' "${DIFF}"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令: sync(默认)
|
||||
#-------------------------------------------------------------------
|
||||
cmd_sync() {
|
||||
local out target board_reply
|
||||
|
||||
# 前置检查:SSH 连通 + 板卡 root 权限
|
||||
out="$(read_board_time)" || return 1
|
||||
parse_board_time ${out}
|
||||
if ! ${SSH} "${SSH_TARGET}" '[ "$(id -u)" = 0 ]' 2>/dev/null; then
|
||||
err "板卡登录用户需要 root 权限(当前 --user ${USER})"
|
||||
return 1
|
||||
fi
|
||||
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
info "当前偏差: 板卡比本机快 ${DIFF} 秒"
|
||||
|
||||
# 关闭 NTP 自动同步(板卡网络隔离本就无 NTP 源,防止干扰手动对时)
|
||||
if [ "${KEEP_NTP}" = "0" ]; then
|
||||
info "关闭板卡 NTP 自动同步 (timedatectl set-ntp false) ..."
|
||||
${SSH} "${SSH_TARGET}" "command -v timedatectl >/dev/null 2>&1 && timedatectl set-ntp false || true"
|
||||
fi
|
||||
|
||||
# 计算目标时间:手动指定值直接用;否则取本机时间 + RTT/2 补偿
|
||||
if [ -n "${SET_TIME}" ]; then
|
||||
target="$(date -d "${SET_TIME}" '+%s.%N' 2>/dev/null)" || {
|
||||
err "无法解析时间: ${SET_TIME}"; return 1;
|
||||
}
|
||||
info "基准时间(手动): ${SET_TIME}"
|
||||
else
|
||||
local t2
|
||||
t2="$(date +%s.%N)"
|
||||
target="$(awk -v t2="${t2}" -v t0="${T0}" -v t1="${T1}" 'BEGIN{printf "%.3f", t2 + (t1-t0)/2}')"
|
||||
info "基准时间(本机 + RTT/2 补偿)"
|
||||
fi
|
||||
info "设置板卡系统时间 -> $(date -d "@${target}" '+%F %T') ..."
|
||||
|
||||
board_reply="$(${SSH} "${SSH_TARGET}" "date -s @${target} 2>&1")" || {
|
||||
err "date -s 失败: ${board_reply}"
|
||||
return 1
|
||||
}
|
||||
ok "板卡返回: ${board_reply}"
|
||||
|
||||
# 写硬件时钟(无 RTC 时仅告警)
|
||||
if [ "${NO_RTC}" = "0" ]; then
|
||||
info "同步硬件时钟 (hwclock --systohc) ..."
|
||||
if ${SSH} "${SSH_TARGET}" "hwclock --systohc" 2>/dev/null; then
|
||||
ok "RTC 已同步"
|
||||
else
|
||||
warn "hwclock 失败(板卡可能无 RTC 或驱动未加载),重启后时间可能回跳"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 校验
|
||||
out="$(read_board_time)" || return 1
|
||||
parse_board_time ${out}
|
||||
info "校正后偏差: 板卡比本机快 ${DIFF} 秒"
|
||||
ok "板卡时间: ${B_DATE}"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 命令入口
|
||||
#-------------------------------------------------------------------
|
||||
case "${ACTION}" in
|
||||
sync) cmd_sync ;;
|
||||
status) cmd_status ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -21,6 +21,9 @@ ADD_SUBDIRECTORY(pPowerManger)
|
||||
add_subdirectory(pPMtest)
|
||||
add_subdirectory(pPowerMangerHost)
|
||||
add_subdirectory(pCCU)
|
||||
add_subdirectory(pCanBridge)
|
||||
add_subdirectory(pMotor)
|
||||
add_subdirectory(pELoad)
|
||||
##############################################################################
|
||||
# END of CMakeLists.txt
|
||||
##############################################################################
|
||||
|
||||
+256
-12
@@ -2,7 +2,10 @@
|
||||
#include "MBUtils.h"
|
||||
#include "../pPowerManger/logc/loguru.hpp"
|
||||
#include "protocol/Frame.h"
|
||||
#include "protocol/CanBms.h"
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
using namespace ccu;
|
||||
using namespace std;
|
||||
@@ -14,9 +17,12 @@ CCU::CCU() {}
|
||||
|
||||
CCU::~CCU() {
|
||||
if (m_web) { m_web->stop(); delete m_web; m_web = nullptr; }
|
||||
if (m_coord) { delete m_coord; m_coord = nullptr; }
|
||||
if (m_bridge) { delete m_bridge; m_bridge = nullptr; }
|
||||
if (m_fcLink) { m_fcLink->stop(); delete m_fcLink; m_fcLink = nullptr; }
|
||||
if (m_pmLink) { m_pmLink->stop(); delete m_pmLink; m_pmLink = nullptr; }
|
||||
if (m_db) { delete m_db; m_db = nullptr; }
|
||||
if (m_rcuLink) { m_rcuLink->stop(); delete m_rcuLink; m_rcuLink = nullptr; }
|
||||
if (m_db) { m_db->close(); delete m_db; m_db = nullptr; }
|
||||
if (m_snap) { delete m_snap; m_snap = nullptr; }
|
||||
if (m_sysData) { delete m_sysData; m_sysData = nullptr; }
|
||||
}
|
||||
@@ -46,8 +52,13 @@ bool CCU::OnStartUp() {
|
||||
else if (param == "pm_local_port") m_pmLocalPort = atol(value.c_str());
|
||||
else if (param == "pm_remote_ip") m_pmHost = value;
|
||||
else if (param == "pm_remote_port") m_pmPort = atol(value.c_str());
|
||||
else if (param == "rcu_enable") m_rcuEnable = (tolower(value) == "true" || value == "1");
|
||||
else if (param == "rcu_local_port") m_rcuLocalPort = atol(value.c_str());
|
||||
else if (param == "rcu_remote_ip") m_rcuHost = value;
|
||||
else if (param == "rcu_remote_port") m_rcuPort = atol(value.c_str());
|
||||
else if (param == "dbpath") m_dbPath = value;
|
||||
else if (param == "logpath") m_logPath = value;
|
||||
else if (param == "bcu_can_channel") m_bcuCanChannel = value;
|
||||
else if (param == "web_port") m_webPort = atoi(value.c_str());
|
||||
else if (param == "web_enable") {
|
||||
m_webEnable = (tolower(value) == "true" || value == "1");
|
||||
@@ -78,6 +89,9 @@ bool CCU::OnStartUp() {
|
||||
// 系统数据快照
|
||||
m_sysData = new SystemData();
|
||||
|
||||
// 配电桥接器(须在链路 start 前完成依赖注入,避免接收线程空指针)
|
||||
m_bridge = new DisBridge();
|
||||
|
||||
// FC 链路
|
||||
m_fcLink = new FcLinkManager(m_fcLocalPort, m_fcHost, m_fcPort);
|
||||
m_fcLink->setLogSink(m_db);
|
||||
@@ -87,22 +101,50 @@ bool CCU::OnStartUp() {
|
||||
m_fcLink->setOnRawFrame([](int dir, const std::vector<uint8_t>&, bool) {
|
||||
// 原始帧已通过 LogSink 落库;如需 Web 实时原始报文可在此扩展
|
||||
});
|
||||
if (!m_fcLink->start())
|
||||
LOG_F(ERROR, "FC link start failed");
|
||||
|
||||
// PM 链路
|
||||
m_pmLink = new PmLinkManager(m_pmLocalPort, m_pmHost, m_pmPort);
|
||||
m_pmLink->setLogSink(m_db);
|
||||
m_pmLink->setOnMessage([this](Message* msg, const std::vector<uint8_t>& frame) {
|
||||
// 配电指令(0x0001~0x0004,Sum8 短帧)优先桥接转发真实CCU;
|
||||
// 注册表按帧总长区分配电指令与 PM 操控/参数指令(共用 id)
|
||||
if (m_bridge && m_bridge->onPmFrame(msg, frame)) return;
|
||||
handlePmMessage(msg, frame);
|
||||
});
|
||||
m_pmLink->setOnRawFrame([](int dir, const std::vector<uint8_t>&, bool) {
|
||||
m_pmLink->setOnRawFrame([](int, const std::vector<uint8_t>&, bool) {
|
||||
});
|
||||
|
||||
// 真实 CCU 链路(配电桥接,rcu_enable 开启时创建)
|
||||
if (m_rcuEnable) {
|
||||
m_rcuLink = new RcuLinkManager(m_rcuLocalPort, m_rcuHost, m_rcuPort);
|
||||
m_rcuLink->setLogSink(m_db);
|
||||
m_rcuLink->setOnMessage([this](Message* msg, const std::vector<uint8_t>& frame) {
|
||||
handleRcuMessage(msg, frame);
|
||||
});
|
||||
m_rcuLink->setOnRawFrame([](int, const std::vector<uint8_t>&, bool) {
|
||||
});
|
||||
}
|
||||
|
||||
// 注入桥接依赖(m_rcuLink 可为空:未启用桥接时相关入口自动失效)
|
||||
m_bridge->setup(m_pmLink, m_rcuLink, m_sysData);
|
||||
|
||||
// 启动链路
|
||||
if (!m_fcLink->start())
|
||||
LOG_F(ERROR, "FC link start failed");
|
||||
if (!m_pmLink->start())
|
||||
LOG_F(ERROR, "PM link start failed");
|
||||
if (m_rcuLink && !m_rcuLink->start())
|
||||
LOG_F(ERROR, "RCU link start failed on port %ld", m_rcuLocalPort);
|
||||
|
||||
// 锂电池:CAN 总线(BMS 协议),数据经 pCanBridge 以 CAN_0x* 消息透传,
|
||||
// 在 OnNewMail 中订阅解码(见 registerVariables/handleCanMessage)。
|
||||
|
||||
// 电源协调器(FC 联动 + 协调算法占位)
|
||||
m_coord = new PowerCoordinator();
|
||||
m_coord->setup(m_sysData, m_fcLink, m_pmLink);
|
||||
|
||||
// 快照构建器(链路就绪后创建)
|
||||
m_snap = new SnapshotBuilder(m_sysData, m_fcLink, m_pmLink, m_db);
|
||||
m_snap = new SnapshotBuilder(m_sysData, m_fcLink, m_pmLink, m_rcuLink, m_db);
|
||||
|
||||
// 网页
|
||||
if (m_webEnable) {
|
||||
@@ -118,9 +160,20 @@ bool CCU::OnStartUp() {
|
||||
}
|
||||
}
|
||||
|
||||
LOG_F(INFO, "pCCU started: fc_link local=%ld -> %s:%ld, pm_link local=%ld -> %s:%ld",
|
||||
m_fcLocalPort, m_fcHost.c_str(), m_fcPort,
|
||||
m_pmLocalPort, m_pmHost.c_str(), m_pmPort);
|
||||
if (m_rcuLink) {
|
||||
LOG_F(INFO, "pCCU started: fc_link local=%ld -> %s:%ld, pm_link local=%ld -> %s:%ld, "
|
||||
"rcu_link local=%ld -> %s:%ld (配电桥接), "
|
||||
"battery=BMS/CAN via pCanBridge(CAN_0x*)",
|
||||
m_fcLocalPort, m_fcHost.c_str(), m_fcPort,
|
||||
m_pmLocalPort, m_pmHost.c_str(), m_pmPort,
|
||||
m_rcuLocalPort, m_rcuHost.c_str(), m_rcuPort);
|
||||
} else {
|
||||
LOG_F(INFO, "pCCU started: fc_link local=%ld -> %s:%ld, pm_link local=%ld -> %s:%ld, "
|
||||
"rcu_link disabled, "
|
||||
"battery=BMS/CAN via pCanBridge(CAN_0x*)",
|
||||
m_fcLocalPort, m_fcHost.c_str(), m_fcPort,
|
||||
m_pmLocalPort, m_pmHost.c_str(), m_pmPort);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -137,9 +190,14 @@ bool CCU::OnConnectToServer() {
|
||||
|
||||
void CCU::registerVariables() {
|
||||
AppCastingMOOSApp::RegisterVariables();
|
||||
// pCCU 数据交换主要走 UDP;MOOSDB 仅登记运行状态变量
|
||||
// 订阅 pCanBridge 透传的全部 CAN 帧(变量名 CAN_0x%08X,二进制数据域)。
|
||||
// 注意:MOOS 通配订阅必须用三参数重载(变量模式 + 来源模式),
|
||||
// 单参数 Register("CAN_0x*") 不会做通配匹配。
|
||||
Register("CAN_0x*", "*", 0);
|
||||
// 运行状态变量
|
||||
Register("CCU_FC_LINK_STATE", 0);
|
||||
Register("CCU_PM_LINK_STATE", 0);
|
||||
Register("CCU_RCU_LINK_STATE", 0);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
@@ -150,11 +208,63 @@ bool CCU::OnNewMail(MOOSMSG_LIST &NewMail) {
|
||||
MOOSMSG_LIST::iterator p;
|
||||
for (p = NewMail.begin(); p != NewMail.end(); p++) {
|
||||
CMOOSMsg &msg = *p;
|
||||
// 预留:未来如需通过 MOOSDB 下发指令可在此处理
|
||||
// pCanBridge 透传的 CAN 帧(变量名前缀 CAN_0x)
|
||||
if (msg.m_sKey.rfind("CAN_0x", 0) == 0) {
|
||||
handleCanMessage(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// handleCanMessage:解码 pCanBridge 透传的 CAN 帧
|
||||
//
|
||||
// 消息格式(pCanBridge 约定):
|
||||
// m_sKey = "CAN_0x%08X"(CAN ID,扩展帧)
|
||||
// m_sVal = 二进制数据域(dlc 字节)
|
||||
// m_dfVal2 = 原始帧信息字节(bit7 FF / bit6 RTR / bit3~0 DLC)
|
||||
|
||||
void CCU::handleCanMessage(CMOOSMsg& msg) {
|
||||
if (!m_sysData) return;
|
||||
|
||||
// 从变量名解析 CAN ID(跳过前缀 "CAN_0x" 共 6 字符)
|
||||
uint32_t canId = 0;
|
||||
if (std::sscanf(msg.m_sKey.c_str() + 6, "%x", &canId) != 1) return;
|
||||
|
||||
m_sysData->incCanFrameCount();
|
||||
|
||||
// 远程帧无数据域,直接跳过
|
||||
if (msg.IsBinary() && msg.GetBinaryDataSize() > 0) {
|
||||
unsigned int n = msg.GetBinaryDataSize();
|
||||
unsigned char* d = msg.GetBinaryData();
|
||||
if (!d) return;
|
||||
|
||||
// 合并语义:先取该节点当前快照 -> 解码部分更新 -> 写回
|
||||
// (每个功能码报文只更新自己的字段,不能整体覆盖)
|
||||
uint8_t addr = bcuAddrOf(canId);
|
||||
if (!addr) return;
|
||||
BcuNodeStatus node = m_sysData->bcuNode(addr);
|
||||
if (decodeCanBcuFrame(canId, d, static_cast<int>(n), addr, node)) {
|
||||
node.valid = true;
|
||||
node.lastRxTime = MOOSTime(false);
|
||||
m_sysData->updateBcuNode(addr, node);
|
||||
|
||||
// 锂电池解析数据落库(bcu_node 表,每帧一行,含节点最新合成状态)
|
||||
if (m_db) m_db->insertBcuNode(addr, bcuFuncOf(canId), node);
|
||||
|
||||
// 节流日志:1s 一条,便于联调观察
|
||||
double now = MOOSTime();
|
||||
if (now - m_lastBmsLog >= 1.0) {
|
||||
m_lastBmsLog = now;
|
||||
LOG_F(INFO, "[BMS/CAN] 节点%d u=%.1fV i=%.1fA soc=%.1f%% alarm=%02X self=%d vMax=%.3fV",
|
||||
addr, node.totalVoltage, node.current, node.soc,
|
||||
node.alarmCode, node.selfCheck, node.maxCellVoltage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Iterate:周期任务(AppTick 次/秒)
|
||||
|
||||
@@ -169,6 +279,13 @@ bool CCU::Iterate() {
|
||||
m_lastStatusTx = now;
|
||||
}
|
||||
|
||||
// BCU 断路器控制指令下发(网页 -> 队列 -> MOOS CAN_TX_*)
|
||||
sendPendingBcuCtrl();
|
||||
|
||||
// 电源协调器:协调算法占位
|
||||
if (m_coord)
|
||||
m_coord->tick(now);
|
||||
|
||||
// 周期(1Hz)推送网页快照
|
||||
if (m_web) {
|
||||
static double lastWebPush = 0;
|
||||
@@ -210,6 +327,8 @@ void CCU::handleFcMessage(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
|
||||
void CCU::handlePmMessage(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (!msg) return;
|
||||
// 配电指令已在入口由 DisBridge 处理(见 OnStartUp 的 onMessage 接线),
|
||||
// 到达此处的均为 PM 协议消息(Sum32 校验)
|
||||
switch (msg->id()) {
|
||||
case 0x0001: { // PM 操控指令 -> 转发给 FC
|
||||
PmControlValue c;
|
||||
@@ -234,6 +353,13 @@ void CCU::handlePmMessage(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
m_sysData->updateFcControl(f);
|
||||
m_fcLink->sendMessage(0x0001, &f);
|
||||
LOG_F(INFO, "[FC] forward control cmd=%d power=%d", f.cmd, f.outputPower);
|
||||
|
||||
// 锂电池启停/功率指令:BMS CAN 协议未定义控制报文,暂不支持
|
||||
if (c.insBatCmd != 0 || c.dynBatCmd != 0 || c.dynBatPower != 0) {
|
||||
LOG_F(WARNING, "[PM] battery cmd ignored (insBat=0x%02X dynBat=0x%02X dynPwr=%u): "
|
||||
"BMS CAN 协议未定义控制报文",
|
||||
c.insBatCmd, c.dynBatCmd, c.dynBatPower);
|
||||
}
|
||||
} else {
|
||||
LOG_F(ERROR, "[PM] control decode failed");
|
||||
}
|
||||
@@ -262,6 +388,19 @@ void CCU::handlePmMessage(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// handleRcuMessage:处理真实 CCU 链路收到的消息(配电桥接)
|
||||
//
|
||||
// 配电反馈(0x0005~0x0008)与真实CCU状态报文(0x0004)由 DisBridge
|
||||
// 处理(原帧转发 pPowerManger / 仅解码显示);其余(如配电指令
|
||||
// 回显)仅记录,不转发。
|
||||
|
||||
void CCU::handleRcuMessage(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (!msg) return;
|
||||
if (m_bridge && m_bridge->onRcuFrame(msg, frame)) return;
|
||||
LOG_F(WARNING, "[RCU] unhandled msg id 0x%04X, len=%zu", msg->id(), frame.size());
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// sendPmStatus:整合最新 FC 状态,编码 PM 状态报文发送
|
||||
|
||||
@@ -369,16 +508,99 @@ 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*(m_sSrcAux=目标通道) -> pCanBridge 订阅
|
||||
// -> 按通道路由 CanEndpoint(TCP) -> CANET -> CAN 总线
|
||||
|
||||
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);
|
||||
// 目标通道经 m_sSrcAux 指定(与上行 CAN_0x* 的通道标注对称)
|
||||
msg.SetSourceAux(m_bcuCanChannel);
|
||||
bool ok = m_Comms.Post(msg);
|
||||
m_sysData->noteBcuCtrlSent(c, ok);
|
||||
LOG_F(INFO, "[BCU/CAN] 断路器指令 0x%08X 节点%d 总正=%d 总负=%d 通道=%s %s",
|
||||
canId, c.addr, c.pos, c.neg, m_bcuCanChannel.c_str(),
|
||||
ok ? "已投递MOOS" : "投递失败");
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// buildReport
|
||||
|
||||
@@ -398,12 +620,34 @@ bool CCU::buildReport() {
|
||||
<< " tx:" << m_pmLink->txCount()
|
||||
<< " err:" << m_pmLink->errorCount() << "\n";
|
||||
}
|
||||
if (m_rcuLink) {
|
||||
m_msgs << "RCU 链路 local:" << m_rcuLink->localPort()
|
||||
<< " -> " << m_rcuLink->rcuHost() << ":" << m_rcuLink->rcuPort()
|
||||
<< " rx:" << m_rcuLink->rxCount()
|
||||
<< " tx:" << m_rcuLink->txCount()
|
||||
<< " err:" << m_rcuLink->errorCount() << "\n";
|
||||
if (m_sysData) {
|
||||
const RealCcuState& st = m_sysData->realCcu();
|
||||
m_msgs << "配电桥接: 反馈转发 " << st.fwdFbOk << " 成功 / " << st.fwdFbErr
|
||||
<< " 失败, 指令转发 " << st.fwdCmdOk << " 成功 / " << st.fwdCmdErr
|
||||
<< " 失败\n";
|
||||
}
|
||||
}
|
||||
if (m_sysData) {
|
||||
m_msgs << "锂电池: BMS/CAN (经 pCanBridge CAN_0x* 订阅)\n";
|
||||
m_msgs << "CAN 帧接收计数:" << m_sysData->canFrameCount() << "\n";
|
||||
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";
|
||||
m_msgs << "数据库BMS记录数:" << m_db->countBcu() << "\n";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -5,8 +5,11 @@
|
||||
#include "MOOS/libMOOS/Thirdparty/AppCasting/AppCastingMOOSApp.h"
|
||||
#include "comm/FcLinkManager.h"
|
||||
#include "comm/PmLinkManager.h"
|
||||
#include "comm/RcuLinkManager.h"
|
||||
#include "core/SystemData.h"
|
||||
#include "core/SnapshotBuilder.h"
|
||||
#include "core/PowerCoordinator.h"
|
||||
#include "core/DisBridge.h"
|
||||
#include "store/DbStore.h"
|
||||
#include "web/WebServer.h"
|
||||
|
||||
@@ -19,7 +22,13 @@ namespace ccu {
|
||||
// 收 FC 状态(0x0002) -> SystemData 快照 -> 周期整合为 PM 状态(0x0004)发给 pPowerManger
|
||||
// 收 PM 操控(0x0001) -> 转发为 FC 控制(0x0001)发给燃料电池
|
||||
// 收 PM 参数设定(0x0002) -> 回 PM 参数反馈(0x0003)
|
||||
// 收发全部帧落库 SQLite;网页周期推送 JSON 快照。
|
||||
// 收 PM 配电指令(0x0001~0x0004,Sum8短帧) -> DisBridge 原帧转发真实CCU(rcu 链路)
|
||||
// 收真实CCU 配电反馈(0x0005~0x0008) -> DisBridge 原帧转发 pPowerManger + 解码显示
|
||||
// 收真实CCU 状态报文(0x0004) -> 仅解码显示,不转发(避免状态冲突)
|
||||
// 订阅 pCanBridge 透传的 CAN_0x* 消息 -> 解析锂电池 BMS 报文
|
||||
// (docs/BMS_协议字段定义.xlsx:总电压/SOC/电流/故障码等)-> SystemData 快照
|
||||
// (BMS CAN 协议未定义控制报文,电池侧远程控制暂不可用)
|
||||
// 收发全部帧落库 SQLite(锂电池 BMS 解析数据另存 bcu_node 表);网页周期推送 JSON 快照。
|
||||
//============================================================================
|
||||
|
||||
class CCU : public AppCastingMOOSApp {
|
||||
@@ -39,6 +48,10 @@ private:
|
||||
// 链路收帧处理
|
||||
void handleFcMessage(Message* msg, const std::vector<uint8_t>& frame);
|
||||
void handlePmMessage(Message* msg, const std::vector<uint8_t>& frame);
|
||||
void handleRcuMessage(Message* msg, const std::vector<uint8_t>& frame);
|
||||
|
||||
// MOOS 订阅消息处理(CAN_0x* 由 pCanBridge 发布)
|
||||
void handleCanMessage(CMOOSMsg& msg);
|
||||
|
||||
// 整合并发送 PM 状态报文
|
||||
void sendPmStatus();
|
||||
@@ -47,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";
|
||||
@@ -56,11 +74,23 @@ private:
|
||||
std::string m_pmHost = "192.168.0.140";
|
||||
long m_pmPort = 5001;
|
||||
|
||||
// 真实 CCU 桥接链路(配电协议透传)
|
||||
bool m_rcuEnable = false; // 默认关闭,.moos 中开启
|
||||
long m_rcuLocalPort = 7100;
|
||||
std::string m_rcuHost = "192.168.0.200"; // 占位地址,.moos 中改为实际值
|
||||
long m_rcuPort = 7000;
|
||||
|
||||
std::string m_dbPath = "pccu_data.db";
|
||||
std::string m_logPath = "pCCU.log";
|
||||
int m_webPort = 8080; // 与 pPowerManger(8090)/pPowerMangerHost(18080) 错开
|
||||
bool m_webEnable = true;
|
||||
|
||||
// BCU 断路器控制帧下发的目标 CAN 通道(经 pCanBridge 的 m_sSrcAux 路由)
|
||||
std::string m_bcuCanChannel = "CAN0";
|
||||
|
||||
// CAN BMS 日志节流(避免 60帧/s 刷爆日志)
|
||||
double m_lastBmsLog = 0;
|
||||
|
||||
// 心跳
|
||||
uint8_t m_pmHeartbeat = 0;
|
||||
double m_lastStatusTx = 0;
|
||||
@@ -68,12 +98,15 @@ private:
|
||||
// 组件
|
||||
FcLinkManager* m_fcLink = nullptr;
|
||||
PmLinkManager* m_pmLink = nullptr;
|
||||
RcuLinkManager* m_rcuLink = nullptr;
|
||||
DisBridge* m_bridge = nullptr;
|
||||
SystemData* m_sysData = nullptr;
|
||||
DbStore* m_db = nullptr;
|
||||
SnapshotBuilder* m_snap = nullptr;
|
||||
WebServer* m_web = nullptr;
|
||||
PowerCoordinator* m_coord = nullptr;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_CCU_H
|
||||
#endif // PCCU_CCU_H
|
||||
|
||||
+18
-4
@@ -16,9 +16,12 @@ void showSynopsis() {
|
||||
blk("------------------------------------ ");
|
||||
blk(" The pCCU application is the composite controller (CCU) ");
|
||||
blk(" gateway between the fuel cell (FC) system and pPowerManger. ");
|
||||
blk(" It receives FC status, integrates & forwards to pPowerManger, ");
|
||||
blk(" forwards pPowerManger commands to FC, stores data in SQLite ");
|
||||
blk(" and provides a web monitoring page. ");
|
||||
blk(" It receives FC status, integrates & forwards to pPowerManger,");
|
||||
blk(" maps pPowerManger commands to FC operations, receives CAN ");
|
||||
blk(" bus BMS battery frames via pCanBridge (CAN_0x* MOOS msgs), ");
|
||||
blk(" bridges power-distribution (配电) frames between pPowerManger");
|
||||
blk(" and a real CCU device (rcu link), stores data in SQLite and ");
|
||||
blk(" provides a web monitoring page. ");
|
||||
blk(" ");
|
||||
}
|
||||
|
||||
@@ -67,6 +70,11 @@ void showExampleConfigAndExit() {
|
||||
blk(" pm_local_port = 7000 // PM 指令接收端口 ");
|
||||
blk(" pm_remote_ip = 192.168.0.140 // 控制主机 IP ");
|
||||
blk(" pm_remote_port = 5001 // 控制主机接收端口 ");
|
||||
blk(" rcu_enable = true // 是否启用真实CCU配电桥接 ");
|
||||
blk(" rcu_local_port = 7100 // 真实CCU 数据接收端口 ");
|
||||
blk(" rcu_remote_ip = 192.168.0.200 // 真实CCU IP(占位,按实际修改) ");
|
||||
blk(" rcu_remote_port= 7000 // 真实CCU 接收端口 ");
|
||||
blk(" // 锂电池 BMS 走 CAN 总线(经 pCanBridge 的 CAN_0x* 透传) ");
|
||||
blk(" dbpath = pccu_data.db // 数据库路径 ");
|
||||
blk(" logpath = pCCU.log // 日志路径 ");
|
||||
blk(" web_port = 8080 // 网页端口(避开8090/18080) ");
|
||||
@@ -86,12 +94,18 @@ void showInterfaceAndExit() {
|
||||
blk(" ");
|
||||
blk("SUBSCRIPTIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" (数据交换主要通过 UDP 链路完成,MOOSDB 交互有限) ");
|
||||
blk(" CAN_0x* = pCanBridge 透传的 CAN 帧(锂电池 BMS) ");
|
||||
blk(" CCU_FC_LINK_STATE = 运行状态字符串 ");
|
||||
blk(" CCU_PM_LINK_STATE = 运行状态字符串 ");
|
||||
blk(" CCU_RCU_LINK_STATE = 运行状态字符串 ");
|
||||
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 = 运行状态字符串 ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
4.具备sqlit数据库存储功能,可以把接收和发送的数据都存储到sqlit数据库中
|
||||
5.各端口配置、IP配置、日志及数据库配置可以通过.moos文件进行配置
|
||||
6.具备网页显示功能,可以显示接收、发送的数据、各数据状态等,数据展示要按照类型进行归类,要直观、简介
|
||||
7.编译部署与仓库内其他程序一样,可以跟随整个仓库一块部署
|
||||
7.编译部署与仓库内其他程序一样,可以跟随整个仓库一块部署
|
||||
8.接收动力/仪表锂电池的消息,协议见docs/锂电池协议20230324.docx;动力/仪表锂电池的ip和端口均可通过.moos文件配置
|
||||
9.锂电池需要额外操作(自检/上下电/电池切换/功率设定),pCCU根据操作按协议时序下发指令(0x0000自检/0x0001控制,1s心跳)
|
||||
10.pCCU按自身算法进行锂电池与燃料电池的协调操作,算法入口为core/PowerCoordinator.cpp的coordinationTick()(当前为占位,待完善)
|
||||
@@ -1,6 +1,6 @@
|
||||
#--------------------------------------------------------
|
||||
# The CMakeLists.txt for: pCCU
|
||||
# 复合管控器网关:FC <-> pPowerManger
|
||||
# 复合管控器网关:FC <-> pPowerManger,配电桥接 <-> 真实CCU
|
||||
#--------------------------------------------------------
|
||||
|
||||
if (${WIN32})
|
||||
@@ -24,6 +24,8 @@ SET(CCU_PROTOCOL_SRC
|
||||
protocol/MessageRegistry.cpp
|
||||
protocol/FcProtocol.cpp
|
||||
protocol/PmProtocol.cpp
|
||||
protocol/DisProtocol.cpp
|
||||
protocol/CanBms.cpp
|
||||
)
|
||||
|
||||
SET(CCU_COMM_SRC
|
||||
@@ -33,6 +35,9 @@ SET(CCU_COMM_SRC
|
||||
|
||||
SET(CCU_CORE_SRC
|
||||
core/SnapshotBuilder.cpp
|
||||
core/PowerCoordinator.cpp
|
||||
core/CoordFsm.cpp
|
||||
core/DisBridge.cpp
|
||||
)
|
||||
|
||||
SET(CCU_STORE_SRC
|
||||
|
||||
@@ -54,17 +54,46 @@ bool LinkManager::sendMessageTo(uint16_t id, const void* value,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LinkManager::sendRaw(const std::vector<uint8_t>& frame) {
|
||||
if (frame.empty()) return false;
|
||||
const std::string& host = defaultRemoteHost();
|
||||
long port = defaultRemotePort();
|
||||
if (host.empty() || port <= 0) return false;
|
||||
if (!m_endpoint.sendTo(host, port, frame)) {
|
||||
LOG_F(ERROR, "[%s] send raw frame (%zu B) to %s:%ld failed",
|
||||
m_linkName.c_str(), frame.size(), host.c_str(), port);
|
||||
return false;
|
||||
}
|
||||
++m_txCount;
|
||||
uint16_t id = 0;
|
||||
Frame::parseId(frame, id);
|
||||
if (m_logSink) m_logSink->onRawFrame(1, id, m_linkName, frame, true);
|
||||
if (m_onRawFrame) m_onRawFrame(1, frame, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LinkManager::handleIncoming(const std::vector<uint8_t>& frame) {
|
||||
if (frame.size() < FRAME_HEADER_LEN) return false;
|
||||
|
||||
uint16_t id = 0;
|
||||
if (!Frame::parseId(frame, id)) {
|
||||
LOG_F(WARNING, "[%s] bad frame header", m_linkName.c_str());
|
||||
// 非本协议帧(如真实CCU 混发的旧协议 0x20 0x20 状态帧、无效帧):
|
||||
// 原样落库便于排查,告警按 10s 节流避免刷屏
|
||||
++m_errorCount;
|
||||
if (m_logSink) m_logSink->onRawFrame(0, 0, m_linkName, frame, false);
|
||||
double now = MOOSTime(false);
|
||||
if (now - m_lastBadHdrLog >= 10.0) {
|
||||
m_lastBadHdrLog = now;
|
||||
LOG_F(WARNING, "[%s] 非本协议帧头(%02X %02X, %zu B),已忽略(10s节流)",
|
||||
m_linkName.c_str(), frame[0], frame[1], frame.size());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Message* msg = m_registry.find(id);
|
||||
// 优先按 id + 实际帧总长精确匹配(配电指令 0x0001~0x0004 与 PM 指令
|
||||
// 共用 id,仅帧长不同;帧头 length 字段不可信,须用实际收到的字节数),
|
||||
// 未命中回退该 id 的第一条(保持旧行为:decode 失败报错)
|
||||
Message* msg = m_registry.find(id, frame.size());
|
||||
if (!msg) {
|
||||
LOG_F(WARNING, "[%s] unknown msg id 0x%04X, len=%zu",
|
||||
m_linkName.c_str(), id, frame.size());
|
||||
|
||||
@@ -48,7 +48,9 @@ public:
|
||||
MessageRegistry& registry() { return m_registry; }
|
||||
const MessageRegistry& registry() const { return m_registry; }
|
||||
|
||||
// 收帧回调:msg 为注册表中命中的消息对象(可调用 decode 解码),frame 为原始完整帧
|
||||
// 收帧回调:msg 为注册表中命中的消息对象(可调用 decode 解码),frame 为原始完整帧。
|
||||
// 注意:同 id 不同长度的消息(如配电指令与 PM 指令)按帧总长精确区分,
|
||||
// 回调携带的 msg 即精确命中的对象;长度未命中时回退该 id 的第一条。
|
||||
void setOnMessage(std::function<void(Message* msg, const std::vector<uint8_t>& frame)> cb) {
|
||||
m_onMessage = std::move(cb);
|
||||
}
|
||||
@@ -68,6 +70,10 @@ public:
|
||||
bool sendMessageTo(uint16_t id, const void* value,
|
||||
const std::string& host, long port);
|
||||
|
||||
// 原样发送一帧到默认远端(桥接透传用,不做编码);
|
||||
// 消息 id 从帧头解析,收发计数与落库口径与 sendMessage 一致
|
||||
bool sendRaw(const std::vector<uint8_t>& frame);
|
||||
|
||||
// 处理一帧收到的原始数据(供接收线程调用,也可测试时手动喂入)
|
||||
bool handleIncoming(const std::vector<uint8_t>& frame);
|
||||
|
||||
@@ -102,6 +108,7 @@ private:
|
||||
ILinkLogSink* m_logSink = nullptr;
|
||||
|
||||
double m_lastRxTime = 0;
|
||||
double m_lastBadHdrLog = 0; // 非本协议帧头告警节流
|
||||
unsigned long m_rxCount = 0;
|
||||
unsigned long m_txCount = 0;
|
||||
unsigned long m_errorCount = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "LinkManager.h"
|
||||
#include "../protocol/PmProtocol.h"
|
||||
#include "../protocol/DisProtocol.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
@@ -11,6 +12,12 @@ namespace ccu {
|
||||
//
|
||||
// - 本地监听:控制主机指令端口(默认 7000)
|
||||
// - 发送目标:控制主机(默认 192.168.0.140:5001)
|
||||
//
|
||||
// 注册的消息:
|
||||
// - PM 协议 0x0001~0x0004(Sum32)
|
||||
// - 配电指令 0x0001~0x0004(Sum8,13~21B 短帧):pPowerManger 下发的
|
||||
// 配电指令与其操控/参数指令共用 id,仅帧长不同,由注册表按
|
||||
// (id+帧长) 区分,DisBridge 据此原桥接转发真实CCU
|
||||
// 端口/地址均可由 .moos 配置覆盖。
|
||||
//============================================================================
|
||||
|
||||
@@ -20,6 +27,7 @@ public:
|
||||
const std::string& pmHost, long pmPort)
|
||||
: LinkManager("pm", localPort), m_pmHost(pmHost), m_pmPort(pmPort) {
|
||||
registerPmMessages(registry());
|
||||
registerDisCmdMessages(registry());
|
||||
}
|
||||
|
||||
void setPmAddress(const std::string& host, long port) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef PCCU_RCU_LINK_MANAGER_H
|
||||
#define PCCU_RCU_LINK_MANAGER_H
|
||||
|
||||
#include "LinkManager.h"
|
||||
#include "../protocol/PmProtocol.h"
|
||||
#include "../protocol/DisProtocol.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
//============================================================================
|
||||
// RcuLinkManager:面向真实 CCU 设备的链路(配电桥接)。
|
||||
//
|
||||
// - 本地监听:rcu_local_port(默认 7100),接收真实CCU 上报的数据
|
||||
// - 发送目标:真实CCU(默认占位 192.168.0.200:7000,.moos 可配)
|
||||
//
|
||||
// 注册的消息:
|
||||
// - 配电反馈 0x0005~0x0008(Sum8):真实CCU 上报的配电状态,
|
||||
// 由 DisBridge 原帧转发给 pPowerManger 并解码显示
|
||||
// - 配电指令 0x0001~0x0004(Sum8):真实CCU 的指令回显仅识别,不转发
|
||||
// - PM 消息 0x0001~0x0004(Sum32):真实CCU 状态报文 0x0004 仅解码
|
||||
// 显示,不转发(避免与 pCCU 自身模拟的状态报文冲突)
|
||||
//============================================================================
|
||||
|
||||
class RcuLinkManager : public LinkManager {
|
||||
public:
|
||||
RcuLinkManager(long localPort,
|
||||
const std::string& rcuHost, long rcuPort)
|
||||
: LinkManager("rcu", localPort), m_rcuHost(rcuHost), m_rcuPort(rcuPort) {
|
||||
registerDisFbMessages(registry());
|
||||
registerDisCmdMessages(registry());
|
||||
registerPmMessages(registry());
|
||||
}
|
||||
|
||||
void setRcuAddress(const std::string& host, long port) {
|
||||
m_rcuHost = host; m_rcuPort = port;
|
||||
}
|
||||
const std::string& rcuHost() const { return m_rcuHost; }
|
||||
long rcuPort() const { return m_rcuPort; }
|
||||
|
||||
protected:
|
||||
std::string defaultRemoteHost() const override { return m_rcuHost; }
|
||||
long defaultRemotePort() const override { return m_rcuPort; }
|
||||
|
||||
private:
|
||||
std::string m_rcuHost;
|
||||
long m_rcuPort;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_RCU_LINK_MANAGER_H
|
||||
@@ -36,6 +36,7 @@ bool UdpEndpoint::init(long localPort) {
|
||||
bool UdpEndpoint::sendTo(const std::string& host, long port, const std::vector<uint8_t>& data) {
|
||||
if (!m_socket || data.empty()) return false;
|
||||
try {
|
||||
std::lock_guard<std::mutex> lock(m_sendMutex);
|
||||
m_socket->iSendMessageTo(const_cast<uint8_t*>(data.data()),
|
||||
static_cast<int>(data.size()),
|
||||
port, host);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#define UNIX
|
||||
#include "MOOS/libMOOS/Comms/XPCUdpSocket.h"
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -17,6 +18,8 @@ namespace ccu {
|
||||
// - 向指定远端 (host:port) 发送数据报
|
||||
// - 阻塞接收数据报
|
||||
// 不关心协议内容,仅负责字节的收发。
|
||||
// 发送可能来自多个线程(MOOS 主线程 + 各链路接收线程的桥接转发),
|
||||
// sendTo 内部加锁串行化。
|
||||
//============================================================================
|
||||
|
||||
class UdpEndpoint {
|
||||
@@ -39,6 +42,7 @@ public:
|
||||
private:
|
||||
XPCUdpSocket* m_socket = nullptr;
|
||||
long m_localPort = 0;
|
||||
std::mutex m_sendMutex; // 串行化并发 sendTo
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "CoordFsm.hpp"
|
||||
#include "SystemData.h"
|
||||
#include "loguru.hpp"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
// 静态上下文(接线时由 CoordFsm::init 注入)
|
||||
PowerCoordinator* CoordFsm::m_coord = nullptr;
|
||||
SystemData* CoordFsm::m_sys = nullptr;
|
||||
|
||||
//============================================================================
|
||||
// CoordNormalState 正常运行(初始状态)
|
||||
//============================================================================
|
||||
|
||||
void CoordNormalState::entry() {
|
||||
LOG_F(INFO, "[CoordFsm] 进入状态: CoordNormalState");
|
||||
}
|
||||
|
||||
void CoordNormalState::exit() {
|
||||
LOG_F(INFO, "[CoordFsm] 离开状态: CoordNormalState");
|
||||
}
|
||||
|
||||
void CoordNormalState::react(TickEvent const &e) {
|
||||
// TODO(策略填写):周期决策入口(AppTick 频率,默认 4Hz)。
|
||||
//
|
||||
// 读取最新状态:
|
||||
// sys()->fcStatus() 燃料电池状态
|
||||
// sys()->bcuNode(addr) 锂电池 BCU 节点状态(电压/SOC/电流/告警,CAN)
|
||||
// sys()->pmControl() 主机最新操控指令
|
||||
//
|
||||
// 下发操作:
|
||||
// coord()->sendFcControl(FcControlValue) 直接控燃料电池
|
||||
(void)e;
|
||||
}
|
||||
|
||||
void CoordNormalState::react(PmControlEvent const &e) {
|
||||
// TODO(策略填写):响应主机操控指令(边沿触发,指令到达一次触发一次)。
|
||||
LOG_F(INFO, "[CoordFsm][Normal] PmControlEvent: mode=%d cmd=%d power=%d",
|
||||
e.cmd.mode, e.cmd.cmd, e.cmd.outputPower);
|
||||
}
|
||||
|
||||
void CoordNormalState::react(StepDoneEvent const &e) {
|
||||
// TODO(策略填写):单步协调操作完成/超时的处理。
|
||||
// e.confirmed==false 表示超时未确认;
|
||||
// e.status.faultCode / statusBits 可判 BMS 故障。
|
||||
LOG_F(INFO, "[CoordFsm][Normal] StepDoneEvent: confirmed=%d soc=%.1f%% u=%.1fV",
|
||||
e.confirmed, e.status.soc, e.status.totalVoltage);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// CoordBusyState 协调操作进行中
|
||||
//============================================================================
|
||||
|
||||
void CoordBusyState::entry() {
|
||||
LOG_F(INFO, "[CoordFsm] 进入状态: CoordBusyState");
|
||||
}
|
||||
|
||||
void CoordBusyState::exit() {
|
||||
LOG_F(INFO, "[CoordFsm] 离开状态: CoordBusyState");
|
||||
}
|
||||
|
||||
void CoordBusyState::react(TickEvent const &e) {
|
||||
// TODO(策略填写):操作进行中的周期监视(进度/超时统计、是否需要中止)。
|
||||
// 状态可通过 sys()->bcuNode(addr) / sys()->fcStatus() 获取。
|
||||
(void)e;
|
||||
}
|
||||
|
||||
void CoordBusyState::react(StepDoneEvent const &e) {
|
||||
// TODO(策略填写):操作步骤完成推进。
|
||||
// 全部步骤完成后返回正常运行态;步骤失败可中止并进入故障态:
|
||||
// if (!e.confirmed) transit<CoordFaultState>();
|
||||
// else transit<CoordNormalState>();
|
||||
LOG_F(INFO, "[CoordFsm][Busy] StepDoneEvent: confirmed=%d", e.confirmed);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// CoordFaultState 故障处理
|
||||
//============================================================================
|
||||
|
||||
void CoordFaultState::entry() {
|
||||
LOG_F(INFO, "[CoordFsm] 进入状态: CoordFaultState");
|
||||
}
|
||||
|
||||
void CoordFaultState::exit() {
|
||||
LOG_F(INFO, "[CoordFsm] 离开状态: CoordFaultState");
|
||||
}
|
||||
|
||||
void CoordFaultState::react(TickEvent const &e) {
|
||||
// TODO(策略填写):故障处理与恢复判断(周期检查故障是否消除,
|
||||
// 消除后 transit<CoordNormalState>() 回正常运行态)。
|
||||
// 可用:sys()->fcStatus().fc_fault_level、sys()->bcuNode(addr).alarmBits 等。
|
||||
(void)e;
|
||||
}
|
||||
|
||||
void CoordFaultState::react(StepDoneEvent const &e) {
|
||||
// TODO(策略填写):故障态下操作步骤结果处理(如降级恢复的确认)。
|
||||
(void)e;
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
// 初始状态声明(TinyFSM 宏要求全局作用域 + 命名空间限定名)
|
||||
FSM_INITIAL_STATE(ccu::CoordFsm, ccu::CoordNormalState)
|
||||
@@ -0,0 +1,137 @@
|
||||
#ifndef PCCU_COORD_FSM_HPP
|
||||
#define PCCU_COORD_FSM_HPP
|
||||
|
||||
#include "../fsm/tinyfsm.hpp"
|
||||
#include "../protocol/FcProtocol.h"
|
||||
#include "../protocol/PmProtocol.h"
|
||||
#include "../protocol/CanBms.h"
|
||||
#include "PowerCoordinator.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
class SystemData;
|
||||
|
||||
//============================================================================
|
||||
// CoordFsm:pCCU 电源协调状态机骨架(TinyFSM,事件驱动)。
|
||||
//
|
||||
// 【当前状态:框架已参与编译,未接入运行流程(惰性)】
|
||||
// - 无任何代码调用 CoordFsm::start() / CoordFsm::dispatch(),
|
||||
// 运行行为与接入前完全一致;
|
||||
// - 各状态 react() 均为 TODO 空实现,由开发者填写协调策略。
|
||||
//
|
||||
// 【设计约定】
|
||||
// - 边沿事件走 dispatch:指令到达(PmControlEvent)、操作步骤完成(StepDoneEvent)、
|
||||
// 周期节拍(TickEvent);
|
||||
// - 电平状态不进事件:最新 FC/BMS 状态由状态机在 TickEvent 中直接读取
|
||||
// SystemData 快照,避免周期状态报文淹没事件队列;
|
||||
// - TinyFSM 非线程安全:dispatch 必须收敛到单线程(MOOS 主循环线程),
|
||||
// 接收线程产生的事件一律先排队,由 tick() 在主线程统一 drain 后
|
||||
// 逐个 dispatch。
|
||||
//
|
||||
// 【接线指南:将来启用时按以下步骤操作】
|
||||
//
|
||||
// 1) 事件桥(可在 PowerCoordinator 或独立 EventBus 中实现):
|
||||
// a. 线程安全事件队列:pushEvent(std::function<void()>)
|
||||
// b. tick() 末尾 drain 队列并逐个执行(单线程 dispatch)
|
||||
//
|
||||
// 2) CCU::OnStartUp:
|
||||
// CoordFsm::init(m_coord, m_sysData);
|
||||
// CoordFsm::start();
|
||||
//
|
||||
// 3) CCU::handlePmMessage 的 0x0001 分支(decode 成功后):
|
||||
// CoordFsm::dispatch(PmControlEvent(c));
|
||||
// (c 为已解析的 PmControlValue,按值捕获)
|
||||
//
|
||||
// 4) 验证:编译运行后日志出现 "[CoordFsm] 进入状态: CoordNormalState" 即接线成功。
|
||||
//
|
||||
// 【状态说明】
|
||||
// CoordNormalState 正常运行(初始状态):周期功率分配/阈值判断、响应主机指令
|
||||
// CoordBusyState 协调操作进行中:等待 StepDoneEvent 推进,完成后返回 Normal
|
||||
// CoordFaultState 故障处理:告警抑制/降级运行/恢复判断
|
||||
//============================================================================
|
||||
|
||||
//---- 事件定义(边沿触发) ------------------------------------------------
|
||||
|
||||
// 周期节拍:由 coordinationTick 转发(AppTick 频率,默认 4Hz)
|
||||
struct TickEvent : tinyfsm::Event {
|
||||
double now; // MOOSTime
|
||||
TickEvent() : now(0) {}
|
||||
explicit TickEvent(double t) : now(t) {}
|
||||
};
|
||||
|
||||
// 主机操控指令到达:PM 0x0001 每次成功解析触发一次
|
||||
struct PmControlEvent : tinyfsm::Event {
|
||||
PmControlValue cmd;
|
||||
PmControlEvent() {}
|
||||
explicit PmControlEvent(const PmControlValue& c) : cmd(c) {}
|
||||
};
|
||||
|
||||
// 协调操作步骤完成/超时:由协调动作引擎产生(预留)。
|
||||
// status 携带完成时刻的 BCU 节点状态快照(SOC/电压/电流/告警,可判故障)
|
||||
struct StepDoneEvent : tinyfsm::Event {
|
||||
bool confirmed; // true=确认完成;false=超时未确认
|
||||
BcuNodeStatus status; // 完成时刻 BCU 节点状态快照
|
||||
StepDoneEvent() : confirmed(false) {}
|
||||
StepDoneEvent(bool ok, const BcuNodeStatus& s) : confirmed(ok), status(s) {}
|
||||
};
|
||||
|
||||
//---- 状态机 ---------------------------------------------------------------
|
||||
|
||||
class CoordFsm : public tinyfsm::Fsm<CoordFsm> {
|
||||
public:
|
||||
// 启用时注入上下文(见接线指南)
|
||||
static void init(PowerCoordinator* coord, SystemData* sys) {
|
||||
m_coord = coord;
|
||||
m_sys = sys;
|
||||
}
|
||||
static PowerCoordinator* coord() { return m_coord; }
|
||||
static SystemData* sys() { return m_sys; }
|
||||
|
||||
// 事件处理默认实现:状态类按需覆盖;未覆盖的事件静默忽略
|
||||
virtual void react(tinyfsm::Event const &) {}
|
||||
virtual void react(TickEvent const &) {}
|
||||
virtual void react(PmControlEvent const &) {}
|
||||
virtual void react(StepDoneEvent const &) {}
|
||||
|
||||
// 状态进入/退出钩子(状态类覆盖以打日志/做进出动作)
|
||||
virtual void entry() {}
|
||||
virtual void exit() {}
|
||||
|
||||
private:
|
||||
static PowerCoordinator* m_coord;
|
||||
static SystemData* m_sys;
|
||||
};
|
||||
|
||||
//---- 状态类 ---------------------------------------------------------------
|
||||
|
||||
// 正常运行(初始状态)
|
||||
class CoordNormalState : public CoordFsm {
|
||||
public:
|
||||
void entry() override;
|
||||
void exit() override;
|
||||
void react(TickEvent const &e) override;
|
||||
void react(PmControlEvent const &e) override;
|
||||
void react(StepDoneEvent const &e) override;
|
||||
};
|
||||
|
||||
// 协调操作进行中(提交多步协调动作后进入)
|
||||
class CoordBusyState : public CoordFsm {
|
||||
public:
|
||||
void entry() override;
|
||||
void exit() override;
|
||||
void react(TickEvent const &e) override;
|
||||
void react(StepDoneEvent const &e) override;
|
||||
};
|
||||
|
||||
// 故障处理
|
||||
class CoordFaultState : public CoordFsm {
|
||||
public:
|
||||
void entry() override;
|
||||
void exit() override;
|
||||
void react(TickEvent const &e) override;
|
||||
void react(StepDoneEvent const &e) override;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_COORD_FSM_HPP
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "DisBridge.h"
|
||||
#include "../comm/LinkManager.h"
|
||||
#include "../protocol/DisProtocol.h"
|
||||
#include "SystemData.h"
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
#include "../pPowerManger/logc/loguru.hpp"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
void DisBridge::setup(LinkManager* pmLink, LinkManager* rcuLink, SystemData* sys) {
|
||||
m_pmLink = pmLink;
|
||||
m_rcuLink = rcuLink;
|
||||
m_sys = sys;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// PM 链路入口:pPowerManger -> 真实CCU(配电指令)
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
bool DisBridge::onPmFrame(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (!isDisCmdMessage(msg)) return false;
|
||||
forwardCmdToRcu(msg, frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DisBridge::forwardCmdToRcu(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (!m_rcuLink || !m_sys) return;
|
||||
|
||||
// 原帧透传给真实CCU(帧字节不变,Sum8 校验和保持有效)
|
||||
bool ok = m_rcuLink->sendRaw(frame);
|
||||
|
||||
// 取副本 -> 更新 -> 写回(跨线程共享快照的既有模式)
|
||||
RealCcuState st = m_sys->realCcu();
|
||||
st.cmdCount++;
|
||||
if (ok) st.fwdCmdOk++; else st.fwdCmdErr++;
|
||||
|
||||
// 解码缓存最近指令(仅用于展示;解码失败不影响透传结果)
|
||||
if (msg) {
|
||||
bool decoded = false;
|
||||
switch (msg->id()) {
|
||||
case DIS_HV_CMD_ID: {
|
||||
DisHighVolBusCmdMessage m;
|
||||
decoded = m.decode(frame, st.lastHvCmd);
|
||||
break;
|
||||
}
|
||||
case DIS_HVA_CMD_ID: {
|
||||
DisHighAVolBusCmdMessage m;
|
||||
decoded = m.decode(frame, st.lastHvaCmd);
|
||||
break;
|
||||
}
|
||||
case DIS_HVB_CMD_ID: {
|
||||
DisHighBVolBusCmdMessage m;
|
||||
decoded = m.decode(frame, st.lastHvbCmd);
|
||||
break;
|
||||
}
|
||||
case DIS_LV_CMD_ID: {
|
||||
DisLowBusCmdMessage m;
|
||||
decoded = m.decode(frame, st.lastLvCmd);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (decoded) {
|
||||
st.cmdValid = true;
|
||||
st.lastCmdRx = MOOSTime(false);
|
||||
} else {
|
||||
LOG_F(WARNING, "[桥接] 配电指令 0x%04X 解码失败(透传不受影响)", msg->id());
|
||||
}
|
||||
}
|
||||
m_sys->updateRealCcu(st);
|
||||
|
||||
LOG_F(INFO, "[桥接] 配电指令 0x%04X (%zu B) -> 真实CCU %s", msg ? msg->id() : 0,
|
||||
frame.size(), ok ? "成功" : "失败");
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// RCU 链路入口:真实CCU -> pPowerManger(配电反馈)+ 状态报文显示
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
bool DisBridge::onRcuFrame(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (isDisFbMessage(msg)) {
|
||||
forwardFbToPm(msg, frame);
|
||||
return true;
|
||||
}
|
||||
if (!isDisCmdMessage(msg) && msg && msg->id() == 0x0004) {
|
||||
// 真实CCU 状态报文:仅解码显示,不转发(避免与 pCCU 模拟状态冲突)
|
||||
storeRealCcuStatus(frame);
|
||||
return true;
|
||||
}
|
||||
return false; // 配电指令回显等:不处理,交上层记录
|
||||
}
|
||||
|
||||
void DisBridge::forwardFbToPm(Message* msg, const std::vector<uint8_t>& frame) {
|
||||
if (!m_pmLink || !m_sys) return;
|
||||
|
||||
// 原帧透传给 pPowerManger
|
||||
bool ok = m_pmLink->sendRaw(frame);
|
||||
|
||||
RealCcuState st = m_sys->realCcu();
|
||||
st.fbCount++;
|
||||
if (ok) st.fwdFbOk++; else st.fwdFbErr++;
|
||||
|
||||
// 解码缓存四条母线最新状态
|
||||
if (msg) {
|
||||
bool decoded = false;
|
||||
switch (msg->id()) {
|
||||
case DIS_HV_FB_ID: {
|
||||
DisHighVolBusFbMessage m;
|
||||
decoded = m.decode(frame, st.hvBus);
|
||||
if (decoded) st.hvLastRx = MOOSTime(false);
|
||||
break;
|
||||
}
|
||||
case DIS_HVA_FB_ID: {
|
||||
DisHighAVolBusFbMessage m;
|
||||
decoded = m.decode(frame, st.hvaBus);
|
||||
if (decoded) st.hvaLastRx = MOOSTime(false);
|
||||
break;
|
||||
}
|
||||
case DIS_HVB_FB_ID: {
|
||||
DisHighBVolBusFbMessage m;
|
||||
decoded = m.decode(frame, st.hvbBus);
|
||||
if (decoded) st.hvbLastRx = MOOSTime(false);
|
||||
break;
|
||||
}
|
||||
case DIS_LV_FB_ID: {
|
||||
DisLowBusFbMessage m;
|
||||
decoded = m.decode(frame, st.lvBus);
|
||||
if (decoded) st.lvLastRx = MOOSTime(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (decoded) {
|
||||
switch (msg->id()) {
|
||||
case DIS_HV_FB_ID: st.hvValid = true; break;
|
||||
case DIS_HVA_FB_ID: st.hvaValid = true; break;
|
||||
case DIS_HVB_FB_ID: st.hvbValid = true; break;
|
||||
case DIS_LV_FB_ID: st.lvValid = true; break;
|
||||
default: break;
|
||||
}
|
||||
} else {
|
||||
LOG_F(WARNING, "[桥接] 配电反馈 0x%04X 解码失败(透传不受影响)", msg->id());
|
||||
}
|
||||
}
|
||||
m_sys->updateRealCcu(st);
|
||||
|
||||
LOG_F(INFO, "[桥接] 配电反馈 0x%04X (%zu B) -> pPowerManger %s", msg ? msg->id() : 0,
|
||||
frame.size(), ok ? "成功" : "失败");
|
||||
}
|
||||
|
||||
void DisBridge::storeRealCcuStatus(const std::vector<uint8_t>& frame) {
|
||||
if (!m_sys) return;
|
||||
|
||||
PmStatusMessage m;
|
||||
PmStatusValue v;
|
||||
if (!m.decode(frame, v)) {
|
||||
LOG_F(WARNING, "[桥接] 真实CCU 状态报文 0x0004 解码失败");
|
||||
return;
|
||||
}
|
||||
|
||||
RealCcuState st = m_sys->realCcu();
|
||||
st.ccuStatus = v;
|
||||
st.ccuStatusValid = true;
|
||||
st.ccuStatusLastRx = MOOSTime(false);
|
||||
m_sys->updateRealCcu(st);
|
||||
|
||||
LOG_F(INFO, "[桥接] 真实CCU 状态报文: mode=%d status=%d heartbeat=%d (仅显示)",
|
||||
v.fc_mode, v.fc_status, v.heartbeat);
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
@@ -0,0 +1,55 @@
|
||||
#ifndef PCCU_DIS_BRIDGE_H
|
||||
#define PCCU_DIS_BRIDGE_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include "../protocol/Message.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
class LinkManager;
|
||||
class SystemData;
|
||||
|
||||
//============================================================================
|
||||
// DisBridge:配电协议桥接器(pPowerManger <-> 真实CCU)。
|
||||
//
|
||||
// pCCU 居中桥接,原帧透传(不做解析重编码,校验和无需重算):
|
||||
// pPowerManger --配电指令 0x0001~0x0004(Sum8,短帧)--> pCCU --原帧--> 真实CCU
|
||||
// 真实CCU --配电反馈 0x0005~0x0008(Sum8)------> pCCU --原帧--> pPowerManger
|
||||
//
|
||||
// 同时把配电反馈(及真实CCU 状态报文 0x0004)解码写入 SystemData
|
||||
// 供网页显示与统计;真实CCU 的 0x0004 仅显示不转发,避免与 pCCU
|
||||
// 自身模拟的状态报文冲突。
|
||||
//
|
||||
// 调用约定:onPmFrame 在 PM 链路接收线程回调,onRcuFrame 在 RCU
|
||||
// 链路接收线程回调;返回 true 表示该帧已被桥接处理,上层不再处理。
|
||||
//============================================================================
|
||||
|
||||
class DisBridge {
|
||||
public:
|
||||
// 注入依赖(在 CCU::OnStartUp 中链路创建完成后调用)
|
||||
void setup(LinkManager* pmLink, LinkManager* rcuLink, SystemData* sys);
|
||||
|
||||
// PM 链路收帧入口:命中配电指令则原帧转发真实CCU,返回是否已处理
|
||||
bool onPmFrame(Message* msg, const std::vector<uint8_t>& frame);
|
||||
|
||||
// RCU 链路收帧入口:命中配电反馈则原帧转发 pPowerManger 并解码
|
||||
// 显示;真实CCU 状态报文 0x0004 仅解码显示。返回是否已处理
|
||||
bool onRcuFrame(Message* msg, const std::vector<uint8_t>& frame);
|
||||
|
||||
private:
|
||||
// PM 链路收到配电指令:原帧转发 + 解码缓存
|
||||
void forwardCmdToRcu(Message* msg, const std::vector<uint8_t>& frame);
|
||||
// RCU 链路收到配电反馈:原帧转发 + 解码缓存
|
||||
void forwardFbToPm(Message* msg, const std::vector<uint8_t>& frame);
|
||||
// RCU 链路收到真实CCU 状态报文:仅解码缓存(显示)
|
||||
void storeRealCcuStatus(const std::vector<uint8_t>& frame);
|
||||
|
||||
LinkManager* m_pmLink = nullptr;
|
||||
LinkManager* m_rcuLink = nullptr;
|
||||
SystemData* m_sys = nullptr;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_DIS_BRIDGE_H
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "PowerCoordinator.h"
|
||||
#include "SystemData.h"
|
||||
#include "../comm/FcLinkManager.h"
|
||||
#include "../comm/PmLinkManager.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
PowerCoordinator::PowerCoordinator() {}
|
||||
|
||||
void PowerCoordinator::setup(SystemData* sys,
|
||||
FcLinkManager* fc,
|
||||
PmLinkManager* pm) {
|
||||
m_sys = sys;
|
||||
m_fc = fc;
|
||||
m_pm = pm;
|
||||
}
|
||||
|
||||
void PowerCoordinator::tick(double now) {
|
||||
coordinationTick(now);
|
||||
}
|
||||
|
||||
bool PowerCoordinator::sendFcControl(const FcControlValue& fcCmd) {
|
||||
if (!m_fc || !m_fc->isRunning()) return false;
|
||||
return m_fc->sendMessage(0x0001, &fcCmd);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// 协调算法占位
|
||||
//
|
||||
// 【占位说明】
|
||||
// 本函数为锂电池(BMS/CAN)与燃料电池协调策略的入口,
|
||||
// 由 CCU::Iterate 周期调用。
|
||||
//
|
||||
// 可用输入(通过 m_sys 读取最新状态):
|
||||
// - m_sys->fcStatus() : 燃料电池最新状态
|
||||
// - m_sys->bcuNode(addr) : 锂电池 BCU 节点最新状态(电压/SOC/电流/告警,
|
||||
// CAN 经 pCanBridge 透传,protocol/CanBms.h)
|
||||
// - m_sys->pmControl() : 控制主机最新操控指令
|
||||
//
|
||||
// 可用操作:
|
||||
// - sendFcControl(FcControlValue) : 直接下发燃料电池控制指令
|
||||
//
|
||||
// 说明:BMS CAN 协议未定义远程控制报文(启停/接触器/功率设定),
|
||||
// 电池侧控制暂不可用;协调策略围绕 FC 与 BMS 状态联动设计。
|
||||
//
|
||||
// TODO(开发者完善):在此实现具体的功率分配、燃料电池与
|
||||
// 锂电池联合调度等协调策略。当前为空实现。
|
||||
//---------------------------------------------------------
|
||||
void PowerCoordinator::coordinationTick(double now) {
|
||||
(void)now; // 占位:暂不执行任何协调动作
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
@@ -0,0 +1,54 @@
|
||||
#ifndef PCCU_POWER_COORDINATOR_H
|
||||
#define PCCU_POWER_COORDINATOR_H
|
||||
|
||||
#include <cstdint>
|
||||
#include "../protocol/FcProtocol.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
class SystemData;
|
||||
class FcLinkManager;
|
||||
class PmLinkManager;
|
||||
|
||||
//============================================================================
|
||||
// PowerCoordinator:电源协调器。
|
||||
//
|
||||
// 职责:
|
||||
// 1. FC 控制指令下发(供协调算法联动使用)。
|
||||
// 2. 协调算法占位:coordinationTick() 为锂电池(BMS/CAN)与燃料电池
|
||||
// 协调策略的占位入口,由后续完善(当前为空实现)。
|
||||
//
|
||||
// 说明:锂电池通信已切换为 CAN 总线(BMS 协议,经 pCanBridge 透传,
|
||||
// 见 protocol/CanBms.h)。BMS 报文仅含状态字段(电压/电流/SOC/故障码),
|
||||
// 未定义远程控制报文,因此原 UDP 电池指令链路(自检/接触器/功率设定)
|
||||
// 已移除;FC 控制仍走 UDP。
|
||||
//============================================================================
|
||||
|
||||
class PowerCoordinator {
|
||||
public:
|
||||
PowerCoordinator();
|
||||
|
||||
// 注入依赖(均在 CCU::OnStartUp 中创建完成后调用)
|
||||
void setup(SystemData* sys,
|
||||
FcLinkManager* fc,
|
||||
PmLinkManager* pm);
|
||||
|
||||
// 周期驱动:由 CCU::Iterate 周期调用(now = MOOSTime())
|
||||
void tick(double now);
|
||||
|
||||
// 直接向燃料电池下发控制指令(供协调算法联动使用)
|
||||
bool sendFcControl(const FcControlValue& fcCmd);
|
||||
|
||||
//---------------- 协调算法占位 ----------------
|
||||
// TODO(算法完善):锂电池(BMS/CAN)与燃料电池协调策略入口。
|
||||
void coordinationTick(double now);
|
||||
|
||||
private:
|
||||
SystemData* m_sys = nullptr;
|
||||
FcLinkManager* m_fc = nullptr;
|
||||
PmLinkManager* m_pm = nullptr;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_POWER_COORDINATOR_H
|
||||
@@ -1,8 +1,11 @@
|
||||
#include "SnapshotBuilder.h"
|
||||
#include "../store/DbStore.h"
|
||||
#include "../protocol/CanBms.h"
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
#include "json/json.h"
|
||||
#include <sstream>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
|
||||
namespace ccu {
|
||||
|
||||
@@ -13,12 +16,15 @@ namespace {
|
||||
void put(JsonVal& j, const char* k, uint8_t v) { j[k] = JsonVal(v); }
|
||||
void put(JsonVal& j, const char* k, uint16_t v){ j[k] = JsonVal(v); }
|
||||
void put(JsonVal& j, const char* k, uint32_t v){ j[k] = JsonVal(static_cast<Json::UInt>(v)); }
|
||||
void putI(JsonVal& j, const char* k, int16_t v) { j[k] = JsonVal(static_cast<int>(v)); }
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 消息描述:根据链路 + 消息 ID + 方向(0=收 1=发),给出中文名与"来源→去向"。
|
||||
// 方向是相对 pCCU 而言:收=对方→CCU,发=CCU→对方。
|
||||
// 消息描述:根据链路 + 消息 ID + 帧长 + 方向(0=收 1=发),给出中文名与
|
||||
// "来源→去向"。方向是相对 pCCU 而言:收=对方→CCU,发=CCU→对方。
|
||||
// length 用于区分同 id 消息:配电指令 0x0001~0x0004 为 13~21B 短帧,
|
||||
// 与 PM 操控/参数指令(28/45B)共用 id。
|
||||
//--------------------------------------------------------------------------
|
||||
std::string describeMessageName(const std::string& link, uint16_t id) {
|
||||
std::string describeMessageName(const std::string& link, uint16_t id, int length) {
|
||||
if (link == "fc") {
|
||||
switch (id) {
|
||||
case 0x0001: return "FC控制指令";
|
||||
@@ -27,10 +33,30 @@ std::string describeMessageName(const std::string& link, uint16_t id) {
|
||||
}
|
||||
} else if (link == "pm") {
|
||||
switch (id) {
|
||||
case 0x0001: return "PM操控指令";
|
||||
case 0x0002: return "PM参数设定";
|
||||
case 0x0003: return "PM参数反馈";
|
||||
case 0x0004: return "PM状态报文";
|
||||
case 0x0001:
|
||||
return (length > 0 && length <= 21) ? "PM配电指令(高压母线)" : "PM操控指令";
|
||||
case 0x0002:
|
||||
return (length > 0 && length <= 21) ? "PM配电指令(高压母线A)" : "PM参数设定";
|
||||
case 0x0003:
|
||||
return (length > 0 && length <= 21) ? "PM配电指令(低压主母线)" : "PM参数反馈";
|
||||
case 0x0004:
|
||||
return (length > 0 && length <= 21) ? "PM配电指令(仪表汇流排)" : "PM状态报文";
|
||||
case 0x0005: return "PM配电反馈(高压母线)";
|
||||
case 0x0006: return "PM配电反馈(高压母线A)";
|
||||
case 0x0007: return "PM配电反馈(低压主母线)";
|
||||
case 0x0008: return "PM配电反馈(仪表汇流排)";
|
||||
default: break;
|
||||
}
|
||||
} else if (link == "rcu") {
|
||||
switch (id) {
|
||||
case 0x0001: return "配电指令(高压母线)";
|
||||
case 0x0002: return "配电指令(高压母线A)";
|
||||
case 0x0003: return "配电指令(低压主母线)";
|
||||
case 0x0004: return "配电指令(仪表汇流排)";
|
||||
case 0x0005: return "配电反馈(高压母线)";
|
||||
case 0x0006: return "配电反馈(高压母线A)";
|
||||
case 0x0007: return "配电反馈(低压主母线)";
|
||||
case 0x0008: return "配电反馈(仪表汇流排)";
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +66,9 @@ std::string describeMessageName(const std::string& link, uint16_t id) {
|
||||
}
|
||||
|
||||
std::string describeDirection(const std::string& link, int direction) {
|
||||
const std::string peer = (link == "fc") ? "FC" : "PM";
|
||||
std::string peer = "PM";
|
||||
if (link == "fc") peer = "FC";
|
||||
else if (link == "rcu") peer = "真实CCU";
|
||||
return (direction == 1) ? ("CCU→" + peer) : (peer + "→CCU");
|
||||
}
|
||||
|
||||
@@ -53,7 +81,7 @@ JsonVal commLogToJson(const CommLogRow& r) {
|
||||
e["msgId"] = r.msgId;
|
||||
e["checksumOk"] = r.checksumOk;
|
||||
e["hex"] = r.hex;
|
||||
e["name"] = describeMessageName(r.link, r.msgId);
|
||||
e["name"] = describeMessageName(r.link, r.msgId, r.length);
|
||||
e["dirText"] = describeDirection(r.link, r.direction);
|
||||
return e;
|
||||
}
|
||||
@@ -206,6 +234,75 @@ JsonVal pmFbJson(const PmParamSetFbValue& v) {
|
||||
return j;
|
||||
}
|
||||
|
||||
// 锂电池 BCU 节点(CAN,docs/04KT38电池BCU-MBMS通信(CAN)定义.docx)-> JSON
|
||||
// 同一节点(0x10XX00YY 中 XX)的全部报文字段归并在一个对象内,
|
||||
// 告警位由附录1 表解析出中文含义放入 alarms 数组。
|
||||
// nowSec 为当前 MOOSTime,用于计算节点数据最后更新距今的秒数 age。
|
||||
JsonVal bcuNodeJson(uint8_t addr, const BcuNodeStatus& v, double nowSec) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["addr"] = JsonVal(addr);
|
||||
j["valid"] = JsonVal(v.valid);
|
||||
j["lastRx"] = JsonVal(v.lastRxTime);
|
||||
j["age"] = JsonVal(nowSec - v.lastRxTime); // 距最后收到该节点报文的秒数
|
||||
j["rx"] = JsonVal(static_cast<Json::UInt>(v.rxMask));
|
||||
|
||||
// 0x10XX0000:电压 / 电流 / SOC / 告警码 / 自检
|
||||
j["totalVoltage"] = JsonVal(v.totalVoltage);
|
||||
j["current"] = JsonVal(v.current);
|
||||
j["soc"] = JsonVal(v.soc);
|
||||
j["alarmCode"] = JsonVal(v.alarmCode);
|
||||
j["selfCheck"] = JsonVal(v.selfCheck);
|
||||
|
||||
// 0x10XX0001:单体电压
|
||||
j["maxCellVoltage"] = JsonVal(v.maxCellVoltage);
|
||||
j["maxCellVoltageNo"] = JsonVal(v.maxCellVoltageNo);
|
||||
j["minCellVoltage"] = JsonVal(v.minCellVoltage);
|
||||
j["minCellVoltageNo"] = JsonVal(v.minCellVoltageNo);
|
||||
j["avgCellVoltage"] = JsonVal(v.avgCellVoltage);
|
||||
|
||||
// 0x10XX0002:单体温度
|
||||
j["maxCellTemp"] = JsonVal(v.maxCellTemp);
|
||||
j["maxCellTempNo"] = JsonVal(v.maxCellTempNo);
|
||||
j["minCellTemp"] = JsonVal(v.minCellTemp);
|
||||
j["minCellTempNo"] = JsonVal(v.minCellTempNo);
|
||||
j["avgCellTemp"] = JsonVal(v.avgCellTemp);
|
||||
|
||||
// 0x10XX0003:继电器
|
||||
j["posRelay"] = JsonVal(v.posRelay);
|
||||
j["negRelay"] = JsonVal(v.negRelay);
|
||||
|
||||
// 0x10XX0006:绝缘 / 端口电压
|
||||
j["posInsulationKohm"] = JsonVal(v.posInsulationKohm);
|
||||
j["negInsulationKohm"] = JsonVal(v.negInsulationKohm);
|
||||
j["portVoltage"] = JsonVal(v.portVoltage);
|
||||
j["posRelayOuterVoltage"] = JsonVal(v.posRelayOuterVoltage);
|
||||
|
||||
// 0x10XX0010:告警位(原始 hex + 附录1 解释)
|
||||
JsonVal bits(Json::arrayValue);
|
||||
for (int i = 0; i < 6; ++i)
|
||||
bits.append(JsonVal(static_cast<Json::UInt>(v.alarmBits[i])));
|
||||
j["alarmBits"] = bits;
|
||||
|
||||
JsonVal alarms(Json::arrayValue);
|
||||
const char* texts[32];
|
||||
int n = bcuAlarmTexts(v, texts, 32);
|
||||
for (int i = 0; i < n; ++i)
|
||||
alarms.append(JsonVal(texts[i]));
|
||||
j["alarms"] = alarms;
|
||||
j["alarmCount"] = JsonVal(n);
|
||||
return j;
|
||||
}
|
||||
|
||||
// 全部 BCU 节点 -> JSON(只输出收到过报文的节点,按地址升序)
|
||||
JsonVal bcuNodesJson(const BcuNodeStatus nodes[kBcuNodeCount], double nowSec) {
|
||||
JsonVal j(Json::arrayValue);
|
||||
for (int i = 0; i < kBcuNodeCount; ++i) {
|
||||
if (nodes[i].valid)
|
||||
j.append(bcuNodeJson(static_cast<uint8_t>(i + kBcuAddrMin), nodes[i], nowSec));
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
// PM 状态报文(整合后发送给控制主机)-> JSON(关键字段)
|
||||
JsonVal pmStatusJson(const PmStatusValue& v) {
|
||||
JsonVal j(Json::objectValue);
|
||||
@@ -318,10 +415,247 @@ JsonVal linkJson(const LinkManager* lm) {
|
||||
return j;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 真实CCU 配电桥接 -> JSON(rcu 节)
|
||||
// 断路器/开关量按原始值下发(0x55 闭合 / 0xAA 断开 / 0x5A 故障),
|
||||
// 由前端渲染;age 为距最后收到该类帧的秒数(-1 表示从未收到)。
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
JsonVal ageJson(bool valid, double lastRx, double now) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["valid"] = JsonVal(valid);
|
||||
j["age"] = JsonVal(valid ? (now - lastRx) : -1.0);
|
||||
return j;
|
||||
}
|
||||
|
||||
// 断路器组( 名称 -> 原始值 )
|
||||
JsonVal disBreakersJson(const std::vector<std::pair<const char*, uint8_t>>& items) {
|
||||
JsonVal j(Json::objectValue);
|
||||
for (const auto& it : items) put(j, it.first, it.second);
|
||||
return j;
|
||||
}
|
||||
|
||||
JsonVal hvBusJson(const disHighVolBusState& v) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["breakers"] = disBreakersJson({
|
||||
{"fuelCell", v.fuelCellCircuitBreaker},
|
||||
{"powerLithiumBattery", v.powerLithiumBatteryCircuitBreaker},
|
||||
{"propulsionMotor", v.propulsionMotorCircuitBreaker},
|
||||
{"lithiumBatteryGroupInstrument", v.lithiumBatteryGroupInstrumentCircuitBreaker},
|
||||
{"dcDc5Module", v.dcDc5ModuleCircuitBreaker},
|
||||
{"bowHighVoltageDistributionBox", v.bowHighVoltageDistributionBoxCircuitBreaker},
|
||||
{"sternFTDevice45", v.sternFTDevice45CircuitBreaker},
|
||||
{"fbReserved", v.fbReservedCircuitBreaker},
|
||||
{"sternRudderSwitch1", v.sternRudderSwitch1CircuitBreaker},
|
||||
{"sternRudderSwitch2", v.sternRudderSwitch2CircuitBreaker},
|
||||
{"tyzReserved", v.tyzReservedCircuitBreaker},
|
||||
{"reserved", v.reservedCircuitBreaker},
|
||||
});
|
||||
put(j, "dcDcFaultWord1", v.dcDcFaultWord1);
|
||||
put(j, "dcDcFaultWord2", v.dcDcFaultWord2);
|
||||
put(j, "powerBusInsulationStatus", v.powerBusInsulationStatus);
|
||||
put(j, "aBusInsulationStatus", v.aBusInsulationStatus);
|
||||
put(j, "meterPowerLossSignal", v.meterPowerLossSignal);
|
||||
put(j, "emergencyPowerLossSignal", v.emergencyPowerLossSignal);
|
||||
put(j, "dcDcTemperature", v.dcDcTemperature);
|
||||
put(j, "powerBusVoltage", v.powerBusVoltage);
|
||||
put(j, "dcDc5ModuleCurrent", v.dcDc5ModuleCurrent);
|
||||
put(j, "powerBusCurrent", v.powerBusCurrent);
|
||||
put(j, "busbarAVoltage", v.busbarAVoltage);
|
||||
put(j, "propulsionMotorControlBoxCurrent", v.propulsionMotorControlBoxCurrent);
|
||||
put(j, "busbarACurrent", v.busbarACurrent);
|
||||
put(j, "lithiumBatteryGroupMeterCurrent", v.lithiumBatteryGroupMeterCurrent);
|
||||
put(j, "bowHighVoltageDistributionBoxCurrent", v.bowHighVoltageDistributionBoxCurrent);
|
||||
put(j, "sternFTDevice45Current", v.sternFTDevice45Current);
|
||||
put(j, "tyzReservedSwitchCurrent", v.tyzReservedSwitchCurrent);
|
||||
put(j, "sternRudderSwitch1Current", v.sternRudderSwitch1Current);
|
||||
put(j, "reservedCurrent1", v.reservedCurrent1);
|
||||
put(j, "sternRudderSwitch2Current", v.sternRudderSwitch2Current);
|
||||
put(j, "reservedCurrent2", v.reservedCurrent2);
|
||||
put(j, "fbReservedSwitchCurrent", v.fbReservedSwitchCurrent);
|
||||
put(j, "reservedCurrent3", v.reservedCurrent3);
|
||||
put(j, "coolWaterPressure", v.coolWaterPressure);
|
||||
return j;
|
||||
}
|
||||
|
||||
JsonVal hvaBusJson(const disHighAVolBusState& v) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["breakers"] = disBreakersJson({
|
||||
{"bowFTDevice123", v.bowFTDevice123CircuitBreaker},
|
||||
{"actuator", v.actuatorCircuitBreaker},
|
||||
{"mastSteeringGearControlBox", v.mastSteeringGearControlBoxCircuitBreaker},
|
||||
{"xcz", v.xczCircuitBreaker},
|
||||
{"bowRudderControlBox", v.bowRudderControlBoxCircuitBreaker},
|
||||
{"openWaterCoverStartCylinder", v.openWaterCoverStartCylinderCircuitBreaker},
|
||||
});
|
||||
put(j, "instrumentPowerFailureSignal", v.instrumentPowerFailureSignal);
|
||||
put(j, "emergencyPowerFailureSignal", v.emergencyPowerFailureSignal);
|
||||
put(j, "waterIngressionAlarm", v.waterIngressionAlarm);
|
||||
put(j, "busbarBVoltage", v.busbarBVoltage);
|
||||
put(j, "busbarBCurrent", v.busbarBCurrent);
|
||||
put(j, "bowFTDevice123Current", v.bowFTDevice123Current);
|
||||
put(j, "actuatorCurrent", v.actuatorCurrent);
|
||||
put(j, "mastSteeringGearControlBoxCurrent", v.mastSteeringGearControlBoxCurrent);
|
||||
put(j, "xczCurrent", v.xczCurrent);
|
||||
put(j, "bowRudderControlBoxCurrent", v.bowRudderControlBoxCurrent);
|
||||
put(j, "openWaterCoverStartCylinderCurrent", v.openWaterCoverStartCylinderCurrent);
|
||||
return j;
|
||||
}
|
||||
|
||||
JsonVal hvbBusJson(const disLowMainBusState& v) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["breakers"] = disBreakersJson({
|
||||
{"lithiumBatteryGroupInstrument", v.lithiumBatteryGroupInstrumentCircuitBreaker},
|
||||
{"bowLowVoltageDistributionBox", v.bowLowVoltageDistributionBoxCircuitBreaker},
|
||||
{"unit4InstrumentDC48V", v.unit4InstrumentDC48VCircuitBreaker},
|
||||
{"dcC1DCDistributionPanel", v.dcC1DCDistributionPanelCircuitBreaker},
|
||||
{"reserved1", v.reservedCircuitBreaker1},
|
||||
{"reserved2", v.reservedCircuitBreaker2},
|
||||
{"emergencyLithiumBatteryGroup2", v.emergencyLithiumBatteryGroup2CircuitBreaker},
|
||||
});
|
||||
put(j, "instrumentPowerFailureSignal", v.instrumentPowerFailureSignal);
|
||||
put(j, "emergencyPowerFailureSignal", v.emergencyPowerFailureSignal);
|
||||
put(j, "instrumentBusbarInsulationLow", v.instrumentBusbarInsulationLow);
|
||||
put(j, "instrumentBusbarVoltage", v.instrumentBusbarVoltage);
|
||||
put(j, "bowLowVoltageDistributionBoxCurrent", v.bowLowVoltageDistributionBoxCurrent);
|
||||
put(j, "dcC1InstrumentDC48VCurrent", v.dcC1InstrumentDC48VCurrent);
|
||||
put(j, "reservedCurrent1", v.reservedCurrent1);
|
||||
put(j, "emergencyLithiumBatteryGroup2Current", v.emergencyLithiumBatteryGroup2Current);
|
||||
put(j, "lithiumBatteryGroupInstrumentCurrent", v.lithiumBatteryGroupInstrumentCurrent);
|
||||
put(j, "unit4InstrumentDC48VCurrent", v.unit4InstrumentDC48VCurrent);
|
||||
put(j, "reservedCurrent2", v.reservedCurrent2);
|
||||
put(j, "emergency2BusbarVoltage", v.emergency2BusbarVoltage);
|
||||
put(j, "compositeEnergyEmergencyDC48VCurrent", v.compositeEnergyManagementSystemEmergencyDC48VCurrent);
|
||||
put(j, "fuelCellSecurityEmergencyDC48VCurrent", v.fuelCellSecuritySystemEmergencyDC48VCurrent);
|
||||
put(j, "plcControlPowerCurrent", v.plcControlPowerCurrent);
|
||||
return j;
|
||||
}
|
||||
|
||||
JsonVal lvBusJson(const disLowBusState& v) {
|
||||
JsonVal j(Json::objectValue);
|
||||
j["breakers"] = disBreakersJson({
|
||||
{"unit1", v.unit1CircuitBreaker},
|
||||
{"unit2", v.unit2CircuitBreaker},
|
||||
{"unit3", v.unit3CircuitBreaker},
|
||||
{"unit5", v.unit5CircuitBreaker},
|
||||
{"bowPZDevice", v.bowPZDeviceCircuitBreaker},
|
||||
{"reserved1", v.reservedCircuitBreaker1},
|
||||
{"reserved2", v.reservedCircuitBreaker2},
|
||||
{"emergencyLithiumBatteryGroup1", v.emergencyLithiumBatteryGroup1CircuitBreaker},
|
||||
});
|
||||
put(j, "powerFailureSignal", v.powerFailureSignal);
|
||||
put(j, "emergencyPowerFailureSignal", v.emergencyPowerFailureSignal);
|
||||
put(j, "waterIngressionAlarm", v.waterIngressionAlarm);
|
||||
put(j, "instrumentBusbarVoltage", v.instrumentBusbarVoltage);
|
||||
put(j, "instrumentBusbarCurrent", v.instrumentBusbarCurrent);
|
||||
put(j, "unit1Current", v.unit1Current);
|
||||
put(j, "unit2Current", v.unit2Current);
|
||||
put(j, "unit3Current", v.unit3Current);
|
||||
put(j, "unit5Current", v.unit5Current);
|
||||
put(j, "bowPZDeviceCurrent", v.bowPZDeviceCurrent);
|
||||
put(j, "emergencyLithiumBatteryGroup1Current", v.emergencyLithiumBatteryGroup1Current);
|
||||
put(j, "emergency1BusbarVoltage", v.emergency1BusbarVoltage);
|
||||
return j;
|
||||
}
|
||||
|
||||
JsonVal realCcuJson(const RealCcuState& st, double now) {
|
||||
JsonVal j(Json::objectValue);
|
||||
|
||||
// 桥接统计
|
||||
JsonVal fwd(Json::objectValue);
|
||||
fwd["fbCount"] = JsonVal(static_cast<Json::UInt>(st.fbCount));
|
||||
fwd["fwdFbOk"] = JsonVal(static_cast<Json::UInt>(st.fwdFbOk));
|
||||
fwd["fwdFbErr"] = JsonVal(static_cast<Json::UInt>(st.fwdFbErr));
|
||||
fwd["cmdCount"] = JsonVal(static_cast<Json::UInt>(st.cmdCount));
|
||||
fwd["fwdCmdOk"] = JsonVal(static_cast<Json::UInt>(st.fwdCmdOk));
|
||||
fwd["fwdCmdErr"] = JsonVal(static_cast<Json::UInt>(st.fwdCmdErr));
|
||||
j["forward"] = fwd;
|
||||
|
||||
// 四条母线最新反馈(全字段)+ 新鲜度
|
||||
j["hvAge"] = ageJson(st.hvValid, st.hvLastRx, now);
|
||||
j["hvaAge"] = ageJson(st.hvaValid, st.hvaLastRx, now);
|
||||
j["hvbAge"] = ageJson(st.hvbValid, st.hvbLastRx, now);
|
||||
j["lvAge"] = ageJson(st.lvValid, st.lvLastRx, now);
|
||||
j["hv"] = st.hvValid ? hvBusJson(st.hvBus) : JsonVal(Json::objectValue);
|
||||
j["hva"] = st.hvaValid ? hvaBusJson(st.hvaBus) : JsonVal(Json::objectValue);
|
||||
j["hvb"] = st.hvbValid ? hvbBusJson(st.hvbBus) : JsonVal(Json::objectValue);
|
||||
j["lv"] = st.lvValid ? lvBusJson(st.lvBus) : JsonVal(Json::objectValue);
|
||||
|
||||
// 最近配电指令(pPowerManger -> 真实CCU)
|
||||
JsonVal lastCmd(Json::objectValue);
|
||||
lastCmd["valid"] = JsonVal(st.cmdValid);
|
||||
lastCmd["age"] = JsonVal(st.cmdValid ? (now - st.lastCmdRx) : -1.0);
|
||||
JsonVal cmds(Json::objectValue);
|
||||
cmds["hv"] = disBreakersJson({
|
||||
{"fuelCell", st.lastHvCmd.fuelCellCircuitBreaker},
|
||||
{"powerLithiumBattery", st.lastHvCmd.powerLithiumBatteryCircuitBreaker},
|
||||
{"propulsionMotor", st.lastHvCmd.propulsionMotorCircuitBreaker},
|
||||
{"lithiumBatteryGroupInstrument", st.lastHvCmd.lithiumBatteryGroupInstrumentCircuitBreaker},
|
||||
{"dcDc5Module", st.lastHvCmd.dcDc5ModuleCircuitBreaker},
|
||||
{"bowHighVoltageDistributionBox", st.lastHvCmd.bowHighVoltageDistributionBoxCircuitBreaker},
|
||||
{"sternFTDevice45", st.lastHvCmd.sternFTDevice45CircuitBreaker},
|
||||
{"fbReserved", st.lastHvCmd.fbReservedCircuitBreaker},
|
||||
{"sternRudderSwitch1", st.lastHvCmd.sternRudderSwitch1CircuitBreaker},
|
||||
{"sternRudderSwitch2", st.lastHvCmd.sternRudderSwitch2CircuitBreaker},
|
||||
{"tyzReserved", st.lastHvCmd.tyzReservedCircuitBreaker},
|
||||
{"reserved", st.lastHvCmd.reservedCircuitBreaker},
|
||||
{"dcdc5ModuleStart", st.lastHvCmd.dcdc5Module},
|
||||
{"coolingSystemStart", st.lastHvCmd.coolingSystem},
|
||||
});
|
||||
cmds["hva"] = disBreakersJson({
|
||||
{"bowFTDevice123", st.lastHvaCmd.bowFTDevice123CircuitBreaker},
|
||||
{"actuator", st.lastHvaCmd.actuatorCircuitBreaker},
|
||||
{"mastSteeringGearControlBox", st.lastHvaCmd.mastSteeringGearControlBoxCircuitBreaker},
|
||||
{"xcz", st.lastHvaCmd.xczCircuitBreaker},
|
||||
{"bowRudderControlBox", st.lastHvaCmd.bowRudderControlBoxCircuitBreaker},
|
||||
{"openWaterCoverStartCylinder", st.lastHvaCmd.openWaterCoverStartCylinderCircuitBreaker},
|
||||
});
|
||||
cmds["hvb"] = disBreakersJson({
|
||||
{"lithiumBatteryGroupInstrument", st.lastHvbCmd.lithiumBatteryGroupInstrumentCircuitBreaker},
|
||||
{"bowLowVoltageDistributionBox", st.lastHvbCmd.bowLowVoltageDistributionBoxCircuitBreaker},
|
||||
{"unit4InstrumentDC48V", st.lastHvbCmd.unit4InstrumentDC48VCircuitBreaker},
|
||||
{"dcC1DCDistributionPanel", st.lastHvbCmd.dcC1DCDistributionPanelCircuitBreaker},
|
||||
{"reserved1", st.lastHvbCmd.reservedCircuitBreaker1},
|
||||
{"reserved2", st.lastHvbCmd.reservedCircuitBreaker2},
|
||||
{"emergencyLithiumBatteryGroup2", st.lastHvbCmd.emergencyLithiumBatteryGroup2CircuitBreaker},
|
||||
});
|
||||
cmds["lv"] = disBreakersJson({
|
||||
{"unit1", st.lastLvCmd.unit1CircuitBreaker},
|
||||
{"unit2", st.lastLvCmd.unit2CircuitBreaker},
|
||||
{"unit3", st.lastLvCmd.unit3CircuitBreaker},
|
||||
{"unit5", st.lastLvCmd.unit5CircuitBreaker},
|
||||
{"bowPZDevice", st.lastLvCmd.bowPZDeviceCircuitBreaker},
|
||||
{"reserved1", st.lastLvCmd.reservedCircuitBreaker1},
|
||||
{"reserved2", st.lastLvCmd.reservedCircuitBreaker2},
|
||||
{"emergencyLithiumBatteryGroup1", st.lastLvCmd.emergencyLithiumBatteryGroup1CircuitBreaker},
|
||||
});
|
||||
lastCmd["cmds"] = cmds;
|
||||
j["lastCmd"] = lastCmd;
|
||||
|
||||
// 真实CCU 状态报文 0x0004(仅显示)
|
||||
JsonVal ccu(Json::objectValue);
|
||||
ccu["valid"] = JsonVal(st.ccuStatusValid);
|
||||
ccu["age"] = JsonVal(st.ccuStatusValid ? (now - st.ccuStatusLastRx) : -1.0);
|
||||
if (st.ccuStatusValid) {
|
||||
put(ccu, "fc_mode", st.ccuStatus.fc_mode);
|
||||
put(ccu, "fc_status", st.ccuStatus.fc_status);
|
||||
put(ccu, "fc_fault_level", st.ccuStatus.fc_fault_level);
|
||||
put(ccu, "generation_power", st.ccuStatus.generation_power);
|
||||
put(ccu, "dyn_soc", st.ccuStatus.dyn_soc);
|
||||
put(ccu, "ins_soc", st.ccuStatus.ins_soc);
|
||||
put(ccu, "heartbeat", st.ccuStatus.heartbeat);
|
||||
put(ccu, "emergency_cmd", st.ccuStatus.emergency_cmd);
|
||||
}
|
||||
j["ccuStatus"] = ccu;
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SnapshotBuilder::SnapshotBuilder(SystemData* sys, LinkManager* fc, LinkManager* pm, DbStore* db)
|
||||
: m_sys(sys), m_fc(fc), m_pm(pm), m_db(db) {}
|
||||
SnapshotBuilder::SnapshotBuilder(SystemData* sys, LinkManager* fc, LinkManager* pm,
|
||||
LinkManager* rcu, DbStore* db)
|
||||
: m_sys(sys), m_fc(fc), m_pm(pm), m_rcu(rcu), m_db(db) {}
|
||||
|
||||
std::string SnapshotBuilder::build() const {
|
||||
JsonVal root(Json::objectValue);
|
||||
@@ -333,10 +667,32 @@ std::string SnapshotBuilder::build() const {
|
||||
root["pmParam"] = pmParamJson(m_sys->pmParamSet());
|
||||
root["pmFb"] = pmFbJson(m_sys->pmParamFb());
|
||||
root["pmStatus"] = pmStatusJson(m_sys->pmStatus());
|
||||
// 锂电池 BCU 节点(CAN,经 pCanBridge 透传,按节点分组)
|
||||
BcuNodeStatus nodes[kBcuNodeCount];
|
||||
m_sys->bcuNodes(nodes);
|
||||
root["bms"] = bcuNodesJson(nodes, MOOSTime(false));
|
||||
root["fcStatusCount"] = JsonVal(static_cast<Json::UInt>(m_sys->fcStatusCount()));
|
||||
root["pmControlCount"] = JsonVal(static_cast<Json::UInt>(m_sys->pmControlCount()));
|
||||
root["fcControlCount"] = JsonVal(static_cast<Json::UInt>(m_sys->fcControlCount()));
|
||||
root["pmStatusCount"] = JsonVal(static_cast<Json::UInt>(m_sys->pmStatusCount()));
|
||||
root["bmsStatusCount"] = JsonVal(static_cast<Json::UInt>(m_sys->bmsStatusCount()));
|
||||
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);
|
||||
@@ -344,11 +700,15 @@ std::string SnapshotBuilder::build() const {
|
||||
root["pmParam"] = JsonVal(Json::objectValue);
|
||||
root["pmFb"] = JsonVal(Json::objectValue);
|
||||
root["pmStatus"] = JsonVal(Json::objectValue);
|
||||
root["bms"] = JsonVal(Json::arrayValue);
|
||||
root["realCcu"] = JsonVal(Json::objectValue);
|
||||
root["bcuCtrl"] = JsonVal(Json::objectValue);
|
||||
}
|
||||
|
||||
JsonVal links(Json::objectValue);
|
||||
links["fc"] = linkJson(m_fc);
|
||||
links["pm"] = linkJson(m_pm);
|
||||
if (m_rcu) links["rcu"] = linkJson(m_rcu);
|
||||
root["links"] = links;
|
||||
|
||||
// 最近原始帧(默认 20 条)
|
||||
|
||||
@@ -12,13 +12,14 @@ class DbStore;
|
||||
//============================================================================
|
||||
// SnapshotBuilder:构建网页推送的 JSON 快照。
|
||||
//
|
||||
// 组合 SystemData(最新状态)、两条链路统计、最近原始帧日志,
|
||||
// 序列化为 JSON 字符串(jsoncpp)。串行化逻辑集中在此处,便于维护。
|
||||
// 组合 SystemData(最新状态:FC + 锂电池 BMS/CAN + 真实CCU 配电桥接)、
|
||||
// 三条 UDP 链路统计、最近原始帧日志,序列化为 JSON 字符串(jsoncpp)。
|
||||
//============================================================================
|
||||
|
||||
class SnapshotBuilder {
|
||||
public:
|
||||
SnapshotBuilder(SystemData* sys, LinkManager* fc, LinkManager* pm, DbStore* db);
|
||||
SnapshotBuilder(SystemData* sys, LinkManager* fc, LinkManager* pm,
|
||||
LinkManager* rcu, DbStore* db);
|
||||
|
||||
// 生成完整快照 JSON
|
||||
std::string build() const;
|
||||
@@ -30,6 +31,7 @@ private:
|
||||
SystemData* m_sys;
|
||||
LinkManager* m_fc;
|
||||
LinkManager* m_pm;
|
||||
LinkManager* m_rcu;
|
||||
DbStore* m_db;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,20 +3,86 @@
|
||||
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
#include "../protocol/FcProtocol.h"
|
||||
#include "../protocol/PmProtocol.h"
|
||||
#include "../protocol/CanBms.h"
|
||||
#include "../protocol/DisProtocol.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
//============================================================================
|
||||
// RealCcuState:真实 CCU 设备的桥接数据缓存(配电协议)。
|
||||
//
|
||||
// 数据来源:RCU 链路(真实CCU -> pCCU),由 DisBridge 在转发的同时
|
||||
// 解码写入;网页快照读取展示。各 lastRx 时间由写入方更新(MOOSTime),
|
||||
// 供在线判定与新鲜度展示。
|
||||
//============================================================================
|
||||
|
||||
struct RealCcuState {
|
||||
// 四条配电母线最新反馈(valid 表示收到过对应反馈帧)
|
||||
disHighVolBusState hvBus{}; bool hvValid = false;
|
||||
disHighAVolBusState hvaBus{}; bool hvaValid = false;
|
||||
disLowMainBusState hvbBus{}; bool hvbValid = false;
|
||||
disLowBusState lvBus{}; bool lvValid = false;
|
||||
double hvLastRx = 0, hvaLastRx = 0, hvbLastRx = 0, lvLastRx = 0;
|
||||
|
||||
// 最近收到的配电指令(pPowerManger 下发、已转发真实CCU,用于展示)
|
||||
disHighVolBusCmd lastHvCmd{};
|
||||
disHighAVolBusCmd lastHvaCmd{};
|
||||
disLowMainBusCmd lastHvbCmd{};
|
||||
disLowBusCmd lastLvCmd{};
|
||||
bool cmdValid = false;
|
||||
double lastCmdRx = 0;
|
||||
|
||||
// 真实CCU 状态报文 0x0004(仅解码显示,不转发)
|
||||
PmStatusValue ccuStatus{};
|
||||
bool ccuStatusValid = false;
|
||||
double ccuStatusLastRx = 0;
|
||||
|
||||
// 桥接统计
|
||||
unsigned long fbCount = 0; // 收到配电反馈帧数
|
||||
unsigned long fwdFbOk = 0; // 反馈转发 pPowerManger 成功帧数
|
||||
unsigned long fwdFbErr = 0; // 反馈转发失败帧数
|
||||
unsigned long cmdCount = 0; // 收到配电指令帧数
|
||||
unsigned long fwdCmdOk = 0; // 指令转发真实CCU 成功帧数
|
||||
unsigned long fwdCmdErr = 0; // 指令转发失败帧数
|
||||
};
|
||||
|
||||
//============================================================================
|
||||
// SystemData:跨线程共享的最新状态快照。
|
||||
//
|
||||
// - 接收线程(FC 链路)写 FcStatus 快照
|
||||
// - MOOS 主线程(OnNewMail)写 BCU 节点快照(CAN 经 pCanBridge 透传)
|
||||
// - CCU 主循环(Iterate)读快照并整合编码为 PM 状态报文发送
|
||||
// - 网页线程读快照展示
|
||||
// 通过互斥锁保护读写。
|
||||
//============================================================================
|
||||
|
||||
//============================================================================
|
||||
// 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:
|
||||
// 更新/获取燃料电池最新状态
|
||||
@@ -99,6 +165,85 @@ public:
|
||||
return m_pmParamFb;
|
||||
}
|
||||
|
||||
//-------- 锂电池 BCU 节点(CAN 总线,经 pCanBridge 透传) --------
|
||||
|
||||
// 更新单个 BCU 节点状态(每帧部分更新,由调用方先取出快照再合并)
|
||||
void updateBcuNode(uint8_t addr, const BcuNodeStatus& v) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (addr >= kBcuAddrMin && addr <= kBcuAddrMax) {
|
||||
m_bcuNodes[addr - kBcuAddrMin] = v;
|
||||
m_bmsStatusCount++;
|
||||
}
|
||||
}
|
||||
// 读取单个 BCU 节点快照(地址非法返回 valid=false 的空快照)
|
||||
BcuNodeStatus bcuNode(uint8_t addr) const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (addr >= kBcuAddrMin && addr <= kBcuAddrMax)
|
||||
return m_bcuNodes[addr - kBcuAddrMin];
|
||||
return BcuNodeStatus();
|
||||
}
|
||||
// 读取全部节点快照(数组下标 = 地址-1)
|
||||
void bcuNodes(BcuNodeStatus out[kBcuNodeCount]) const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
for (int i = 0; i < kBcuNodeCount; ++i) out[i] = m_bcuNodes[i];
|
||||
}
|
||||
unsigned long bmsStatusCount() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_bmsStatusCount;
|
||||
}
|
||||
|
||||
// CAN 链路收帧计数(含非 BMS 报文,用于链路状态展示)
|
||||
void incCanFrameCount() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_canFrameCount++;
|
||||
}
|
||||
unsigned long canFrameCount() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_canFrameCount;
|
||||
}
|
||||
|
||||
//-------- 真实 CCU 桥接数据(配电协议,RCU 链路) --------
|
||||
|
||||
// 整体更新真实CCU 快照(DisBridge 在接收线程先取副本->修改->写回)
|
||||
void updateRealCcu(const RealCcuState& v) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_realCcu = v;
|
||||
}
|
||||
RealCcuState realCcu() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
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;
|
||||
@@ -107,10 +252,16 @@ private:
|
||||
FcControlValue m_fcControl;
|
||||
PmStatusValue m_pmStatus;
|
||||
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;
|
||||
unsigned long m_pmStatusCount = 0;
|
||||
unsigned long m_bmsStatusCount = 0;
|
||||
unsigned long m_canFrameCount = 0;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* TinyFSM - Tiny Finite State Machine Processor
|
||||
*
|
||||
* Copyright (c) 2012-2022 Axel Burri
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Version: 0.3.3
|
||||
*
|
||||
* API documentation: see "../doc/50-API.md"
|
||||
*
|
||||
* The official TinyFSM website is located at:
|
||||
* https://digint.ch/tinyfsm/
|
||||
*
|
||||
* Author:
|
||||
* Axel Burri <axel@tty0.ch>
|
||||
* ---------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
#ifndef TINYFSM_HPP_INCLUDED
|
||||
#define TINYFSM_HPP_INCLUDED
|
||||
|
||||
#ifndef TINYFSM_NOSTDLIB
|
||||
#include <type_traits>
|
||||
#endif
|
||||
|
||||
// #include <iostream>
|
||||
// #define DBG(str) do { std::cerr << str << std::endl; } while( false )
|
||||
// DBG("*** dbg_example *** " << __PRETTY_FUNCTION__);
|
||||
|
||||
namespace tinyfsm
|
||||
{
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
struct Event { };
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
#ifdef TINYFSM_NOSTDLIB
|
||||
// remove dependency on standard library (silent fail!).
|
||||
// useful in conjunction with -nostdlib option, e.g. if your compiler
|
||||
// does not provide a standard library.
|
||||
// NOTE: this silently disables all static_assert() calls below!
|
||||
template<typename F, typename S>
|
||||
struct is_same_fsm { static constexpr bool value = true; };
|
||||
#else
|
||||
// check if both fsm and state class share same fsmtype
|
||||
template<typename F, typename S>
|
||||
struct is_same_fsm : std::is_same< typename F::fsmtype, typename S::fsmtype > { };
|
||||
#endif
|
||||
|
||||
template<typename S>
|
||||
struct _state_instance
|
||||
{
|
||||
using value_type = S;
|
||||
using type = _state_instance<S>;
|
||||
static S value;
|
||||
};
|
||||
|
||||
template<typename S>
|
||||
typename _state_instance<S>::value_type _state_instance<S>::value;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
template<typename F>
|
||||
class Fsm
|
||||
{
|
||||
public:
|
||||
|
||||
using fsmtype = Fsm<F>;
|
||||
using state_ptr_t = F *;
|
||||
|
||||
static state_ptr_t current_state_ptr;
|
||||
|
||||
// public, leaving ability to access state instance (e.g. on reset)
|
||||
template<typename S>
|
||||
static constexpr S & state(void) {
|
||||
static_assert(is_same_fsm<F, S>::value, "accessing state of different state machine");
|
||||
return _state_instance<S>::value;
|
||||
}
|
||||
|
||||
template<typename S>
|
||||
static constexpr bool is_in_state(void) {
|
||||
static_assert(is_same_fsm<F, S>::value, "accessing state of different state machine");
|
||||
return current_state_ptr == &_state_instance<S>::value;
|
||||
}
|
||||
|
||||
/// state machine functions
|
||||
public:
|
||||
|
||||
// explicitely specialized in FSM_INITIAL_STATE macro
|
||||
static void set_initial_state();
|
||||
|
||||
static void reset() { };
|
||||
|
||||
static void enter() {
|
||||
current_state_ptr->entry();
|
||||
}
|
||||
|
||||
static void start() {
|
||||
set_initial_state();
|
||||
enter();
|
||||
}
|
||||
|
||||
template<typename E>
|
||||
static void dispatch(E const & event) {
|
||||
current_state_ptr->react(event);
|
||||
}
|
||||
|
||||
|
||||
/// state transition functions
|
||||
protected:
|
||||
|
||||
template<typename S>
|
||||
void transit(void) {
|
||||
static_assert(is_same_fsm<F, S>::value, "transit to different state machine");
|
||||
current_state_ptr->exit();
|
||||
current_state_ptr = &_state_instance<S>::value;
|
||||
current_state_ptr->entry();
|
||||
}
|
||||
|
||||
template<typename S, typename ActionFunction>
|
||||
void transit(ActionFunction action_function) {
|
||||
static_assert(is_same_fsm<F, S>::value, "transit to different state machine");
|
||||
current_state_ptr->exit();
|
||||
// NOTE: do not send events in action_function definisions.
|
||||
action_function();
|
||||
current_state_ptr = &_state_instance<S>::value;
|
||||
current_state_ptr->entry();
|
||||
}
|
||||
|
||||
template<typename S, typename ActionFunction, typename ConditionFunction>
|
||||
void transit(ActionFunction action_function, ConditionFunction condition_function) {
|
||||
if(condition_function()) {
|
||||
transit<S>(action_function);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
template<typename F>
|
||||
typename Fsm<F>::state_ptr_t Fsm<F>::current_state_ptr;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
template<typename... FF>
|
||||
struct FsmList;
|
||||
|
||||
template<> struct FsmList<> {
|
||||
static void set_initial_state() { }
|
||||
static void reset() { }
|
||||
static void enter() { }
|
||||
template<typename E>
|
||||
static void dispatch(E const &) { }
|
||||
};
|
||||
|
||||
template<typename F, typename... FF>
|
||||
struct FsmList<F, FF...>
|
||||
{
|
||||
using fsmtype = Fsm<F>;
|
||||
|
||||
static void set_initial_state() {
|
||||
fsmtype::set_initial_state();
|
||||
FsmList<FF...>::set_initial_state();
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
F::reset();
|
||||
FsmList<FF...>::reset();
|
||||
}
|
||||
|
||||
static void enter() {
|
||||
fsmtype::enter();
|
||||
FsmList<FF...>::enter();
|
||||
}
|
||||
|
||||
static void start() {
|
||||
set_initial_state();
|
||||
enter();
|
||||
}
|
||||
|
||||
template<typename E>
|
||||
static void dispatch(E const & event) {
|
||||
fsmtype::template dispatch<E>(event);
|
||||
FsmList<FF...>::template dispatch<E>(event);
|
||||
}
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
template<typename... SS> struct StateList;
|
||||
template<> struct StateList<> {
|
||||
static void reset() { }
|
||||
};
|
||||
template<typename S, typename... SS>
|
||||
struct StateList<S, SS...>
|
||||
{
|
||||
static void reset() {
|
||||
_state_instance<S>::value = S();
|
||||
StateList<SS...>::reset();
|
||||
}
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
template<typename F>
|
||||
struct MooreMachine : tinyfsm::Fsm<F>
|
||||
{
|
||||
virtual void entry(void) { }; /* entry actions in some states */
|
||||
void exit(void) { }; /* no exit actions */
|
||||
};
|
||||
|
||||
template<typename F>
|
||||
struct MealyMachine : tinyfsm::Fsm<F>
|
||||
{
|
||||
// input actions are modeled in react():
|
||||
// - conditional dependent of event type or payload
|
||||
// - transit<>(ActionFunction)
|
||||
void entry(void) { }; /* no entry actions */
|
||||
void exit(void) { }; /* no exit actions */
|
||||
};
|
||||
|
||||
} /* namespace tinyfsm */
|
||||
|
||||
|
||||
#define FSM_INITIAL_STATE(_FSM, _STATE) \
|
||||
namespace tinyfsm { \
|
||||
template<> void Fsm< _FSM >::set_initial_state(void) { \
|
||||
current_state_ptr = &_state_instance< _STATE >::value; \
|
||||
} \
|
||||
}
|
||||
|
||||
#endif /* TINYFSM_HPP_INCLUDED */
|
||||
+26
-3
@@ -2,9 +2,15 @@
|
||||
// pCCU 配置示例
|
||||
//
|
||||
// 链路拓扑:
|
||||
// 燃料电池控制器(FC) 192.168.1.162:7000
|
||||
// 控制主机(pPowerManger) 192.168.0.140:5001
|
||||
// pCCU 本机监听:FC 状态端口 6000、PM 指令端口 7000
|
||||
// 燃料电池控制器(FC) 192.168.1.162:7000(UDP)
|
||||
// 控制主机(pPowerManger) 192.168.0.140:5001(UDP)
|
||||
// 真实CCU设备(rcu) 192.168.0.200:7000(UDP,配电桥接,占位地址需按实际修改)
|
||||
// 锂电池 BMS CAN 总线(经 pCanBridge 订阅 CAN_0x* 透传)
|
||||
//
|
||||
// 配电桥接(rcu_enable = true 时启用):
|
||||
// pPowerManger 下发的配电指令(0x0001~0x0004) 经 pCCU 原帧转发真实CCU;
|
||||
// 真实CCU 上报的配电反馈(0x0005~0x0008) 经 pCCU 原帧转发 pPowerManger,
|
||||
// 同时解码显示在网页"真实CCU"页签;真实CCU 状态报文(0x0004)仅显示不转发。
|
||||
//============================================================================
|
||||
|
||||
ProcessConfig = pCCU
|
||||
@@ -26,6 +32,23 @@ ProcessConfig = pCCU
|
||||
pm_remote_ip = 192.168.0.140
|
||||
pm_remote_port = 5001
|
||||
|
||||
//======== 真实CCU 桥接链路(配电协议) ========
|
||||
// 是否启用桥接(关闭时 pCCU 行为与纯模拟版一致)
|
||||
rcu_enable = true
|
||||
// 本机接收真实CCU 数据的端口
|
||||
rcu_local_port = 7100
|
||||
// 真实CCU 地址(转发配电指令目标;占位地址,按实际设备修改)
|
||||
rcu_remote_ip = 192.168.0.200
|
||||
rcu_remote_port = 7000
|
||||
|
||||
//======== 锂电池 ========
|
||||
// CAN 总线 BMS 协议(docs/BMS_协议字段定义.xlsx),
|
||||
// 经 pCanBridge 发布的 CAN_0x* 消息透传,无需额外配置。
|
||||
|
||||
//======== BCU 断路器控制下发 ========
|
||||
// 下行 CAN_TX_0x* 消息的目标通道(经 m_sSrcAux 由 pCanBridge 路由)
|
||||
bcu_can_channel = CAN0
|
||||
|
||||
//======== 存储与日志 ========
|
||||
// 数据库路径(SQLite)
|
||||
dbpath = pccu_data.db
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#include "CanBms.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
namespace {
|
||||
|
||||
// 大端 u16
|
||||
inline uint16_t rdU16BE(const uint8_t* p) {
|
||||
return static_cast<uint16_t>((static_cast<uint16_t>(p[0]) << 8) | p[1]);
|
||||
}
|
||||
|
||||
// 大端 16 位有符号整数(补码)
|
||||
inline int16_t rdS16BE(const uint8_t* p) {
|
||||
return static_cast<int16_t>(rdU16BE(p));
|
||||
}
|
||||
|
||||
// 有符号 8 位(温度)
|
||||
inline int8_t rdS8(uint8_t b) { return static_cast<int8_t>(b); }
|
||||
|
||||
// 设置功能码已收位
|
||||
inline void markRx(BcuNodeStatus& n, uint8_t func) {
|
||||
n.rxMask |= (1u << (func & 31));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
//============================================================================
|
||||
// 附录1:0x10XX0010 告警位定义(04KT38电池BCU-MBMS通信(CAN)定义.docx)
|
||||
//============================================================================
|
||||
const BcuAlarmDesc kBcuAlarmTable[] = {
|
||||
// byte0:2 级告警
|
||||
{0, 0, "BCU绝缘2级"},
|
||||
{0, 1, "BCU最高温度过高2级"},
|
||||
{0, 2, "BCU最低温度过低2级"},
|
||||
{0, 3, "BCU单体电压不均衡2级"},
|
||||
{0, 4, "BCU单体温度不均衡2级"},
|
||||
{0, 5, "BCU放电过流2级"},
|
||||
{0, 6, "BCU单体电压过高2级"},
|
||||
{0, 7, "BCU单体电压过低2级"},
|
||||
// byte1
|
||||
{1, 0, "BCU总电压过高2级"},
|
||||
{1, 1, "BCU总电压过低2级"},
|
||||
{1, 2, "BCU烟雾报警1级"},
|
||||
{1, 3, "烟雾报警器不在线"},
|
||||
{1, 4, "烟雾报警器上报自身故障"},
|
||||
{1, 5, "BCU单包绝缘3级"},
|
||||
{1, 6, "BCU最高温度过高3级"},
|
||||
{1, 7, "BCU最低温度过低3级"},
|
||||
// byte2
|
||||
{2, 0, "单体电压不均衡3级"},
|
||||
{2, 1, "单体温度不均衡3级"},
|
||||
{2, 2, "单包放电过流3级"},
|
||||
{2, 3, "BCU单体电压过高3级"},
|
||||
{2, 4, "BCU单体电压过低3级"},
|
||||
{2, 5, "BCU总电压过高3级"},
|
||||
{2, 6, "BCU总电压过低3级"},
|
||||
{2, 7, "BCU单体温度传感器检测故障"},
|
||||
// byte3
|
||||
{3, 0, "BCU正极继电器失效"},
|
||||
{3, 1, "BCU负极继电器失效"},
|
||||
{3, 3, "单包BMS内部通讯故障"},
|
||||
{3, 4, "BCU电流传感器故障"},
|
||||
{3, 5, "BCU电压传感器故障"},
|
||||
{3, 7, "BCU高压回路连接异常"},
|
||||
// byte4
|
||||
{4, 1, "烟雾报警2级"},
|
||||
{4, 4, "烟雾报警3级"},
|
||||
{4, 6, "烟雾报警4级"},
|
||||
{4, 7, "回馈电流过大2级"},
|
||||
// byte5
|
||||
{5, 0, "回馈电流过大3级"},
|
||||
{5, 1, "充电电流过大3级"},
|
||||
{0xFF, 0, nullptr}, // 表尾
|
||||
};
|
||||
|
||||
int bcuAlarmTexts(const BcuNodeStatus& node, const char* texts[], int cap) {
|
||||
if (!texts || cap <= 0) return 0;
|
||||
int count = 0;
|
||||
for (int i = 0; kBcuAlarmTable[i].text; ++i) {
|
||||
const BcuAlarmDesc& d = kBcuAlarmTable[i];
|
||||
if (d.byteIdx < sizeof(node.alarmBits) &&
|
||||
(node.alarmBits[d.byteIdx] >> d.bitIdx) & 0x01) {
|
||||
if (count >= cap) break;
|
||||
texts[count++] = d.text;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool decodeCanBcuFrame(uint32_t canId, const uint8_t* data, int dlc,
|
||||
uint8_t& nodeAddr, BcuNodeStatus& node) {
|
||||
if (!data || dlc <= 0) return false;
|
||||
|
||||
uint8_t addr = bcuAddrOf(canId);
|
||||
if (!addr || !isCanBcuId(canId)) return false;
|
||||
|
||||
nodeAddr = addr;
|
||||
|
||||
// 缩放用除法(而非乘 0.1/0.001),保证结果与十进制字面量严格一致。
|
||||
switch (bcuFuncOf(canId)) {
|
||||
case BCU_FUNC_BASE: // 0x10XX0000:电压 / 电流 / SOC / 告警码 / 自检
|
||||
if (dlc >= 8) {
|
||||
node.totalVoltage = rdU16BE(data + 0) / 10.0; // 0.1V
|
||||
node.current = rdS16BE(data + 2) / 10.0 - 600.0; // 0.1A,偏移-600A
|
||||
node.soc = rdU16BE(data + 4) / 10.0; // 0.1%
|
||||
node.alarmCode = data[6];
|
||||
node.selfCheck = data[7];
|
||||
markRx(node, BCU_FUNC_BASE);
|
||||
}
|
||||
break;
|
||||
|
||||
case BCU_FUNC_CELL_V: // 0x10XX0001:最高/最低/平均单体电压
|
||||
if (dlc >= 2) node.maxCellVoltage = rdU16BE(data + 0) / 1000.0; // 1mV
|
||||
if (dlc >= 3) node.maxCellVoltageNo = data[2];
|
||||
if (dlc >= 5) node.minCellVoltage = rdU16BE(data + 3) / 1000.0; // 1mV
|
||||
if (dlc >= 6) node.minCellVoltageNo = data[5];
|
||||
if (dlc >= 8) node.avgCellVoltage = rdU16BE(data + 6) / 1000.0; // 1mV
|
||||
markRx(node, BCU_FUNC_CELL_V);
|
||||
break;
|
||||
|
||||
case BCU_FUNC_CELL_T: // 0x10XX0002:最高/最低/平均单体温度
|
||||
if (dlc >= 1) node.maxCellTemp = rdS8(data[0]) - 40.0; // 偏移-40℃
|
||||
if (dlc >= 2) node.maxCellTempNo = data[1];
|
||||
if (dlc >= 3) node.minCellTemp = rdS8(data[2]) - 40.0; // 偏移-40℃
|
||||
if (dlc >= 4) node.minCellTempNo = data[3];
|
||||
if (dlc >= 5) node.avgCellTemp = rdS8(data[4]) - 40.0; // 偏移-40℃(文档写偏移0,
|
||||
// 但实测固件与最高/最低一致均带-40偏移,见0x10020002:
|
||||
// 原始68/69减40后为28/29℃,落在最低28~最高30区间内)
|
||||
markRx(node, BCU_FUNC_CELL_T);
|
||||
break;
|
||||
|
||||
case BCU_FUNC_RELAY: // 0x10XX0003:正/负极继电器状态
|
||||
if (dlc >= 1) node.posRelay = data[0];
|
||||
if (dlc >= 2) node.negRelay = data[1];
|
||||
markRx(node, BCU_FUNC_RELAY);
|
||||
break;
|
||||
|
||||
case BCU_FUNC_ISU: // 0x10XX0006:绝缘阻值 / 端口电压
|
||||
if (dlc >= 2) node.posInsulationKohm = rdU16BE(data + 0); // 1kΩ
|
||||
if (dlc >= 4) node.negInsulationKohm = rdU16BE(data + 2); // 1kΩ
|
||||
if (dlc >= 6) node.portVoltage = rdU16BE(data + 4) / 10.0; // 0.1V
|
||||
if (dlc >= 8) node.posRelayOuterVoltage = rdU16BE(data + 6) / 10.0; // 0.1V
|
||||
markRx(node, BCU_FUNC_ISU);
|
||||
break;
|
||||
|
||||
case BCU_FUNC_ALARM: // 0x10XX0010:告警位(附录1)
|
||||
for (int i = 0; i < 6 && i < dlc; ++i) node.alarmBits[i] = data[i];
|
||||
if (dlc >= 7) node.alarmExtraByte6 = data[6];
|
||||
if (dlc >= 8) node.alarmExtraByte7 = data[7];
|
||||
markRx(node, BCU_FUNC_ALARM);
|
||||
break;
|
||||
|
||||
default: // Reserve(0x10XX0004/0005 等)不解析
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
@@ -0,0 +1,175 @@
|
||||
#ifndef PCCU_CAN_BMS_H
|
||||
#define PCCU_CAN_BMS_H
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace ccu {
|
||||
|
||||
//============================================================================
|
||||
// 锂电池 BCU-MBMS CAN 协议(docs/04KT38电池BCU-MBMS通信(CAN)定义.docx)
|
||||
//
|
||||
// 数据来源:pCanBridge 透传的 MOOS 消息
|
||||
// 变量名 CAN_0x%08X = CAN ID(扩展帧),m_sVal = 二进制数据域,
|
||||
// m_dfVal2 = 原始帧信息字节。本模块只负责按报文 ID 解析数据域字段。
|
||||
//
|
||||
// 【节点地址】一个 MBMS 下最多挂 54 个 BCU,CAN ID 中间的 XX(01h~36h)
|
||||
// 为 BCU 节点地址编号,其余位固定:
|
||||
// 0x10XX00YY BCU 主动上报消息(YY=功能码 00/01/02/03/06/10)
|
||||
// 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
|
||||
// 回路电流 s16@2 ÷10-600 A(偏移 -600A,放电为正)
|
||||
// SOC u16@4 ÷10 %
|
||||
// 告警码 u8 @6(循环上报,协议未附码表,原样显示)
|
||||
// 自检状态 u8 @7
|
||||
// 0x10XX0001 (200ms) 最高单体电压 u16@0 mV,编号 u8@2
|
||||
// 最低单体电压 u16@3 mV,编号 u8@5
|
||||
// 单体平均电压 u16@6 mV
|
||||
// 0x10XX0002 (1000ms) 最高单体温度 s8@0(偏移 -40℃),编号 u8@1
|
||||
// 最低单体温度 s8@2(偏移 -40℃),编号 u8@3
|
||||
// 单体平均温度 s8@4(偏移 -40℃,协议文档写"偏移0"
|
||||
// 有误:实测原始值减 40 后才落在最低~最高区间内)
|
||||
// 0x10XX0003 (200ms) 正极继电器状态 u8@0(1 闭合 / 0 断开)
|
||||
// 负极继电器状态 u8@1(1 闭合 / 0 断开)
|
||||
// 0x10XX0006 (200ms) 正极绝缘阻抗 u16@0 kΩ
|
||||
// 负极绝缘阻抗 u16@2 kΩ
|
||||
// 正负极端口电压 u16@4 ÷10 V
|
||||
// 正极继电器外侧电压 u16@6 ÷10 V
|
||||
// 0x10XX0010 (200ms) byte0~5 告警位(bit 定义见附录1,
|
||||
// kBcuAlarmTable,可经 bcuAlarmTexts() 取告警描述)
|
||||
// 0x10XX0004/0005 Reserve(调试预留,不解析)
|
||||
//============================================================================
|
||||
|
||||
// BCU 节点地址范围:01h~36h(1~54)
|
||||
static const int kBcuAddrMin = 0x01;
|
||||
static const int kBcuAddrMax = 0x36;
|
||||
static const int kBcuNodeCount = kBcuAddrMax - kBcuAddrMin + 1;
|
||||
|
||||
// BCU 上报报文功能码(CAN ID 低字节 YY)
|
||||
enum BcuFunc : uint8_t {
|
||||
BCU_FUNC_BASE = 0x00, // 0x10XX0000 电压 / 电流 / SOC / 告警码 / 自检
|
||||
BCU_FUNC_CELL_V = 0x01, // 0x10XX0001 单体电压
|
||||
BCU_FUNC_CELL_T = 0x02, // 0x10XX0002 单体温度
|
||||
BCU_FUNC_RELAY = 0x03, // 0x10XX0003 继电器状态
|
||||
BCU_FUNC_ISU = 0x06, // 0x10XX0006 绝缘阻值 / 端口电压
|
||||
BCU_FUNC_ALARM = 0x10, // 0x10XX0010 告警位(附录1)
|
||||
};
|
||||
|
||||
// 从 CAN ID 提取 BCU 节点地址(0x10XX00YY -> XX);非 BCU 报文格式返回 0
|
||||
inline uint8_t bcuAddrOf(uint32_t canId) {
|
||||
if ((canId & 0xFF00FF00u) != 0x10000000u) return 0;
|
||||
uint8_t addr = static_cast<uint8_t>((canId >> 16) & 0xFF);
|
||||
return (addr >= kBcuAddrMin && addr <= kBcuAddrMax) ? addr : 0;
|
||||
}
|
||||
|
||||
// 从 CAN ID 提取功能码(0x10XX00YY -> YY)
|
||||
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);
|
||||
if (!addr) return false;
|
||||
switch (bcuFuncOf(canId)) {
|
||||
case BCU_FUNC_BASE:
|
||||
case BCU_FUNC_CELL_V:
|
||||
case BCU_FUNC_CELL_T:
|
||||
case BCU_FUNC_RELAY:
|
||||
case BCU_FUNC_ISU:
|
||||
case BCU_FUNC_ALARM:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 单个 BCU 节点最新状态(各报文字段按功能码部分更新,最终合成完整快照)
|
||||
struct BcuNodeStatus {
|
||||
// 0x10XX0000 基本信息
|
||||
double totalVoltage = 0; // 累加电压 V
|
||||
double current = 0; // 回路电流 A(放电为正,偏移 -600A 已折算)
|
||||
double soc = 0; // SOC %
|
||||
int alarmCode = 0; // 告警码(原始值,协议未附码表)
|
||||
int selfCheck = 0; // 自检状态(原始值)
|
||||
// 0x10XX0001 单体电压
|
||||
double maxCellVoltage = 0; // 最高单体电压 V
|
||||
int maxCellVoltageNo = 0; // 最高单体电压编号
|
||||
double minCellVoltage = 0; // 最低单体电压 V
|
||||
int minCellVoltageNo = 0; // 最低单体电压编号
|
||||
double avgCellVoltage = 0; // 单体平均电压 V
|
||||
// 0x10XX0002 单体温度
|
||||
double maxCellTemp = 0; // 最高单体温度 ℃
|
||||
int maxCellTempNo = 0; // 最高单体温度编号
|
||||
double minCellTemp = 0; // 最低单体温度 ℃
|
||||
int minCellTempNo = 0; // 最低单体温度编号
|
||||
double avgCellTemp = 0; // 单体平均温度 ℃
|
||||
// 0x10XX0003 继电器(1 闭合 / 0 断开)
|
||||
int posRelay = 0;
|
||||
int negRelay = 0;
|
||||
// 0x10XX0006 绝缘 / 端口电压
|
||||
double posInsulationKohm = 0; // 正极绝缘阻抗 kΩ
|
||||
double negInsulationKohm = 0; // 负极绝缘阻抗 kΩ
|
||||
double portVoltage = 0; // 正负极端口电压 V
|
||||
double posRelayOuterVoltage = 0; // 正极继电器外侧电压 V
|
||||
// 0x10XX0010 告警位(附录1,byte0~5)
|
||||
uint8_t alarmBits[6] = {0, 0, 0, 0, 0, 0};
|
||||
// 0x10XX0010 附加字节(协议未详列,原样保留)
|
||||
int alarmExtraByte6 = 0;
|
||||
int alarmExtraByte7 = 0;
|
||||
|
||||
// 元数据
|
||||
uint32_t rxMask = 0; // 收到过的功能码位图(bit = BcuFunc)
|
||||
double lastRxTime = 0; // 最近收到该节点任一报文的时刻(MOOSTime)
|
||||
bool valid = false; // 是否收到过该节点报文
|
||||
};
|
||||
|
||||
// 解析一帧 CAN 数据域;命中 BCU 上报报文返回 true,nodeAddr 为节点地址(1~54),
|
||||
// 字段按功能码部分更新合并进 node。data/dlc 非法或非 BCU 报文返回 false。
|
||||
bool decodeCanBcuFrame(uint32_t canId, const uint8_t* data, int dlc,
|
||||
uint8_t& nodeAddr, BcuNodeStatus& node);
|
||||
|
||||
// 附录1:0x10XX0010 告警位定义表(byteIdx/bitIdx -> 告警描述),表尾以 byteIdx=0xFF 结束
|
||||
struct BcuAlarmDesc {
|
||||
uint8_t byteIdx;
|
||||
uint8_t bitIdx;
|
||||
const char* text;
|
||||
};
|
||||
extern const BcuAlarmDesc kBcuAlarmTable[];
|
||||
|
||||
// 收集节点当前置位的告警描述;返回条数(不超过 cap,texts 填描述指针)
|
||||
int bcuAlarmTexts(const BcuNodeStatus& node, const char* texts[], int cap);
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_CAN_BMS_H
|
||||
@@ -12,6 +12,7 @@ namespace ccu {
|
||||
//
|
||||
// 协议联调阶段校验和的实现可能调整,因此对每条消息独立配置策略:
|
||||
// - None : 无校验和字段(如 FC 状态帧文档未列校验行)
|
||||
// - Sum8 : uint8 字节和(配电协议,取字节和低 8 位)
|
||||
// - Sum32 : uint32 字节和,从域起始符到校验和之前所有数据的字节和
|
||||
// - Sum16 : uint16 字节和(预留,备用)
|
||||
// 新增策略只需在此类扩展,不影响其它代码。
|
||||
@@ -19,6 +20,7 @@ namespace ccu {
|
||||
|
||||
enum class ChecksumType {
|
||||
None, // 无校验
|
||||
Sum8, // uint8 字节和(配电协议)
|
||||
Sum32, // uint32 字节和
|
||||
Sum16, // uint16 字节和
|
||||
};
|
||||
@@ -32,6 +34,7 @@ public:
|
||||
// 校验和字段占用的字节数;None 返回 0
|
||||
size_t size() const {
|
||||
switch (m_type) {
|
||||
case ChecksumType::Sum8: return 1;
|
||||
case ChecksumType::Sum32: return 4;
|
||||
case ChecksumType::Sum16: return 2;
|
||||
case ChecksumType::None: return 0;
|
||||
@@ -60,6 +63,10 @@ public:
|
||||
|
||||
uint32_t actual = 0;
|
||||
size_t offset = frame.size() - cs;
|
||||
if (m_type == ChecksumType::Sum8) {
|
||||
actual = static_cast<uint32_t>(frame[offset]);
|
||||
return (expect & 0xFF) == actual;
|
||||
}
|
||||
if (m_type == ChecksumType::Sum32) {
|
||||
actual = static_cast<uint32_t>(frame[offset]) |
|
||||
(static_cast<uint32_t>(frame[offset + 1]) << 8) |
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "DisProtocol.h"
|
||||
#include "Frame.h"
|
||||
#include "MessageRegistry.h"
|
||||
#include <cstring>
|
||||
|
||||
namespace ccu {
|
||||
|
||||
//============================================================================
|
||||
// 结构体尺寸核对:pPowerManger 按同结构裸解析收帧,布局必须与文档一致;
|
||||
// 若 pmSysvariable.h 结构调整导致尺寸变化,此处编译期报错提醒同步。
|
||||
//============================================================================
|
||||
|
||||
static_assert(sizeof(disHighVolBusCmd) == 14, "disHighVolBusCmd 尺寸与协议不符");
|
||||
static_assert(sizeof(disHighAVolBusCmd) == 6, "disHighAVolBusCmd 尺寸与协议不符");
|
||||
static_assert(sizeof(disLowMainBusCmd) == 7, "disLowMainBusCmd 尺寸与协议不符");
|
||||
static_assert(sizeof(disLowBusCmd) == 8, "disLowBusCmd 尺寸与协议不符");
|
||||
static_assert(sizeof(disHighVolBusState) == 53, "disHighVolBusState 尺寸与协议不符");
|
||||
static_assert(sizeof(disHighAVolBusState) == 25, "disHighAVolBusState 尺寸与协议不符");
|
||||
static_assert(sizeof(disLowMainBusState) == 34, "disLowMainBusState 尺寸与协议不符");
|
||||
static_assert(sizeof(disLowBusState) == 29, "disLowBusState 尺寸与协议不符");
|
||||
|
||||
//============================================================================
|
||||
// 通用编解码:配电结构体为 #pragma pack(1) 的 POD,与线上字节布局一致
|
||||
//(pPowerManger 亦按同结构裸解析),因此直接按字节拷贝。
|
||||
//============================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<uint8_t> encodePacked(uint16_t id, const void* obj, size_t size,
|
||||
const ChecksumPolicy& policy) {
|
||||
std::vector<uint8_t> p(size, 0);
|
||||
std::memcpy(p.data(), obj, size);
|
||||
return Frame::build(id, p, policy);
|
||||
}
|
||||
|
||||
bool decodePacked(const std::vector<uint8_t>& frame, uint16_t id,
|
||||
const ChecksumPolicy& policy, size_t size, void* obj) {
|
||||
// 注意:不依赖帧头 length 字段(pPowerManger 发送的配电指令该字段
|
||||
// 与总长不符),validateFrame 只要实际帧长足够即放行,校验和兜底。
|
||||
if (!Message::validateFrame(frame, id, policy,
|
||||
FRAME_HEADER_LEN + size + policy.size()))
|
||||
return false;
|
||||
std::memcpy(obj, frame.data() + FRAME_HEADER_LEN, size);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 指令消息编解码
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
std::vector<uint8_t> DisHighVolBusCmdMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disHighVolBusCmd), m_checksum);
|
||||
}
|
||||
bool DisHighVolBusCmdMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disHighVolBusCmd), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisHighAVolBusCmdMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disHighAVolBusCmd), m_checksum);
|
||||
}
|
||||
bool DisHighAVolBusCmdMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disHighAVolBusCmd), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisHighBVolBusCmdMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disLowMainBusCmd), m_checksum);
|
||||
}
|
||||
bool DisHighBVolBusCmdMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disLowMainBusCmd), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisLowBusCmdMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disLowBusCmd), m_checksum);
|
||||
}
|
||||
bool DisLowBusCmdMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disLowBusCmd), obj);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 反馈消息编解码
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
std::vector<uint8_t> DisHighVolBusFbMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disHighVolBusState), m_checksum);
|
||||
}
|
||||
bool DisHighVolBusFbMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disHighVolBusState), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisHighAVolBusFbMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disHighAVolBusState), m_checksum);
|
||||
}
|
||||
bool DisHighAVolBusFbMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disHighAVolBusState), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisHighBVolBusFbMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disLowMainBusState), m_checksum);
|
||||
}
|
||||
bool DisHighBVolBusFbMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disLowMainBusState), obj);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> DisLowBusFbMessage::encode(const void* obj) const {
|
||||
return encodePacked(id(), obj, sizeof(disLowBusState), m_checksum);
|
||||
}
|
||||
bool DisLowBusFbMessage::decode(const std::vector<uint8_t>& frame, void* obj) const {
|
||||
return decodePacked(frame, id(), m_checksum, sizeof(disLowBusState), obj);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 消息类型判断(供桥接器/上层分发使用)
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
bool isDisMessage(const Message* msg) {
|
||||
return isDisCmdMessage(msg) || isDisFbMessage(msg);
|
||||
}
|
||||
|
||||
bool isDisCmdMessage(const Message* msg) {
|
||||
if (!msg) return false;
|
||||
return dynamic_cast<const DisHighVolBusCmdMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisHighAVolBusCmdMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisHighBVolBusCmdMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisLowBusCmdMessage*>(msg) != nullptr;
|
||||
}
|
||||
|
||||
bool isDisFbMessage(const Message* msg) {
|
||||
if (!msg) return false;
|
||||
return dynamic_cast<const DisHighVolBusFbMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisHighAVolBusFbMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisHighBVolBusFbMessage*>(msg) != nullptr ||
|
||||
dynamic_cast<const DisLowBusFbMessage*>(msg) != nullptr;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 注册
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
void registerDisCmdMessages(MessageRegistry& reg) {
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighVolBusCmdMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighAVolBusCmdMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighBVolBusCmdMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisLowBusCmdMessage()));
|
||||
}
|
||||
|
||||
void registerDisFbMessages(MessageRegistry& reg) {
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighVolBusFbMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighAVolBusFbMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisHighBVolBusFbMessage()));
|
||||
reg.registerMessage(std::unique_ptr<Message>(new DisLowBusFbMessage()));
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
@@ -0,0 +1,115 @@
|
||||
#ifndef PCCU_DIS_PROTOCOL_H
|
||||
#define PCCU_DIS_PROTOCOL_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include "Message.h"
|
||||
#include "pmSysvariable.h"
|
||||
|
||||
namespace ccu {
|
||||
|
||||
//============================================================================
|
||||
// 配电协议(配电控制器 <-> 复合管控器,经 CCU 在 PM 链路与真实 CCU 链路间桥接)
|
||||
// 依据:docs/配电控制器与复合管控器的通信协议20260824.docx
|
||||
// 数据结构复用 src/pPowerManger/pmSysvariable.h(pPowerManger 按同结构
|
||||
// 裸解析收帧,复用可保证两侧 sizeof/字段布局完全一致)。
|
||||
//
|
||||
// 帧格式:0x40 0x40 + 域标识符(2B) + 域字节数(2B) + 数据域 + uint8 校验和
|
||||
// 校验和 = 从域起始符到校验和之前所有数据的字节和低 8 位(Sum8)。
|
||||
//
|
||||
// 注意(重要):
|
||||
// 配电指令 0x0001~0x0004 与 PM 协议操控/参数指令共用 id,仅能靠
|
||||
// 帧总长区分(配电指令 13~21B,PM 指令 28/45B)。且 pPowerManger 发送
|
||||
// 配电指令时帧头 length 字段并非总长(见 udpComm.cpp TODO),
|
||||
// 分发必须用实际帧长(见 MessageRegistry::find(id, totalLength))。
|
||||
//
|
||||
// 八条消息(均 Sum8 校验):
|
||||
// 指令(pPowerManger -> CCU -> 真实CCU) payload 总长
|
||||
// 0x0001 高压母线配电指令 disHighVolBusCmd 14B 21B
|
||||
// 0x0002 高压母线A配电指令 disHighAVolBusCmd 6B 13B
|
||||
// 0x0003 低压主母线配电指令 disLowMainBusCmd 7B 14B
|
||||
// 0x0004 仪表汇流排配电指令 disLowBusCmd 8B 15B
|
||||
// 反馈(真实CCU -> CCU -> pPowerManger)
|
||||
// 0x0005 高压母线配电反馈 disHighVolBusState 53B 60B
|
||||
// 0x0006 高压母线A配电反馈 disHighAVolBusState 25B 32B
|
||||
// 0x0007 低压主母线配电反馈 disLowMainBusState 34B 41B
|
||||
// 0x0008 仪表汇流排配电反馈 disLowBusState 29B 36B
|
||||
// (payload/总长由 static_assert 在编译期核对,见 DisProtocol.cpp)
|
||||
//============================================================================
|
||||
|
||||
enum : uint16_t {
|
||||
DIS_HV_CMD_ID = 0x0001, // 高压母线配电指令
|
||||
DIS_HVA_CMD_ID = 0x0002, // 高压母线A配电指令
|
||||
DIS_HVB_CMD_ID = 0x0003, // 低压主母线配电指令
|
||||
DIS_LV_CMD_ID = 0x0004, // 仪表汇流排配电指令
|
||||
DIS_HV_FB_ID = 0x0005, // 高压母线配电反馈
|
||||
DIS_HVA_FB_ID = 0x0006, // 高压母线A配电反馈
|
||||
DIS_HVB_FB_ID = 0x0007, // 低压主母线配电反馈
|
||||
DIS_LV_FB_ID = 0x0008, // 仪表汇流排配电反馈
|
||||
};
|
||||
|
||||
// 断路器/开关类指令与状态值
|
||||
enum : uint8_t {
|
||||
DIS_BREAKER_ON = 0x55, // 闭合
|
||||
DIS_BREAKER_OFF = 0xAA, // 断开
|
||||
DIS_BREAKER_FLT = 0x5A, // 故障脱扣
|
||||
};
|
||||
|
||||
// 判断是否配电协议消息(指令或反馈),桥接器据此识别
|
||||
bool isDisMessage(const Message* msg);
|
||||
bool isDisCmdMessage(const Message* msg);
|
||||
bool isDisFbMessage(const Message* msg);
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 指令消息(pPowerManger 下发,pCCU 原帧转发给真实CCU)
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
#define PCCU_DIS_MSG_CLASS(clsName, msgId, msgName, dataT) \
|
||||
class clsName : public Message { \
|
||||
public: \
|
||||
uint16_t id() const override { return msgId; } \
|
||||
const char* name() const override { return msgName; } \
|
||||
const ChecksumPolicy& checksum() const override { return m_checksum; } \
|
||||
size_t payloadLength() const override { return sizeof(dataT); } \
|
||||
std::vector<uint8_t> encode(const void* obj) const override; \
|
||||
bool decode(const std::vector<uint8_t>& frame, void* obj) const override; \
|
||||
std::vector<uint8_t> encode(const dataT& v) const { return encode(&v); } \
|
||||
bool decode(const std::vector<uint8_t>& frame, dataT& v) const { \
|
||||
return decode(frame, static_cast<void*>(&v)); \
|
||||
} \
|
||||
private: \
|
||||
ChecksumPolicy m_checksum{ChecksumType::Sum8}; \
|
||||
}
|
||||
|
||||
// 高压母线配电指令(0x0001)
|
||||
PCCU_DIS_MSG_CLASS(DisHighVolBusCmdMessage, DIS_HV_CMD_ID, "dis_hv_cmd", disHighVolBusCmd);
|
||||
// 高压母线A配电指令(0x0002)
|
||||
PCCU_DIS_MSG_CLASS(DisHighAVolBusCmdMessage, DIS_HVA_CMD_ID, "dis_hva_cmd", disHighAVolBusCmd);
|
||||
// 低压主母线配电指令(0x0003)
|
||||
PCCU_DIS_MSG_CLASS(DisHighBVolBusCmdMessage, DIS_HVB_CMD_ID, "dis_hvb_cmd", disLowMainBusCmd);
|
||||
// 仪表汇流排配电指令(0x0004)
|
||||
PCCU_DIS_MSG_CLASS(DisLowBusCmdMessage, DIS_LV_CMD_ID, "dis_lv_cmd", disLowBusCmd);
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 反馈消息(真实CCU 上报,pCCU 原帧转发给 pPowerManger 并解码显示)
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
// 高压母线配电反馈(0x0005)
|
||||
PCCU_DIS_MSG_CLASS(DisHighVolBusFbMessage, DIS_HV_FB_ID, "dis_hv_fb", disHighVolBusState);
|
||||
// 高压母线A配电反馈(0x0006)
|
||||
PCCU_DIS_MSG_CLASS(DisHighAVolBusFbMessage, DIS_HVA_FB_ID, "dis_hva_fb", disHighAVolBusState);
|
||||
// 低压主母线配电反馈(0x0007)
|
||||
PCCU_DIS_MSG_CLASS(DisHighBVolBusFbMessage, DIS_HVB_FB_ID, "dis_hvb_fb", disLowMainBusState);
|
||||
// 仪表汇流排配电反馈(0x0008)
|
||||
PCCU_DIS_MSG_CLASS(DisLowBusFbMessage, DIS_LV_FB_ID, "dis_lv_fb", disLowBusState);
|
||||
|
||||
#undef PCCU_DIS_MSG_CLASS
|
||||
|
||||
// 注册配电指令消息(PM 链路用:识别 pPowerManger 下发的配电指令)
|
||||
void registerDisCmdMessages(class MessageRegistry& reg);
|
||||
// 注册配电反馈消息(RCU 链路用:识别真实CCU 上报的配电反馈)
|
||||
void registerDisFbMessages(class MessageRegistry& reg);
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_DIS_PROTOCOL_H
|
||||
@@ -41,8 +41,9 @@ std::vector<uint8_t> Frame::build(
|
||||
std::vector<uint8_t> body(frame.begin(), frame.end() - csSize);
|
||||
uint32_t sum = checksum.compute(body);
|
||||
size_t off = frame.size() - csSize;
|
||||
if (csSize == 4) FieldCodec::putU32(frame, off, sum);
|
||||
else FieldCodec::putU16(frame, off, static_cast<uint16_t>(sum));
|
||||
if (csSize == 4) FieldCodec::putU32(frame, off, sum);
|
||||
else if (csSize == 1) frame[off] = static_cast<uint8_t>(sum & 0xFF);
|
||||
else FieldCodec::putU16(frame, off, static_cast<uint16_t>(sum));
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ namespace ccu {
|
||||
bool MessageRegistry::registerMessage(std::unique_ptr<Message> msg) {
|
||||
if (!msg) return false;
|
||||
uint16_t id = msg->id();
|
||||
if (m_msgs.count(id) != 0) return false; // id 冲突
|
||||
m_msgs[id] = std::move(msg);
|
||||
// 同 id 且同总长的重复注册视为冲突;同 id 不同总长为合法
|
||||
//(配电指令与 PM 指令共用 id,仅帧长不同)
|
||||
for (auto it = m_msgs.lower_bound(id); it != m_msgs.upper_bound(id); ++it) {
|
||||
if (it->second->totalLength() == msg->totalLength()) return false;
|
||||
}
|
||||
m_msgs.insert(std::make_pair(id, std::move(msg)));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -15,6 +19,15 @@ Message* MessageRegistry::find(uint16_t id) const {
|
||||
return (it != m_msgs.end()) ? it->second.get() : nullptr;
|
||||
}
|
||||
|
||||
Message* MessageRegistry::find(uint16_t id, size_t totalLength) const {
|
||||
Message* first = nullptr;
|
||||
for (auto it = m_msgs.lower_bound(id); it != m_msgs.upper_bound(id); ++it) {
|
||||
if (it->second->totalLength() == totalLength) return it->second.get();
|
||||
if (!first) first = it->second.get();
|
||||
}
|
||||
return first; // 总长未命中时回退该 id 的第一条
|
||||
}
|
||||
|
||||
size_t MessageRegistry::size() const {
|
||||
return m_msgs.size();
|
||||
}
|
||||
@@ -26,4 +39,4 @@ std::vector<Message*> MessageRegistry::all() const {
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
} // namespace ccu
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define PCCU_MESSAGE_REGISTRY_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
@@ -12,31 +13,39 @@ namespace ccu {
|
||||
//============================================================================
|
||||
// MessageRegistry:消息注册表与收发分发中心。
|
||||
//
|
||||
// 每条链路(FC 链路 / PM 链路)持有各自的注册表实例。
|
||||
// 每条链路(FC 链路 / PM 链路 / RCU 链路)持有各自的注册表实例。
|
||||
// 由于 FC 协议与 PM 协议的域标识符存在重叠(0x0001/0x0002),
|
||||
// 分属不同注册表可完全隔离,互不干扰。
|
||||
//
|
||||
// 同 id 多消息:配电指令 0x0001~0x0004 与 PM 协议操控/参数指令共用 id,
|
||||
// 仅帧总长不同,因此注册表允许同 id 多条消息,并支持按总长精确查找:
|
||||
// find(id, totalLength) 精确匹配(收帧分发用,未命中回退 find(id))
|
||||
// find(id) 返回该 id 的第一条(兼容旧接口)
|
||||
//
|
||||
// 协议新增/调整消息时只需 register() 对应 Message 实现,上层无需改动。
|
||||
//============================================================================
|
||||
|
||||
class MessageRegistry {
|
||||
public:
|
||||
// 注册一条消息;返回是否成功(id 冲突时返回 false)
|
||||
// 注册一条消息;返回是否成功(同 id 且同总长的重复注册返回 false)
|
||||
bool registerMessage(std::unique_ptr<Message> msg);
|
||||
|
||||
// 按 id 查找消息;未注册返回 nullptr
|
||||
// 按 id 查找消息(同 id 多条时返回第一条);未注册返回 nullptr
|
||||
Message* find(uint16_t id) const;
|
||||
|
||||
// 按 id + 帧总长精确查找;未命中时回退为 find(id)
|
||||
Message* find(uint16_t id, size_t totalLength) const;
|
||||
|
||||
// 已注册消息数量
|
||||
size_t size() const;
|
||||
|
||||
// 所有已注册消息(按 id 升序)
|
||||
// 所有已注册消息(按 id 升序,同 id 按注册顺序)
|
||||
std::vector<Message*> all() const;
|
||||
|
||||
private:
|
||||
std::map<uint16_t, std::unique_ptr<Message>> m_msgs;
|
||||
std::multimap<uint16_t, std::unique_ptr<Message>> m_msgs;
|
||||
};
|
||||
|
||||
} // namespace ccu
|
||||
|
||||
#endif // PCCU_MESSAGE_REGISTRY_H
|
||||
#endif // PCCU_MESSAGE_REGISTRY_H
|
||||
|
||||
+121
-1
@@ -65,7 +65,45 @@ bool DbStore::open() {
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_comm_log_time ON comm_log(time);");
|
||||
|
||||
return prepareInsert();
|
||||
// bcu_node:锂电池 BCU 节点解析数据(每收到一帧 BMS 报文落一行,
|
||||
// 记录该节点合并后的最新状态;BMS/CAN 协议字段固定故用结构化表)
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS bcu_node ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time REAL NOT NULL,"
|
||||
" addr INTEGER NOT NULL," // BCU 节点地址 1~54
|
||||
" func INTEGER NOT NULL," // 触发本行的报文功能码
|
||||
" total_voltage REAL," // 累加电压 V
|
||||
" current REAL," // 回路电流 A(放电为正)
|
||||
" soc REAL," // SOC %
|
||||
" alarm_code INTEGER," // 告警码(原始值)
|
||||
" self_check INTEGER," // 自检状态(原始值)
|
||||
" max_cell_voltage REAL," // 最高单体电压 V
|
||||
" max_cell_voltage_no INTEGER,"
|
||||
" min_cell_voltage REAL," // 最低单体电压 V
|
||||
" min_cell_voltage_no INTEGER,"
|
||||
" avg_cell_voltage REAL," // 单体平均电压 V
|
||||
" max_cell_temp REAL," // 最高单体温度 ℃
|
||||
" max_cell_temp_no INTEGER,"
|
||||
" min_cell_temp REAL," // 最低单体温度 ℃
|
||||
" min_cell_temp_no INTEGER,"
|
||||
" avg_cell_temp REAL," // 单体平均温度 ℃
|
||||
" pos_relay INTEGER," // 正极继电器 1闭合/0断开
|
||||
" neg_relay INTEGER," // 负极继电器 1闭合/0断开
|
||||
" pos_insulation REAL," // 正极绝缘阻抗 kΩ
|
||||
" neg_insulation REAL," // 负极绝缘阻抗 kΩ
|
||||
" port_voltage REAL," // 正负极端口电压 V
|
||||
" pos_relay_outer_voltage REAL," // 正极继电器外侧电压 V
|
||||
" alarm_bits TEXT," // 告警位 byte0~5 十六进制
|
||||
" alarm_extra_byte6 INTEGER,"
|
||||
" alarm_extra_byte7 INTEGER,"
|
||||
" rx_mask INTEGER," // 收到过的功能码位图
|
||||
" last_rx_time REAL" // 节点最近收帧时刻(MOOSTime)
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_bcu_node_time ON bcu_node(time);");
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_bcu_node_addr ON bcu_node(addr);");
|
||||
|
||||
return prepareInsert() && prepareBcuInsert();
|
||||
}
|
||||
|
||||
bool DbStore::prepareInsert() {
|
||||
@@ -79,12 +117,33 @@ bool DbStore::prepareInsert() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::prepareBcuInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO bcu_node (time, addr, func, total_voltage, current, soc, "
|
||||
"alarm_code, self_check, "
|
||||
"max_cell_voltage, max_cell_voltage_no, min_cell_voltage, min_cell_voltage_no, avg_cell_voltage, "
|
||||
"max_cell_temp, max_cell_temp_no, min_cell_temp, min_cell_temp_no, avg_cell_temp, "
|
||||
"pos_relay, neg_relay, pos_insulation, neg_insulation, port_voltage, pos_relay_outer_voltage, "
|
||||
"alarm_bits, alarm_extra_byte6, alarm_extra_byte7, rx_mask, last_rx_time) "
|
||||
"VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,"
|
||||
"?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtBcuInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DbStore::close() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_stmtInsert) {
|
||||
sqlite3_finalize(m_stmtInsert);
|
||||
m_stmtInsert = nullptr;
|
||||
}
|
||||
if (m_stmtBcuInsert) {
|
||||
sqlite3_finalize(m_stmtBcuInsert);
|
||||
m_stmtBcuInsert = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
@@ -119,6 +178,55 @@ void DbStore::onRawFrame(int direction, uint16_t msgId, const std::string& link,
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertBcuNode(uint8_t addr, uint8_t func, const BcuNodeStatus& v) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtBcuInsert) return;
|
||||
|
||||
char bits[16];
|
||||
std::snprintf(bits, sizeof(bits), "%02X%02X%02X%02X%02X%02X",
|
||||
v.alarmBits[0], v.alarmBits[1], v.alarmBits[2],
|
||||
v.alarmBits[3], v.alarmBits[4], v.alarmBits[5]);
|
||||
|
||||
sqlite3_reset(m_stmtBcuInsert);
|
||||
sqlite3_clear_bindings(m_stmtBcuInsert);
|
||||
// 时间:与 comm_log 一致用 unix 时间(秒)
|
||||
int i = 1;
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, static_cast<double>(::time(nullptr)));
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, addr);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, func);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.totalVoltage);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.current);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.soc);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.alarmCode);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.selfCheck);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.maxCellVoltage);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.maxCellVoltageNo);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.minCellVoltage);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.minCellVoltageNo);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.avgCellVoltage);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.maxCellTemp);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.maxCellTempNo);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.minCellTemp);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.minCellTempNo);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.avgCellTemp);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.posRelay);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.negRelay);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.posInsulationKohm);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.negInsulationKohm);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.portVoltage);
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.posRelayOuterVoltage);
|
||||
sqlite3_bind_text(m_stmtBcuInsert, i++, bits, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.alarmExtraByte6);
|
||||
sqlite3_bind_int(m_stmtBcuInsert, i++, v.alarmExtraByte7);
|
||||
sqlite3_bind_int64(m_stmtBcuInsert, i++, static_cast<sqlite3_int64>(v.rxMask));
|
||||
sqlite3_bind_double(m_stmtBcuInsert, i++, v.lastRxTime);
|
||||
|
||||
int rc = sqlite3_step(m_stmtBcuInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<CommLogRow> DbStore::queryRecent(const std::string& link, int direction,
|
||||
uint16_t msgId, int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
@@ -175,4 +283,16 @@ long long DbStore::count() const {
|
||||
return n;
|
||||
}
|
||||
|
||||
long long DbStore::countBcu() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM bcu_node;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace ccu
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include "../comm/LinkManager.h"
|
||||
#include "../protocol/CanBms.h"
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
@@ -19,6 +20,9 @@ namespace ccu {
|
||||
// - 以“原始帧日志”为主表(comm_log),收发全部数据按帧落库,
|
||||
// 协议字段变化不会导致建表失败。
|
||||
// - 提供按 link / direction / msg_id 的查询接口供网页展示。
|
||||
// - 锂电池 BMS/CAN 协议字段固定,另设解析数据表 bcu_node:
|
||||
// 每收到一帧 BMS 报文,把该 BCU 节点的合成状态落一行
|
||||
// (电压/电流/SOC/单体电压温度/继电器/绝缘/告警位)。
|
||||
// 复用仓库内 src/pPowerManger/sqlit3/sqlite3.c 与 sqlite3.h。
|
||||
//============================================================================
|
||||
|
||||
@@ -48,19 +52,26 @@ public:
|
||||
void onRawFrame(int direction, uint16_t msgId, const std::string& link,
|
||||
const std::vector<uint8_t>& data, bool checksumOk) override;
|
||||
|
||||
// 锂电池解析数据落库(bcu_node 表):addr=节点地址(1~54),
|
||||
// func=本次触发落库的 BCU 报文功能码,v=合并后的节点最新状态
|
||||
void insertBcuNode(uint8_t addr, uint8_t func, const BcuNodeStatus& v);
|
||||
|
||||
// 查询最近 N 条记录(可限定链路/方向/消息id;limit<=0 表示默认 100)
|
||||
std::vector<CommLogRow> queryRecent(const std::string& link, int direction,
|
||||
uint16_t msgId, int limit);
|
||||
|
||||
long long count() const;
|
||||
long long countBcu() const;
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
bool prepareInsert();
|
||||
bool prepareBcuInsert();
|
||||
|
||||
std::string m_dbPath;
|
||||
sqlite3* m_db = nullptr;
|
||||
sqlite3_stmt* m_stmtInsert = nullptr;
|
||||
sqlite3_stmt* m_stmtBcuInsert = nullptr;
|
||||
mutable std::mutex m_mutex;
|
||||
std::string m_lastError;
|
||||
};
|
||||
|
||||
+367
-4
@@ -65,6 +65,14 @@ th{color:var(--dim);font-weight:500;}
|
||||
.lvl4{background:rgba(248,81,73,.38);color:#ff7b72;border:1px solid #f85149;}
|
||||
.fc-bad{color:#f85149;font-weight:700;background:rgba(248,81,73,.12);padding:1px 6px;border-radius:4px;}
|
||||
.fc-ok{color:#3fb950;}
|
||||
/* 卡片内分组小标题 */
|
||||
.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>
|
||||
@@ -74,6 +82,7 @@ th{color:var(--dim);font-weight:500;}
|
||||
<button class="tab active" data-tab="fcStatus">FC状态反馈</button>
|
||||
<button class="tab" data-tab="pmCmd">PM操控指令</button>
|
||||
<button class="tab" data-tab="pmParam">PM参数设定</button>
|
||||
<button class="tab" data-tab="bms">锂电池BMS(CAN)</button>
|
||||
</div>
|
||||
<div class="tab-group"><span class="tg-label">发送</span>
|
||||
<button class="tab" data-tab="fcCmd">FC控制指令</button>
|
||||
@@ -82,6 +91,7 @@ th{color:var(--dim);font-weight:500;}
|
||||
</div>
|
||||
<div class="tab-group"><span class="tg-label">系统</span>
|
||||
<button class="tab" data-tab="links">链路状态</button>
|
||||
<button class="tab" data-tab="realCcu">真实CCU</button>
|
||||
<button class="tab" data-tab="raw">原始报文</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,11 +101,12 @@ th{color:var(--dim);font-weight:500;}
|
||||
const $ = id => document.getElementById(id);
|
||||
const pagesEl = $('pages');
|
||||
const statusEl = $('status');
|
||||
const TABS = ['fcStatus','pmCmd','pmParam','fcCmd','pmStatus','pmFb','links','raw'];
|
||||
const TABS = ['fcStatus','pmCmd','pmParam','bms','fcCmd','pmStatus','pmFb','links','realCcu','raw'];
|
||||
let activeTab = 'fcStatus';
|
||||
|
||||
function dot(ok){ return ok ? 'dot-ok' : (ok === undefined ? 'dot-warn' : 'dot-bad'); }
|
||||
function row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
|
||||
function sub(t){ return '<div class="sub">'+t+'</div>'; }
|
||||
function hex(v){ return '0x' + (v & 0xFF).toString(16).toUpperCase().padStart(2,'0'); }
|
||||
function hex16(v){ return '0x' + (v & 0xFFFF).toString(16).toUpperCase().padStart(4,'0'); }
|
||||
function hex32(v){ return '0x' + (v >>> 0).toString(16).toUpperCase().padStart(8,'0'); }
|
||||
@@ -126,6 +137,34 @@ function statusText(s){
|
||||
return m[s] || ('未知('+hex(s)+')');
|
||||
}
|
||||
function flameText(f){ return {0:'无效',1:'火焰报警',2:'探测器故障'}[f] || ('未知('+hex(f)+')'); }
|
||||
|
||||
//------------------ 锂电池 BCU 节点(CAN) 解析 ------------------
|
||||
// 报文定义见 docs/04KT38电池BCU-MBMS通信(CAN)定义.docx:
|
||||
// 0x10XX0000 累加电压/回路电流/SOC/告警码/自检状态
|
||||
// 0x10XX0001 单体电压 0x10XX0002 单体温度 0x10XX0003 继电器
|
||||
// 0x10XX0006 绝缘阻值/端口电压 0x10XX0010 告警位(附录1)
|
||||
// XX(01h~36h) 为 BCU 节点地址:同一节点的全部报文归入同一张卡片;
|
||||
// 告警含义由服务器端按附录1 解析为中文列表(alarms 字段)
|
||||
function pad2(v){ return (v & 0xFF).toString(16).toUpperCase().padStart(2,'0'); }
|
||||
function relayText(v){ return v===1 ? '<span class="fc-ok">闭合</span>' : '断开'; }
|
||||
// 告警码(0x10XX0000 BYTE7,协议未附码表,显示原始值)
|
||||
function bmsFault(c){ return c===0 ? '<span class="fc-ok">'+hex(c)+'</span>'
|
||||
: '<span class="fc-bad">'+hex(c)+'</span>'; }
|
||||
// 数据新鲜度:10s 内视为在线。优先用后端算好的 age(距最后收到该节点
|
||||
// 报文的秒数,不依赖浏览器与板卡时钟同步),无 age 时退回 lastRx 比对本地时钟
|
||||
function bmsOnline(n){
|
||||
if(!n) return undefined;
|
||||
if(typeof n.age === 'number') return n.age < 10;
|
||||
if(typeof n.lastRx === 'number') return (Date.now()/1000 - n.lastRx) < 10;
|
||||
return undefined;
|
||||
}
|
||||
// 最后更新时间文本(a:距最后收到报文的秒数)
|
||||
function ageText(a){
|
||||
if(typeof a !== 'number' || isNaN(a)) return '—';
|
||||
if(a < 90) return a.toFixed(1)+' 秒前';
|
||||
if(a < 3600) return Math.floor(a/60)+' 分 '+Math.floor(a%60)+' 秒前';
|
||||
return Math.floor(a/3600)+' 时 '+Math.floor((a%3600)/60)+' 分前';
|
||||
}
|
||||
// 带解析的值:hex 码 + 括号解析
|
||||
function parsed(v, text){ return hex(v)+' ('+text+')'; }
|
||||
// 故障码高亮:0 显示绿色正常,非 0 显示红色
|
||||
@@ -291,6 +330,15 @@ function linkCard(s){
|
||||
'收:'+l.fc.rx+' 发:'+l.fc.tx+' 误:'+l.fc.err);
|
||||
h+=row('<span><span class="status-dot '+dot(online(l.pm.lastRx))+'"></span>PM 链路</span>',
|
||||
'收:'+l.pm.rx+' 发:'+l.pm.tx+' 误:'+l.pm.err);
|
||||
if(l.rcu){
|
||||
h+=row('<span><span class="status-dot '+dot(online(l.rcu.lastRx))+'"></span>RCU 链路 (真实CCU)</span>',
|
||||
'收:'+l.rcu.rx+' 发:'+l.rcu.tx+' 误:'+l.rcu.err);
|
||||
}
|
||||
// CAN/BMS 行:任一 BCU 节点在线即视为链路在线
|
||||
const bmsNodes = Array.isArray(s.bms) ? s.bms : [];
|
||||
const bmsAnyOnline = bmsNodes.some(function(n){ return bmsOnline(n); });
|
||||
h+=row('<span><span class="status-dot '+dot(bmsNodes.length ? bmsAnyOnline : undefined)+
|
||||
'"></span>CAN 链路 (pCanBridge)</span>', 'CAN帧:'+s.canFrameCount+' BMS帧:'+s.bmsStatusCount);
|
||||
h+=row('FC 状态接收计数', s.fcStatusCount);
|
||||
h+=row('PM 指令接收计数', s.pmControlCount);
|
||||
h+=row('FC 控制转发计数', s.fcControlCount);
|
||||
@@ -311,9 +359,9 @@ function cmdCard(d){
|
||||
h+=row('应急允许', parsed(d.emergencyAllow, emerAllowText(d.emergencyAllow)));
|
||||
h+=row('潜深深度', d.depth+' m');
|
||||
h+=row('补给/排放指令', parsed(d.supplyCmd, supplyCmdText(d.supplyCmd)));
|
||||
h+=row('仪表锂电池启停', parsed(d.insBatCmd, batCmdText(d.insBatCmd)));
|
||||
h+=row('动力锂电池启停', parsed(d.dynBatCmd, batCmdText(d.dynBatCmd)));
|
||||
h+=row('动力锂电池功率配置', d.dynBatPower+' kW');
|
||||
h+=row('仪表锂电池启停', parsed(d.insBatCmd, batCmdText(d.insBatCmd))+' <span style="color:var(--warn)">(BMS/CAN暂不支持,未转发)</span>');
|
||||
h+=row('动力锂电池启停', parsed(d.dynBatCmd, batCmdText(d.dynBatCmd))+' <span style="color:var(--warn)">(BMS/CAN暂不支持,未转发)</span>');
|
||||
h+=row('动力锂电池功率配置', d.dynBatPower+' kW <span style="color:var(--warn)">(未转发)</span>');
|
||||
h+=row('通信心跳', d.heartbeat);
|
||||
h+=row('主机状态', parsed(d.hostState, hostStateText(d.hostState)));
|
||||
return card('指令内容', h);
|
||||
@@ -359,6 +407,98 @@ 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 各报文归并在卡片内分组展示
|
||||
function bmsCard(d){
|
||||
const nodes = (d && d.length) ? 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+')';
|
||||
let h='';
|
||||
// 概要(0x10XX0000)
|
||||
h+=sub('概要 0x10'+pad2(n.addr)+'0000');
|
||||
h+=row('累加电压', n.totalVoltage.toFixed(1)+' V');
|
||||
h+=row('回路电流', n.current.toFixed(1)+' A');
|
||||
h+=row('SOC', n.soc.toFixed(1)+' %');
|
||||
h+=row('告警码', bmsFault(n.alarmCode));
|
||||
h+=row('自检状态', hex(n.selfCheck));
|
||||
// 单体电压(0x10XX0001)
|
||||
h+=sub('单体电压 0x10'+pad2(n.addr)+'0001');
|
||||
h+=row('最高单体电压', n.maxCellVoltage.toFixed(3)+' V (#'+n.maxCellVoltageNo+')');
|
||||
h+=row('最低单体电压', n.minCellVoltage.toFixed(3)+' V (#'+n.minCellVoltageNo+')');
|
||||
h+=row('单体平均电压', n.avgCellVoltage.toFixed(3)+' V');
|
||||
// 单体温度(0x10XX0002)
|
||||
h+=sub('单体温度 0x10'+pad2(n.addr)+'0002');
|
||||
h+=row('最高单体温度', n.maxCellTemp.toFixed(0)+' ℃ (#'+n.maxCellTempNo+')');
|
||||
h+=row('最低单体温度', n.minCellTemp.toFixed(0)+' ℃ (#'+n.minCellTempNo+')');
|
||||
h+=row('单体平均温度', n.avgCellTemp.toFixed(0)+' ℃');
|
||||
// 继电器(0x10XX0003)
|
||||
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Ω');
|
||||
h+=row('负极绝缘阻抗', n.negInsulationKohm.toFixed(0)+' kΩ');
|
||||
h+=row('正负极端口电压', n.portVoltage.toFixed(1)+' V');
|
||||
h+=row('正极继电器外侧电压', n.posRelayOuterVoltage.toFixed(1)+' V');
|
||||
// 告警(0x10XX0010,附录1 解析)
|
||||
h+=sub('告警明细 0x10'+pad2(n.addr)+'0010');
|
||||
if(n.alarms && n.alarms.length){
|
||||
h+=row('告警(' + n.alarms.length + '条)',
|
||||
n.alarms.map(function(a){ return '<span class="fc-bad">'+a+'</span>'; }).join('<br>'));
|
||||
}else{
|
||||
h+=row('告警', '<span class="fc-ok">无</span>');
|
||||
}
|
||||
// 最后更新时间(判断该节点消息是否持续正常发送)
|
||||
const online = bmsOnline(n);
|
||||
h+=row('最后更新', (online ? '<span class="fc-ok">' : '<span class="fc-bad">')
|
||||
+ ageText(n.age || 0) + (online ? ' (在线)' : ' (超时)') + '</span>');
|
||||
html+=card(addrTxt, h);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function pmStatusCard(d){
|
||||
if(!d) return '';
|
||||
let h='';
|
||||
@@ -398,6 +538,214 @@ function pmStatusCard(d){
|
||||
return card('整合后状态', h);
|
||||
}
|
||||
|
||||
//------------------ 真实 CCU(配电桥接) 卡片 ------------------
|
||||
// 数据来源:RCU 链路。配电反馈(0x0005~0x0008)由 pCCU 原帧转发
|
||||
// pPowerManger 并在此全字段展示;配电指令(0x0001~0x0004)由 pPowerManger
|
||||
// 经 pCCU 原帧转发真实CCU。断路器/开关量:0x55 闭合 0xAA 断开 0x5A 故障
|
||||
function brkText(v){
|
||||
if(v===0x55) return '<span class="fc-ok">闭合</span>';
|
||||
if(v===0xAA) return '断开';
|
||||
if(v===0x5A) return '<span class="fc-bad">故障脱扣</span>';
|
||||
return hex(v);
|
||||
}
|
||||
// 双值信号(0x55 正常 / 0xAA 异常)
|
||||
function sigText(v){
|
||||
if(v===0x55) return '<span class="fc-ok">正常</span>';
|
||||
if(v===0xAA) return '<span class="fc-bad">异常</span>';
|
||||
return hex(v);
|
||||
}
|
||||
function insStatText(v){ return v===0x55 ? '<span class="fc-ok">正常</span>'
|
||||
: (v===0xAA ? '<span class="fc-bad">绝缘低</span>' : hex(v)); }
|
||||
// age:距最后收到该类帧的秒数;valid:是否收到过(未收到显示"从未收到")
|
||||
function ageCell(age, valid){
|
||||
if(!valid || typeof age !== 'number' || isNaN(age))
|
||||
return '<span style="color:var(--dim)">从未收到</span>';
|
||||
const online = age >= 0 && age < 5;
|
||||
return (online ? '<span class="fc-ok">' : '<span class="fc-bad">') + ageText(age)
|
||||
+ (online ? ' (在线)' : ' (超时)') + '</span>';
|
||||
}
|
||||
function fwdLine(okN, errN){
|
||||
return okN + ' 成功' + (errN ? ' <span class="fc-bad">'+errN+' 失败</span>' : '');
|
||||
}
|
||||
|
||||
// 母线卡片通用:breakers 子表 + 信号/电气量行(无数据时只显示概要行)
|
||||
function busCard(title, bus, age, rows){
|
||||
let h='';
|
||||
const b = bus && bus.breakers ? bus.breakers : {};
|
||||
const keys = Object.keys(bNamesDis).filter(function(k){ return b[k] !== undefined; });
|
||||
if(!keys.length){
|
||||
h+=sub('断路器状态');
|
||||
h+=row('状态','暂无数据');
|
||||
h+=row('数据更新', ageCell(age ? age.age : -1, !!(age && age.valid)));
|
||||
return card(title, h);
|
||||
}
|
||||
h+=sub('断路器状态');
|
||||
for(const k of keys) h+=row(bNamesDis[k], brkText(b[k]));
|
||||
for(const r of rows) h+=row(r[0], r[1]);
|
||||
h+=row('数据更新', ageCell(age ? age.age : -1, !!(age && age.valid)));
|
||||
return card(title, h);
|
||||
}
|
||||
// 母线断路器中文名表(busCard 共用)
|
||||
const bNamesDis = {
|
||||
fuelCell:'燃料电池断路器', powerLithiumBattery:'动力锂电池断路器',
|
||||
propulsionMotor:'推进电机断路器', lithiumBatteryGroupInstrument:'锂电池组(仪表)断路器',
|
||||
dcDc5Module:'DC/DC5模块断路器', bowHighVoltageDistributionBox:'艏部高压配电箱断路器',
|
||||
sternFTDevice45:'艉部FT装置4/5断路器', fbReserved:'FB预留断路器',
|
||||
sternRudderSwitch1:'艉舵开关1断路器', sternRudderSwitch2:'艉舵开关2断路器',
|
||||
tyzReserved:'TYZ预留断路器', reserved:'预留断路器',
|
||||
bowFTDevice123:'艏部FT装置1/2/3断路器', actuator:'启闭机构断路器',
|
||||
mastSteeringGearControlBox:'桅杆舵机控制箱断路器', xcz:'XCZ断路器',
|
||||
bowRudderControlBox:'艏舵控制箱断路器', openWaterCoverStartCylinder:'敞水盖启动电缸断路器',
|
||||
bowLowVoltageDistributionBox:'艏部低压配电箱断路器', unit4InstrumentDC48V:'UNIT4仪表DC48V断路器',
|
||||
dcC1DCDistributionPanel:'DC-C1直流配电板断路器', reserved1:'预留断路器1', reserved2:'预留断路器2',
|
||||
emergencyLithiumBatteryGroup2:'应急锂电池组2断路器',
|
||||
unit1:'UNIT1断路器', unit2:'UNIT2断路器', unit3:'UNIT3断路器', unit5:'UNIT5断路器',
|
||||
bowPZDevice:'艏部PZ装置断路器', emergencyLithiumBatteryGroup1:'应急锂电池组1断路器',
|
||||
dcdc5ModuleStart:'DC/DC5模块启动', coolingSystemStart:'冷却系统启动'
|
||||
};
|
||||
|
||||
function realCcuPage(d){
|
||||
if(!d) return '<div class="card"><h2>真实CCU</h2><div class="row"><span class="k">数据状态</span><span class="v">等待数据...</span></div></div>';
|
||||
let h='<div class="page-hdr"><span class="raw-id">RCU</span> <span class="raw-name">真实CCU 配电桥接</span> '+
|
||||
'<span class="badge ok">原帧透传</span></div><div class="grid">';
|
||||
|
||||
// 桥接概览卡片
|
||||
{
|
||||
let c='';
|
||||
const fwd = d.forward || {};
|
||||
c+=row('配电反馈收帧', fwd.fbCount || 0);
|
||||
c+=row('反馈转发PM', fwdLine(fwd.fwdFbOk || 0, fwd.fwdFbErr || 0));
|
||||
c+=row('配电指令收帧', fwd.cmdCount || 0);
|
||||
c+=row('指令转发真实CCU', fwdLine(fwd.fwdCmdOk || 0, fwd.fwdCmdErr || 0));
|
||||
const ccu = d.ccuStatus || {};
|
||||
c+=sub('真实CCU 状态报文(仅显示,不转发)');
|
||||
c+=row('在线', ageCell(ccu.age, ccu.valid));
|
||||
if(ccu.valid){
|
||||
c+=row('运行模式', modeText(ccu.fc_mode));
|
||||
c+=row('运行状态', statusText(ccu.fc_status));
|
||||
c+=row('系统故障等级', faultLvlBadge(ccu.fc_fault_level));
|
||||
c+=row('系统发电功率', (ccu.generation_power*0.01).toFixed(2)+' kW');
|
||||
c+=row('仪表/动力SOC', ccu.ins_soc+'% / '+ccu.dyn_soc+'%');
|
||||
c+=row('通信心跳', ccu.heartbeat);
|
||||
}
|
||||
h+=card('桥接概览', c);
|
||||
}
|
||||
|
||||
// 高压母线(0x0005 反馈 / 0x0001 指令)
|
||||
{
|
||||
const hv = d.hv || {};
|
||||
const rows = [
|
||||
['DC/DC5故障字低', hex(hv.dcDcFaultWord1)],
|
||||
['DC/DC5故障字高', hex(hv.dcDcFaultWord2)],
|
||||
['动力母排绝缘状态', insStatText(hv.powerBusInsulationStatus)],
|
||||
['A汇流排绝缘状态', insStatText(hv.aBusInsulationStatus)],
|
||||
['仪表电源状态', sigText(hv.meterPowerLossSignal)],
|
||||
['应急电源状态', sigText(hv.emergencyPowerLossSignal)],
|
||||
['DC/DC温度', hv.dcDcTemperature],
|
||||
['动力汇流排电压', hv.powerBusVoltage],
|
||||
['DC/DC5模块电流', hv.dcDc5ModuleCurrent],
|
||||
['动力汇流排电流', hv.powerBusCurrent],
|
||||
['A汇流排电压', hv.busbarAVoltage],
|
||||
['推进电机控制箱电流', hv.propulsionMotorControlBoxCurrent],
|
||||
['A汇流排电流', hv.busbarACurrent],
|
||||
['锂电池组(仪表)电流', hv.lithiumBatteryGroupMeterCurrent],
|
||||
['艏部高压配电箱电流', hv.bowHighVoltageDistributionBoxCurrent],
|
||||
['艉部FT装置4/5电流', hv.sternFTDevice45Current],
|
||||
['TYZ预留开关电流', hv.tyzReservedSwitchCurrent],
|
||||
['艉舵开关1电流', hv.sternRudderSwitch1Current],
|
||||
['预留电流1', hv.reservedCurrent1],
|
||||
['艉舵开关2电流', hv.sternRudderSwitch2Current],
|
||||
['预留电流2', hv.reservedCurrent2],
|
||||
['FB预留电流', hv.fbReservedSwitchCurrent],
|
||||
['预留电流3', hv.reservedCurrent3],
|
||||
['冷却水压力', hv.coolWaterPressure],
|
||||
];
|
||||
h+=busCard('高压母线 (0x0005 反馈 / 0x0001 指令)', hv, d.hvAge, rows);
|
||||
}
|
||||
|
||||
// 高压母线A(0x0006 反馈 / 0x0002 指令)
|
||||
{
|
||||
const b = d.hva || {};
|
||||
const rows = [
|
||||
['仪表电源失电信号', sigText(b.instrumentPowerFailureSignal)],
|
||||
['应急电源失电信号', sigText(b.emergencyPowerFailureSignal)],
|
||||
['浸水报警', sigText(b.waterIngressionAlarm)],
|
||||
['汇流排B电压', b.busbarBVoltage],
|
||||
['汇流排B电流', b.busbarBCurrent],
|
||||
['艏部FT装置1/2/3电流', b.bowFTDevice123Current],
|
||||
['启闭机构电流', b.actuatorCurrent],
|
||||
['桅杆舵机控制箱电流', b.mastSteeringGearControlBoxCurrent],
|
||||
['XCZ电流', b.xczCurrent],
|
||||
['艏舵控制箱电流', b.bowRudderControlBoxCurrent],
|
||||
['敞水盖启动电缸电流', b.openWaterCoverStartCylinderCurrent],
|
||||
];
|
||||
h+=busCard('高压母线A (0x0006 反馈 / 0x0002 指令)', b, d.hvaAge, rows);
|
||||
}
|
||||
|
||||
// 低压主母线(0x0007 反馈 / 0x0003 指令)
|
||||
{
|
||||
const b = d.hvb || {};
|
||||
const rows = [
|
||||
['仪表电源失电信号', sigText(b.instrumentPowerFailureSignal)],
|
||||
['应急电源失电信号', sigText(b.emergencyPowerFailureSignal)],
|
||||
['仪表汇流排绝缘', insStatText(b.instrumentBusbarInsulationLow)],
|
||||
['仪表母排电压', b.instrumentBusbarVoltage],
|
||||
['艏部低压配电箱电流', b.bowLowVoltageDistributionBoxCurrent],
|
||||
['DC-C1仪表DC48V电流', b.dcC1InstrumentDC48VCurrent],
|
||||
['预留电流1', b.reservedCurrent1],
|
||||
['应急锂电池组2电流', b.emergencyLithiumBatteryGroup2Current],
|
||||
['锂电池组(仪表)电流', b.lithiumBatteryGroupInstrumentCurrent],
|
||||
['UNIT4仪表DC48V电流', b.unit4InstrumentDC48VCurrent],
|
||||
['预留电流2', b.reservedCurrent2],
|
||||
['应急2汇流排电压', b.emergency2BusbarVoltage],
|
||||
['复合能源应急DC48V电流', b.compositeEnergyEmergencyDC48VCurrent],
|
||||
['燃料电池安全应急DC48V电流', b.fuelCellSecurityEmergencyDC48VCurrent],
|
||||
['PLC控制电源电流', b.plcControlPowerCurrent],
|
||||
];
|
||||
h+=busCard('低压主母线 (0x0007 反馈 / 0x0003 指令)', b, d.hvbAge, rows);
|
||||
}
|
||||
|
||||
// 仪表汇流排(0x0008 反馈 / 0x0004 指令)
|
||||
{
|
||||
const b = d.lv || {};
|
||||
const rows = [
|
||||
['仪表电源失电信号', sigText(b.powerFailureSignal)],
|
||||
['应急电源失电信号', sigText(b.emergencyPowerFailureSignal)],
|
||||
['浸水报警', sigText(b.waterIngressionAlarm)],
|
||||
['仪表母排电压', b.instrumentBusbarVoltage],
|
||||
['仪表母排电流', b.instrumentBusbarCurrent],
|
||||
['UNIT1电流', b.unit1Current],
|
||||
['UNIT2电流', b.unit2Current],
|
||||
['UNIT3电流', b.unit3Current],
|
||||
['UNIT5电流', b.unit5Current],
|
||||
['艏部PZ装置电流', b.bowPZDeviceCurrent],
|
||||
['应急锂电池组1电流', b.emergencyLithiumBatteryGroup1Current],
|
||||
['应急1汇流排电压', b.emergency1BusbarVoltage],
|
||||
];
|
||||
h+=busCard('仪表汇流排 (0x0008 反馈 / 0x0004 指令)', b, d.lvAge, rows);
|
||||
}
|
||||
|
||||
// 最近下发指令卡片
|
||||
{
|
||||
const lc = d.lastCmd || {};
|
||||
let c='';
|
||||
c+=row('最后指令时间', ageCell(lc.age, lc.valid));
|
||||
const cmds = lc.cmds || {};
|
||||
const groups = [['hv','高压母线'],['hva','高压母线A'],['hvb','低压主母线'],['lv','仪表汇流排']];
|
||||
for(const g of groups){
|
||||
const gb = cmds[g[0]] || {};
|
||||
const keys = Object.keys(gb);
|
||||
if(!keys.length) continue;
|
||||
c+=sub(g[1]+' (0x'+({hv:'0001',hva:'0002',hvb:'0003',lv:'0004'})[g[0]]+')');
|
||||
for(const k of keys) c+=row(bNamesDis[k]||k, brkText(gb[k]));
|
||||
}
|
||||
h+=card('最近下发的配电指令 (PM→真实CCU)', c);
|
||||
}
|
||||
|
||||
h+='</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
//------------------ 标签页头部 ------------------
|
||||
function pageHeader(id, name, dir){
|
||||
const idHex = '0x'+id.toString(16).toUpperCase().padStart(4,'0');
|
||||
@@ -450,12 +798,27 @@ function render(snap){
|
||||
+tankCard(snap.fc)+cabinCard(snap.fc)+'</div>',
|
||||
pmCmd: pageHeader(0x0001,'PM操控指令','PM→CCU')+'<div class="grid">'+cmdCard(snap.pmCmd)+'</div>',
|
||||
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','最高/最低/平均单体电压及编号')+
|
||||
row('0x10XX0002','最高/最低/平均单体温度及编号')+
|
||||
row('0x10XX0003','正/负极继电器状态')+
|
||||
row('0x10XX0006','正/负极绝缘阻抗 / 端口电压 / 继电器外侧电压')+
|
||||
row('0x10XX0010','告警位(附录1,按位解析显示告警含义)')+
|
||||
row('0x10XX81FF','断路器控制下发(MBMS→BCU,总正/总负继电器,总正+总负同帧)')+
|
||||
row('XX','BCU 节点地址 01h~36h,同一节点的报文归入同一卡片')+
|
||||
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">'+
|
||||
pmStatusCard(snap.pmStatus)+batteryCard(snap.pmStatus)+emergCard(snap.pmStatus)+'</div>',
|
||||
pmFb: pageHeader(0x0003,'PM参数反馈','CCU→PM')+'<div class="grid">'+pmFbCard(snap.pmFb)+'</div>',
|
||||
links: '<div class="grid">'+linkCard(snap)+'</div>',
|
||||
realCcu: realCcuPage(snap.realCcu),
|
||||
raw: rawCard(snap.logs)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#--------------------------------------------------------
|
||||
# The CMakeLists.txt for: pCanBridge
|
||||
# CAN(USBCAN-8E-U/CANET TCP) -> MOOSDB 透传桥 + SQLite 落库
|
||||
#--------------------------------------------------------
|
||||
|
||||
if (${WIN32})
|
||||
SET(SYSTEM_LIBS wsock32)
|
||||
else (${WIN32})
|
||||
SET(SYSTEM_LIBS m pthread)
|
||||
endif (${WIN32})
|
||||
|
||||
# 复用仓库内 pPowerManger 的 sqlite3 amalgamation(相对路径引用,避免重复维护)
|
||||
SET(PM_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../pPowerManger)
|
||||
|
||||
SET(SHARED_SRC
|
||||
${PM_DIR}/sqlit3/sqlite3.c
|
||||
)
|
||||
|
||||
SET(SRC
|
||||
${SHARED_SRC}
|
||||
CanEndpoint.cpp
|
||||
CanDbStore.cpp
|
||||
CanBridge.cpp
|
||||
CanBridge_Info.cpp
|
||||
main.cpp
|
||||
)
|
||||
|
||||
ADD_EXECUTABLE(pCanBridge ${SRC})
|
||||
|
||||
TARGET_INCLUDE_DIRECTORIES(pCanBridge PRIVATE
|
||||
${PM_DIR}
|
||||
${PM_DIR}/sqlit3
|
||||
)
|
||||
|
||||
TARGET_LINK_LIBRARIES(pCanBridge
|
||||
${MOOS_LIBRARIES}
|
||||
apputil
|
||||
mbutil
|
||||
m
|
||||
pthread
|
||||
dl
|
||||
${SYSTEM_LIBS}
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
#include "CanBridge.h"
|
||||
#include "CanBridge_Info.h"
|
||||
#include "MBUtils.h"
|
||||
#include "MOOS/libMOOS/Comms/MOOSMsg.h"
|
||||
#include <cstdio>
|
||||
|
||||
using namespace std;
|
||||
|
||||
namespace canbridge {
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Constructor / Destructor
|
||||
|
||||
CanBridge::CanBridge() {}
|
||||
|
||||
CanBridge::~CanBridge() {
|
||||
for (size_t i = 0; i < m_channels.size(); ++i) {
|
||||
if (m_channels[i].endpoint) {
|
||||
m_channels[i].endpoint->stop();
|
||||
delete m_channels[i].endpoint;
|
||||
m_channels[i].endpoint = nullptr;
|
||||
}
|
||||
}
|
||||
m_channels.clear();
|
||||
if (m_db) {
|
||||
m_db->close();
|
||||
delete m_db;
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// OnStartUp:读取配置并启动各通道 CAN 链路
|
||||
//
|
||||
// 通道配置(可重复多行):
|
||||
// channel = <通道名>,<转换器IP>,<工作端口>
|
||||
// 兼容旧单通道配置:can_host / can_port / can_channel_name
|
||||
|
||||
bool CanBridge::OnStartUp() {
|
||||
AppCastingMOOSApp::OnStartUp();
|
||||
|
||||
STRING_LIST sParams;
|
||||
m_MissionReader.EnableVerbatimQuoting(false);
|
||||
if (!m_MissionReader.GetConfiguration(GetAppName(), sParams))
|
||||
reportConfigWarning("No config block found for " + GetAppName());
|
||||
|
||||
// 旧单通道配置缓存(存在 can_host 时回退使用)
|
||||
bool hasLegacy = false;
|
||||
string legacyHost, legacyName = "CAN0";
|
||||
long legacyPort = 0;
|
||||
|
||||
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 == "channel") {
|
||||
// channel = <通道名>,<IP>,<端口>
|
||||
string name = stripBlankEnds(biteStringX(value, ','));
|
||||
string host = stripBlankEnds(biteStringX(value, ','));
|
||||
string port = stripBlankEnds(value);
|
||||
ChannelCfg cfg;
|
||||
cfg.name = name;
|
||||
cfg.host = host;
|
||||
cfg.port = atol(port.c_str());
|
||||
if (cfg.name.empty() || cfg.host.empty() || cfg.port <= 0) {
|
||||
reportConfigWarning("bad channel line (want name,host,port): " + orig);
|
||||
} else {
|
||||
Channel ch;
|
||||
ch.cfg = cfg;
|
||||
m_channels.push_back(ch);
|
||||
}
|
||||
}
|
||||
else if (param == "default_channel") m_defaultChannel = value;
|
||||
else if (param == "dbpath") m_dbPath = value;
|
||||
// 旧单通道配置(向后兼容)
|
||||
else if (param == "can_host") { legacyHost = value; hasLegacy = true; }
|
||||
else if (param == "can_port") { legacyPort = atol(value.c_str()); hasLegacy = true; }
|
||||
else if (param == "can_channel_name") { legacyName = value; }
|
||||
else handled = false;
|
||||
|
||||
if (!handled)
|
||||
reportUnhandledConfigWarning(orig);
|
||||
}
|
||||
|
||||
// 无 channel 行但有旧配置 -> 构造单通道
|
||||
if (m_channels.empty() && hasLegacy &&
|
||||
!legacyHost.empty() && legacyPort > 0) {
|
||||
ChannelCfg cfg;
|
||||
cfg.name = legacyName;
|
||||
cfg.host = legacyHost;
|
||||
cfg.port = legacyPort;
|
||||
Channel ch;
|
||||
ch.cfg = cfg;
|
||||
m_channels.push_back(ch);
|
||||
reportConfigWarning("legacy can_host/can_port config used; "
|
||||
"prefer 'channel = name,host,port' lines");
|
||||
}
|
||||
|
||||
registerVariables();
|
||||
|
||||
if (m_channels.empty())
|
||||
reportRunWarning("no CAN channel configured");
|
||||
|
||||
// 数据库(CAN 帧落库,channel 列区分通道)
|
||||
m_db = new CanDbStore(m_dbPath);
|
||||
if (!m_db->open()) {
|
||||
reportRunWarning("CAN db open failed: " + m_dbPath + " (" + m_db->lastError() + ")");
|
||||
delete m_db;
|
||||
m_db = nullptr;
|
||||
}
|
||||
|
||||
// 各通道 CAN 链路(TCP Client,收帧回调在各自接收线程中触发)
|
||||
for (size_t i = 0; i < m_channels.size(); ++i) {
|
||||
Channel& ch = m_channels[i];
|
||||
ch.endpoint = new CanEndpoint();
|
||||
ch.endpoint->configure(ch.cfg.host, ch.cfg.port);
|
||||
const string name = ch.cfg.name; // 按通道捕获
|
||||
ch.endpoint->setFrameCallback(
|
||||
[this, name](uint8_t fi, uint32_t id, const uint8_t* data, int dlc) {
|
||||
handleFrame(name, fi, id, data, dlc);
|
||||
});
|
||||
if (!ch.endpoint->start())
|
||||
reportRunWarning("CAN endpoint start failed: " + ch.cfg.name +
|
||||
" (" + ch.cfg.host + ")");
|
||||
}
|
||||
|
||||
cout << "pCanBridge started with " << m_channels.size() << " channel(s):";
|
||||
for (size_t i = 0; i < m_channels.size(); ++i)
|
||||
cout << " " << m_channels[i].cfg.name << "="
|
||||
<< m_channels[i].cfg.host << ":" << m_channels[i].cfg.port;
|
||||
cout << ", db: " << m_dbPath << endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// OnConnectToServer
|
||||
|
||||
bool CanBridge::OnConnectToServer() {
|
||||
registerVariables();
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// registerVariables:订阅下行指令(CAN_TX_0x*,pCCU 发布的
|
||||
// BCU 断路器控制帧等,通道经 m_sSrcAux 指定)。
|
||||
|
||||
void CanBridge::registerVariables() {
|
||||
AppCastingMOOSApp::RegisterVariables();
|
||||
// 通配订阅必须用三参数重载(变量模式 + 来源模式)
|
||||
Register("CAN_TX_0x*", "*", 0);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// 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;
|
||||
if (msg.m_sKey.rfind("CAN_TX_0x", 0) == 0)
|
||||
handleTxMessage(msg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// findChannel:按通道名查找运行通道
|
||||
|
||||
CanBridge::Channel* CanBridge::findChannel(const std::string& name) {
|
||||
for (size_t i = 0; i < m_channels.size(); ++i) {
|
||||
if (m_channels[i].cfg.name == name && m_channels[i].endpoint)
|
||||
return &m_channels[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// resolveTxChannel:下行目标通道解析(回退规则)
|
||||
// 1) srcAux 非空:命中通道则用之,否则告警丢弃
|
||||
// 2) srcAux 为空:default_channel 命中则用之
|
||||
// 3) 仍无:仅单通道时用该通道,多通道告警丢弃
|
||||
|
||||
CanBridge::Channel* CanBridge::resolveTxChannel(const std::string& srcAux) {
|
||||
if (!srcAux.empty()) {
|
||||
Channel* ch = findChannel(srcAux);
|
||||
if (!ch)
|
||||
reportRunWarning("CAN_TX unknown channel: '" + srcAux + "'");
|
||||
return ch;
|
||||
}
|
||||
|
||||
if (!m_defaultChannel.empty()) {
|
||||
Channel* ch = findChannel(m_defaultChannel);
|
||||
if (ch) return ch;
|
||||
}
|
||||
|
||||
if (m_channels.size() == 1)
|
||||
return m_channels[0].endpoint ? &m_channels[0] : nullptr;
|
||||
|
||||
reportRunWarning("CAN_TX no channel (empty srcAux) with " +
|
||||
to_string(m_channels.size()) + " channels, dropped");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// handleTxMessage:MOOS 下行消息 -> CANET 13 字节帧 -> 对应通道
|
||||
//
|
||||
// m_sKey = "CAN_TX_0x%08X"(CAN ID)
|
||||
// m_sVal = 二进制数据域(dlc<=8)
|
||||
// m_sSrcAux = 目标通道名(空时按回退规则)
|
||||
// 帧信息:id>0x7FF 视为扩展帧(bit7=1),DLC=数据字节数。
|
||||
|
||||
void CanBridge::handleTxMessage(CMOOSMsg& msg) {
|
||||
++m_txCount;
|
||||
|
||||
uint32_t id = 0;
|
||||
// "CAN_TX_0x" 前缀长 9 字符(注意不是 8)
|
||||
int sr = std::sscanf(msg.m_sKey.c_str() + 9, "%x", &id);
|
||||
if (sr != 1) {
|
||||
++m_txDropCount;
|
||||
reportRunWarning("CAN_TX bad key: " + msg.m_sKey);
|
||||
return;
|
||||
}
|
||||
if (!msg.IsBinary() || msg.GetBinaryDataSize() <= 0 ||
|
||||
msg.GetBinaryDataSize() > 8) {
|
||||
++m_txDropCount;
|
||||
reportRunWarning("CAN_TX bad data size: " + msg.m_sKey);
|
||||
return;
|
||||
}
|
||||
|
||||
Channel* ch = resolveTxChannel(msg.GetSourceAux());
|
||||
if (!ch) {
|
||||
++m_txDropCount;
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned char* d = msg.GetBinaryData();
|
||||
if (!d) {
|
||||
++m_txDropCount;
|
||||
return;
|
||||
}
|
||||
int dlc = static_cast<int>(msg.GetBinaryDataSize());
|
||||
uint8_t frameInfo = static_cast<uint8_t>(((id > 0x7FF) ? 0x80 : 0x00) | dlc);
|
||||
|
||||
if (ch->endpoint->sendFrame(frameInfo, id, d, dlc)) {
|
||||
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 " << ch->cfg.name << "] 0x" << std::hex << id
|
||||
<< std::dec << " dlc=" << dlc << " data=" << hex << std::endl;
|
||||
} else {
|
||||
reportRunWarning("CAN_TX send failed on " + ch->cfg.name + ": " +
|
||||
msg.m_sKey +
|
||||
(ch->endpoint->isConnected() ? "" : " (未连接)"));
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Iterate:周期任务
|
||||
|
||||
bool CanBridge::Iterate() {
|
||||
AppCastingMOOSApp::Iterate();
|
||||
AppCastingMOOSApp::PostReport();
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// handleFrame:CAN 帧 -> CMOOSMsg(二进制) -> MOOSDB
|
||||
//
|
||||
// 在各通道 CanEndpoint 接收线程中调用;m_Comms::Post 线程安全。
|
||||
|
||||
void CanBridge::handleFrame(const std::string& channel, uint8_t frameInfo,
|
||||
uint32_t id, const uint8_t* data, int dlc) {
|
||||
// 落库(WAL + 预处理语句,~60帧/秒无压力)
|
||||
if (m_db) m_db->onFrame(channel, frameInfo, id, data, dlc);
|
||||
|
||||
char key[32];
|
||||
std::snprintf(key, sizeof(key), "CAN_0x%08X", id);
|
||||
|
||||
// 二进制构造:m_cDataType = MOOS_BINARY_STRING
|
||||
CMOOSMsg msg(MOOS_NOTIFY, key,
|
||||
static_cast<unsigned int>(dlc),
|
||||
const_cast<uint8_t*>(data));
|
||||
msg.SetSourceAux(channel); // 通道 -> m_sSrcAux
|
||||
msg.SetDoubleAux(static_cast<double>(frameInfo)); // FF/RTR/DLC -> m_dfVal2
|
||||
m_Comms.Post(msg);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// buildReport:AppCasting 报告(逐通道)
|
||||
|
||||
bool CanBridge::buildReport() {
|
||||
m_msgs << "============================================" << "\n";
|
||||
m_msgs << "pCanBridge CAN<->MOOSDB 多通道双向透传" << "\n";
|
||||
m_msgs << "============================================" << "\n";
|
||||
m_msgs << "通道数: " << m_channels.size() << "\n";
|
||||
if (!m_defaultChannel.empty())
|
||||
m_msgs << "默认通道: " << m_defaultChannel << "\n";
|
||||
m_msgs << "MOOS下发数: " << m_txCount << " (丢弃 " << m_txDropCount << ")\n";
|
||||
|
||||
for (size_t i = 0; i < m_channels.size(); ++i) {
|
||||
const Channel& ch = m_channels[i];
|
||||
m_msgs << "--------------------------------------------" << "\n";
|
||||
m_msgs << "[" << ch.cfg.name << "] " << ch.cfg.host << ":"
|
||||
<< ch.cfg.port << "\n";
|
||||
if (ch.endpoint) {
|
||||
m_msgs << " 连接状态: "
|
||||
<< (ch.endpoint->isConnected() ? "已连接" : "断开") << "\n";
|
||||
m_msgs << " 收帧数: " << ch.endpoint->frameCount() << "\n";
|
||||
m_msgs << " 下行帧数: " << ch.endpoint->txCount()
|
||||
<< " (错误 " << ch.endpoint->txErrorCount() << ")\n";
|
||||
m_msgs << " 错误数: " << ch.endpoint->errorCount() << "\n";
|
||||
m_msgs << " 重连次数: " << ch.endpoint->reconnectCount() << "\n";
|
||||
m_msgs << " 最近收帧: " << ch.endpoint->lastFrameTime() << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
m_msgs << "============================================" << "\n";
|
||||
if (m_db) {
|
||||
m_msgs << "数据库: " << m_dbPath
|
||||
<< (m_db->isOpen() ? " (已打开)" : " (未打开)") << "\n";
|
||||
m_msgs << "落库记录数: " << m_db->count() << "\n";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace canbridge
|
||||
@@ -0,0 +1,106 @@
|
||||
#ifndef PCANBRIDGE_CAN_BRIDGE_H
|
||||
#define PCANBRIDGE_CAN_BRIDGE_H
|
||||
|
||||
#define UNIX
|
||||
#include "MOOS/libMOOS/Thirdparty/AppCasting/AppCastingMOOSApp.h"
|
||||
#include "CanEndpoint.h"
|
||||
#include "CanDbStore.h"
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace canbridge {
|
||||
|
||||
//============================================================================
|
||||
// CanBridge:CAN <-> MOOSDB 多通道双向透传桥(MOOS 应用外壳)。
|
||||
//
|
||||
// 通道配置(.moos 中可重复多行):
|
||||
// channel = <通道名>,<转换器IP>,<工作端口>
|
||||
// 如 channel = CAN0,192.168.0.222,4001(CANET: CAN0=4001..CAN7=4008)
|
||||
// 每通道一条独立 TCP 连接 + 独立接收线程。
|
||||
//
|
||||
// 数据流(上行,每通道一条 TCP 连接):
|
||||
// CANET 工作端口 (TCP Server)
|
||||
// -> CanEndpoint(TCP Client,13 字节切帧)
|
||||
// -> handleFrame() 解析
|
||||
// -> Notify 到 MOOSDB
|
||||
//
|
||||
// 数据流(下行,BCU 断路器/继电器控制等):
|
||||
// MOOS "CAN_TX_0x%08X"(二进制数据域,dlc<=8)
|
||||
// -> OnNewMail 解析 CAN ID 与数据
|
||||
// -> 按消息 m_sSrcAux(目标通道名)路由到对应通道的 CanEndpoint
|
||||
// -> CanEndpoint::sendFrame(该通道 TCP 连接写出 13 字节帧)
|
||||
// -> CANET -> CAN 总线
|
||||
//
|
||||
// MOOS 消息映射(上行/下行对称,通道一律走 m_sSrcAux):
|
||||
// 上行 m_sKey = "CAN_0x%08X" CAN ID(完整 4 字节,标准/扩展帧同格式)
|
||||
// 上行 m_sVal = 二进制 data(dlc 字节,MOOS_BINARY_STRING)
|
||||
// 上行 m_sSrcAux = 通道名(如 "CAN0")
|
||||
// 上行 m_dfVal2 = 原始帧信息字节 byte0(bit7 FF / bit6 RTR / bit3~0 DLC)
|
||||
// 下行 m_sKey = "CAN_TX_0x%08X",m_sVal = 二进制 data,m_sSrcAux = 目标通道名
|
||||
// m_nID 不使用(MOOS 内部消息序号)
|
||||
//
|
||||
// 下行通道回退规则(m_sSrcAux 为空时):
|
||||
// 1) 配置了 default_channel 且命中 -> 用该通道
|
||||
// 2) 仅配置了单通道 -> 用该通道
|
||||
// 3) 多通道且无 default_channel -> 告警丢弃
|
||||
// m_sSrcAux 非空但未命中任何通道 -> 告警丢弃
|
||||
//
|
||||
// 每帧(上行)同时落库 SQLite(can_frame 表,配置项 dbpath);
|
||||
// 下行帧仅日志记录(can_frame 表无方向列,保持旧库兼容)。
|
||||
//============================================================================
|
||||
|
||||
class CanBridge : public AppCastingMOOSApp {
|
||||
public:
|
||||
CanBridge();
|
||||
~CanBridge();
|
||||
|
||||
protected:
|
||||
bool OnNewMail(MOOSMSG_LIST &NewMail);
|
||||
bool Iterate();
|
||||
bool OnConnectToServer();
|
||||
bool OnStartUp();
|
||||
bool buildReport();
|
||||
void registerVariables();
|
||||
|
||||
private:
|
||||
// 通道配置(.moos 重复行 channel = name,host,port)
|
||||
struct ChannelCfg {
|
||||
std::string name;
|
||||
std::string host;
|
||||
long port = 0;
|
||||
};
|
||||
|
||||
// 运行中的通道(配置 + 独立 TCP 连接)
|
||||
struct Channel {
|
||||
ChannelCfg cfg;
|
||||
CanEndpoint* endpoint = nullptr;
|
||||
};
|
||||
|
||||
// 收帧回调(CanEndpoint 接收线程调用)
|
||||
void handleFrame(const std::string& channel, uint8_t frameInfo,
|
||||
uint32_t id, const uint8_t* data, int dlc);
|
||||
|
||||
// 下行指令处理(OnNewMail 调用):CAN_TX_0x%08X -> 按通道路由发送
|
||||
void handleTxMessage(CMOOSMsg& msg);
|
||||
|
||||
// 按通道名查找运行通道;未命中返回 nullptr
|
||||
Channel* findChannel(const std::string& name);
|
||||
|
||||
// 下行目标通道解析(含回退规则);返回通道指针,nullptr 表示丢弃
|
||||
Channel* resolveTxChannel(const std::string& srcAux);
|
||||
|
||||
// 配置
|
||||
std::vector<Channel> m_channels;
|
||||
std::string m_defaultChannel; // default_channel(可空)
|
||||
std::string m_dbPath = "pCanBridge_data.db"; // SQLite 路径(moos 配置项 dbpath)
|
||||
|
||||
// 组件
|
||||
CanDbStore* m_db = nullptr;
|
||||
std::atomic<unsigned long> m_txCount{0}; // MOOS 下行接收总数
|
||||
std::atomic<unsigned long> m_txDropCount{0}; // 下行因通道解析失败丢弃数
|
||||
};
|
||||
|
||||
} // namespace canbridge
|
||||
|
||||
#endif // PCANBRIDGE_CAN_BRIDGE_H
|
||||
@@ -0,0 +1,111 @@
|
||||
/****************************************************************/
|
||||
/* NAME: CanBridge_Info */
|
||||
/* FILE: CanBridge_Info.cpp */
|
||||
/****************************************************************/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include "CanBridge_Info.h"
|
||||
#include "ColorParse.h"
|
||||
#include "ReleaseInfo.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
void showSynopsis() {
|
||||
blk("SYNOPSIS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" The pCanBridge application bridges CAN bus frames between ");
|
||||
blk(" multi-channel CANET Ethernet-CAN converters (TCP Server mode) ");
|
||||
blk(" and the MOOSDB. Each channel uses its own TCP connection ");
|
||||
blk(" (CANET working port). Every CAN frame is parsed from the ");
|
||||
blk(" 13-byte CANET wire format and transparently published as a ");
|
||||
blk(" binary MOOS message (channel tagged via m_sSrcAux). ");
|
||||
blk(" ");
|
||||
}
|
||||
|
||||
void showHelpAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("Usage: pCanBridge file.moos [OPTIONS] ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("Options: ");
|
||||
mag(" --alias","=<ProcessName> ");
|
||||
blk(" Launch pCanBridge with the given process name ");
|
||||
blk(" rather than pCanBridge. ");
|
||||
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 pCanBridge. ");
|
||||
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("pCanBridge Example MOOS Configuration ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
blk("ProcessConfig = pCanBridge ");
|
||||
blk("{ ");
|
||||
blk(" AppTick = 4 ");
|
||||
blk(" CommsTick = 4 ");
|
||||
blk(" ");
|
||||
blk(" // CANET 通道(可重复多行):通道名,IP,工作端口 ");
|
||||
blk(" // 工作端口: CAN0=4001 CAN1=4002 ... CAN7=4008 ");
|
||||
blk(" channel = CAN0,192.168.0.222,4001 ");
|
||||
blk(" channel = CAN1,192.168.0.222,4002 ");
|
||||
blk(" ");
|
||||
blk(" // CAN_TX 下行默认通道(m_sSrcAux 为空时使用,可省略) ");
|
||||
blk(" default_channel = CAN0 ");
|
||||
blk(" ");
|
||||
blk(" // CAN 帧 SQLite 落库路径(can_frame 表) ");
|
||||
blk(" dbpath = pCanBridge_data.db ");
|
||||
blk("} ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showInterfaceAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("pCanBridge INTERFACE ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("SUBSCRIPTIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" CAN_TX_0x%08X = 下行 CAN 帧(每帧一条二进制消息) ");
|
||||
blk(" m_sVal: 二进制 data(dlc<=8 字节, MOOS_BINARY_STRING) ");
|
||||
blk(" m_sSrcAux: 目标通道名(如 CAN0;空时按回退规则: ");
|
||||
blk(" default_channel > 单通道 > 多通道告警丢弃) ");
|
||||
blk(" ");
|
||||
blk("PUBLICATIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" CAN_0x%08X = CAN 帧(每帧一条二进制消息) ");
|
||||
blk(" 变量名: CAN ID 完整 4 字节十六进制, 如 CAN_0x10010001 ");
|
||||
blk(" m_sVal: 二进制 data(dlc 字节, MOOS_BINARY_STRING) ");
|
||||
blk(" m_sSrcAux: 通道名(如 CAN0) ");
|
||||
blk(" m_dfVal2: 原始帧信息字节 byte0 ");
|
||||
blk(" bit7=FF(1扩展/0标准) bit6=RTR(1远程/0数据) ");
|
||||
blk(" bit3~0=DLC ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showReleaseInfoAndExit() {
|
||||
showReleaseInfo("pCanBridge", "gpl");
|
||||
exit(0);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/****************************************************************/
|
||||
/* NAME: CanBridge_Info */
|
||||
/* FILE: CanBridge_Info.h */
|
||||
/****************************************************************/
|
||||
|
||||
#ifndef PCANBRIDGE_INFO_HEADER
|
||||
#define PCANBRIDGE_INFO_HEADER
|
||||
|
||||
void showSynopsis();
|
||||
void showHelpAndExit();
|
||||
void showExampleConfigAndExit();
|
||||
void showInterfaceAndExit();
|
||||
void showReleaseInfoAndExit();
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,158 @@
|
||||
#include "CanDbStore.h"
|
||||
#include "sqlite3.h"
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
#include <cstdio>
|
||||
|
||||
namespace canbridge {
|
||||
|
||||
namespace {
|
||||
void toHex(const uint8_t* data, int len, std::string& out) {
|
||||
static const char* hex = "0123456789ABCDEF";
|
||||
out.clear();
|
||||
out.reserve(static_cast<size_t>(len) * 2);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
out += hex[(data[i] >> 4) & 0xF];
|
||||
out += hex[data[i] & 0xF];
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
CanDbStore::CanDbStore(const std::string& dbPath) : m_dbPath(dbPath) {}
|
||||
|
||||
CanDbStore::~CanDbStore() {
|
||||
close();
|
||||
}
|
||||
|
||||
bool CanDbStore::exec(const char* sql) {
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql, nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
m_lastError = err ? err : "sqlite error";
|
||||
sqlite3_free(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CanDbStore::open() {
|
||||
if (m_db) return true;
|
||||
if (sqlite3_open(m_dbPath.c_str(), &m_db) != SQLITE_OK) {
|
||||
m_lastError = m_db ? sqlite3_errmsg(m_db) : "cannot open database";
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
sqlite3_busy_timeout(m_db, 2000);
|
||||
exec("PRAGMA journal_mode=WAL;");
|
||||
exec("PRAGMA synchronous=NORMAL;");
|
||||
|
||||
// can_frame:收到的每个 CAN 帧一条记录
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS can_frame ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time REAL NOT NULL," // unix 时间(含小数秒)
|
||||
" channel TEXT NOT NULL," // 通道名(CAN0~CAN7)
|
||||
" frame_info INTEGER NOT NULL," // 原始帧信息字节 byte0
|
||||
" can_id INTEGER NOT NULL," // CAN ID
|
||||
" dlc INTEGER NOT NULL," // 数据长度 0~8
|
||||
" hex TEXT NOT NULL" // 数据十六进制
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_can_frame_time ON can_frame(time);");
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_can_frame_id ON can_frame(can_id);");
|
||||
|
||||
return prepareInsert();
|
||||
}
|
||||
|
||||
bool CanDbStore::prepareInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO can_frame (time, channel, frame_info, can_id, dlc, hex) "
|
||||
"VALUES (?1, ?2, ?3, ?4, ?5, ?6);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CanDbStore::close() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_stmtInsert) {
|
||||
sqlite3_finalize(m_stmtInsert);
|
||||
m_stmtInsert = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void CanDbStore::onFrame(const std::string& channel, uint8_t frameInfo,
|
||||
uint32_t canId, const uint8_t* data, int dlc) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtInsert) return;
|
||||
|
||||
std::string hex;
|
||||
if (data && dlc > 0) toHex(data, dlc, hex);
|
||||
|
||||
sqlite3_reset(m_stmtInsert);
|
||||
sqlite3_clear_bindings(m_stmtInsert);
|
||||
sqlite3_bind_double(m_stmtInsert, 1, MOOSTime(false));
|
||||
sqlite3_bind_text(m_stmtInsert, 2, channel.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(m_stmtInsert, 3, frameInfo);
|
||||
sqlite3_bind_int64(m_stmtInsert, 4, static_cast<sqlite3_int64>(canId));
|
||||
sqlite3_bind_int(m_stmtInsert, 5, dlc);
|
||||
sqlite3_bind_text(m_stmtInsert, 6, hex.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(m_stmtInsert) != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<CanFrameRow> CanDbStore::queryRecent(uint32_t canId, int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<CanFrameRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 100;
|
||||
|
||||
std::string sql = "SELECT id, time, channel, frame_info, can_id, dlc, hex "
|
||||
"FROM can_frame";
|
||||
if (canId != 0) {
|
||||
sql += " WHERE can_id=" + std::to_string(canId);
|
||||
}
|
||||
sql += " ORDER BY id DESC LIMIT " + std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
CanFrameRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_double(stmt, 1);
|
||||
r.channel = sqlite3_column_text(stmt, 2)
|
||||
? reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2)) : "";
|
||||
r.frameInfo = sqlite3_column_int(stmt, 3);
|
||||
r.canId = static_cast<uint32_t>(sqlite3_column_int64(stmt, 4));
|
||||
r.dlc = sqlite3_column_int(stmt, 5);
|
||||
r.hex = sqlite3_column_text(stmt, 6)
|
||||
? reinterpret_cast<const char*>(sqlite3_column_text(stmt, 6)) : "";
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
long long CanDbStore::count() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM can_frame;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace canbridge
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef PCANBRIDGE_CAN_DB_STORE_H
|
||||
#define PCANBRIDGE_CAN_DB_STORE_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
namespace canbridge {
|
||||
|
||||
//============================================================================
|
||||
// CanDbStore:CAN 帧 SQLite 存储。
|
||||
//
|
||||
// 设计参考 src/pCCU/store/DbStore(原始帧日志模式):
|
||||
// - 收到的每个 CAN 帧逐条落库(can_frame 表),
|
||||
// 存原始帧信息/ID/DLC/十六进制数据,语义解析交给下游。
|
||||
// - 预处理语句 + WAL + synchronous=NORMAL,满足 ~60 帧/秒持续写入。
|
||||
// - 内部互斥锁:接收线程写入,Iterate 线程查询/统计。
|
||||
// 复用仓库内 src/pPowerManger/sqlit3/sqlite3.c 与 sqlite3.h。
|
||||
//============================================================================
|
||||
|
||||
struct CanFrameRow {
|
||||
long long id;
|
||||
double time; // unix 时间(含小数秒)
|
||||
std::string channel; // 通道名(CAN0~CAN7)
|
||||
int frameInfo; // 原始帧信息字节 byte0(bit7 FF / bit6 RTR / bit3~0 DLC)
|
||||
uint32_t canId; // CAN ID
|
||||
int dlc;
|
||||
std::string hex; // 数据十六进制
|
||||
};
|
||||
|
||||
class CanDbStore {
|
||||
public:
|
||||
explicit CanDbStore(const std::string& dbPath);
|
||||
~CanDbStore();
|
||||
|
||||
bool open();
|
||||
void close();
|
||||
bool isOpen() const { return m_db != nullptr; }
|
||||
std::string lastError() const { return m_lastError; }
|
||||
|
||||
// 收帧落库(CanEndpoint 接收线程调用,内部加锁)
|
||||
void onFrame(const std::string& channel, uint8_t frameInfo,
|
||||
uint32_t canId, const uint8_t* data, int dlc);
|
||||
|
||||
// 查询最近 N 条(canId=0 表示不过滤;limit<=0 表示默认 100)
|
||||
std::vector<CanFrameRow> queryRecent(uint32_t canId, int limit);
|
||||
|
||||
long long count() const;
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
bool prepareInsert();
|
||||
|
||||
std::string m_dbPath;
|
||||
sqlite3* m_db = nullptr;
|
||||
sqlite3_stmt* m_stmtInsert = nullptr;
|
||||
mutable std::mutex m_mutex;
|
||||
std::string m_lastError;
|
||||
};
|
||||
|
||||
} // namespace canbridge
|
||||
|
||||
#endif // PCANBRIDGE_CAN_DB_STORE_H
|
||||
@@ -0,0 +1,242 @@
|
||||
#include "CanEndpoint.h"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
namespace canbridge {
|
||||
|
||||
namespace {
|
||||
// CANET 系列固定帧长
|
||||
const size_t kFrameLen = 13;
|
||||
// 非阻塞 connect 超时(ms)
|
||||
const int kConnectTOms = 2000;
|
||||
// recv 读超时(s):保证断线能及时检出、stop() 能及时退出
|
||||
const int kRecvTimeoutS = 1;
|
||||
// 连接失败后的重试间隔(ms)
|
||||
const int kRetryInterval = 2000;
|
||||
} // namespace
|
||||
|
||||
CanEndpoint::CanEndpoint() {
|
||||
m_buf.reserve(4096);
|
||||
}
|
||||
|
||||
CanEndpoint::~CanEndpoint() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void CanEndpoint::configure(const std::string& host, long port) {
|
||||
m_host = host;
|
||||
m_port = port;
|
||||
}
|
||||
|
||||
void CanEndpoint::setFrameCallback(CanFrameCallback cb) {
|
||||
m_onFrame = std::move(cb);
|
||||
}
|
||||
|
||||
bool CanEndpoint::start() {
|
||||
if (m_running) return true;
|
||||
if (m_host.empty() || m_port <= 0) {
|
||||
std::cerr << "[CanEndpoint] invalid target: " << m_host << ":" << m_port << std::endl;
|
||||
return false;
|
||||
}
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() { threadFunc(); });
|
||||
std::cout << "[CanEndpoint] started, target " << m_host << ":" << m_port << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void CanEndpoint::stop() {
|
||||
if (!m_running) return;
|
||||
m_running = false;
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
closeSocket();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 连接一次目标(非阻塞 connect + poll 超时)
|
||||
//----------------------------------------------------------------------
|
||||
bool CanEndpoint::connectOnce() {
|
||||
m_fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (m_fd < 0) return false;
|
||||
|
||||
// 非阻塞 connect,避免目标不可达时线程长时间卡死
|
||||
int flags = ::fcntl(m_fd, F_GETFL, 0);
|
||||
::fcntl(m_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
struct sockaddr_in addr;
|
||||
std::memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<uint16_t>(m_port));
|
||||
if (::inet_pton(AF_INET, m_host.c_str(), &addr.sin_addr) != 1) {
|
||||
std::cerr << "[CanEndpoint] bad host: " << m_host << std::endl;
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = ::connect(m_fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
|
||||
if (rc < 0 && errno != EINPROGRESS) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
if (rc < 0) {
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLOUT;
|
||||
int pr = ::poll(&pfd, 1, kConnectTOms);
|
||||
if (pr <= 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
int err = 0;
|
||||
socklen_t elen = sizeof(err);
|
||||
if (::getsockopt(m_fd, SOL_SOCKET, SO_ERROR, &err, &elen) < 0 || err != 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复阻塞模式 + 读超时 + 禁用 Nagle
|
||||
::fcntl(m_fd, F_SETFL, flags);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = kRecvTimeoutS;
|
||||
tv.tv_usec = 0;
|
||||
::setsockopt(m_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
int one = 1;
|
||||
::setsockopt(m_fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||
|
||||
m_connected = true;
|
||||
++m_reconnectCount;
|
||||
std::cout << "[CanEndpoint] connected to " << m_host << ":" << m_port << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void CanEndpoint::closeSocket() {
|
||||
m_connected = false;
|
||||
if (m_fd >= 0) {
|
||||
::close(m_fd);
|
||||
m_fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 下行发送:组装 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 字节即解析并回调
|
||||
//----------------------------------------------------------------------
|
||||
void CanEndpoint::processBuffer() {
|
||||
while (m_buf.size() >= kFrameLen) {
|
||||
const uint8_t* f = m_buf.data();
|
||||
|
||||
uint8_t frameInfo = f[0];
|
||||
uint8_t dlc = frameInfo & 0x0F;
|
||||
uint32_t id = (static_cast<uint32_t>(f[1]) << 24) |
|
||||
(static_cast<uint32_t>(f[2]) << 16) |
|
||||
(static_cast<uint32_t>(f[3]) << 8) |
|
||||
static_cast<uint32_t>(f[4]);
|
||||
|
||||
// 无效 DLC(>8)按坏帧丢弃;同时覆盖以太网心跳包(AA 00 ... 55)
|
||||
if (dlc > 8) {
|
||||
++m_errorCount;
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + kFrameLen);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m_onFrame) {
|
||||
m_onFrame(frameInfo, id, f + 5, static_cast<int>(dlc));
|
||||
}
|
||||
++m_frameCount;
|
||||
m_lastFrameTime = MOOSTime(false);
|
||||
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + kFrameLen);
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 接收线程:连接 -> 读流 -> 切帧 -> 断线重连
|
||||
//----------------------------------------------------------------------
|
||||
void CanEndpoint::threadFunc() {
|
||||
uint8_t tmp[4096];
|
||||
while (m_running) {
|
||||
if (!m_connected) {
|
||||
if (!connectOnce()) {
|
||||
++m_errorCount;
|
||||
// 分片休眠,保证 stop() 能及时退出
|
||||
for (int i = 0; i < kRetryInterval / 100 && m_running; ++i) {
|
||||
struct timespec ts = {0, 100 * 1000 * 1000}; // 100ms
|
||||
nanosleep(&ts, nullptr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
m_buf.clear();
|
||||
}
|
||||
|
||||
ssize_t n = ::recv(m_fd, tmp, sizeof(tmp), 0);
|
||||
if (n > 0) {
|
||||
m_buf.insert(m_buf.end(), tmp, tmp + n);
|
||||
processBuffer();
|
||||
} else if (n == 0) {
|
||||
// 对端关闭
|
||||
std::cerr << "[CanEndpoint] connection closed by peer" << std::endl;
|
||||
closeSocket();
|
||||
} else {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
|
||||
// 读超时/中断:回环检查 m_running
|
||||
continue;
|
||||
}
|
||||
std::cerr << "[CanEndpoint] recv error: " << std::strerror(errno) << std::endl;
|
||||
closeSocket();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace canbridge
|
||||
@@ -0,0 +1,98 @@
|
||||
#ifndef PCANBRIDGE_CAN_ENDPOINT_H
|
||||
#define PCANBRIDGE_CAN_ENDPOINT_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <functional>
|
||||
|
||||
namespace canbridge {
|
||||
|
||||
//============================================================================
|
||||
// CanEndpoint:USBCAN-8E-U(CANET 系列)TCP 工作端口的客户端封装。
|
||||
//
|
||||
// 协议(CANET-8E-U 用户手册 8.1 节):TCP 为字节流,每个 CAN 帧固定 13 字节:
|
||||
// byte0 帧信息:bit7=FF(1扩展/0标准) bit6=RTR(1远程/0数据) bit3~0=DLC(0~8)
|
||||
// byte1~4 帧 ID(大端,标准帧 11 位有效 / 扩展帧 29 位有效)
|
||||
// byte5~12 数据 8 字节(有效长度由 DLC 决定)
|
||||
//
|
||||
// 职责:
|
||||
// - 以 TCP Client 连接目标 host:port(非阻塞 connect + 超时)
|
||||
// - 接收线程阻塞读流 -> 缓冲 -> 按 13 字节切帧 -> 回调
|
||||
// - 断线(recv 返回 0 / 错误)自动重连
|
||||
//
|
||||
// 说明:采用 POSIX socket 而非 XPCTcpSocket,便于精确控制连接超时与
|
||||
// 读超时(SO_RCVTIMEO),并避免 XPC 系列的异常式错误处理。
|
||||
//============================================================================
|
||||
|
||||
// 收帧回调:frameInfo 为原始帧信息字节;data 有效长度为 dlc 字节
|
||||
using CanFrameCallback = std::function<void(uint8_t frameInfo, uint32_t id,
|
||||
const uint8_t* data, int dlc)>;
|
||||
|
||||
class CanEndpoint {
|
||||
public:
|
||||
CanEndpoint();
|
||||
~CanEndpoint();
|
||||
|
||||
// 配置目标地址(须在 start 前调用)
|
||||
void configure(const std::string& host, long port);
|
||||
|
||||
// 收帧回调(须在 start 前调用)
|
||||
void setFrameCallback(CanFrameCallback cb);
|
||||
|
||||
// 启动接收线程(内部自动连接/重连);返回是否成功启动线程
|
||||
bool start();
|
||||
void stop();
|
||||
|
||||
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; }
|
||||
|
||||
const std::string& host() const { return m_host; }
|
||||
long port() const { return m_port; }
|
||||
|
||||
private:
|
||||
void threadFunc();
|
||||
bool connectOnce();
|
||||
void closeSocket();
|
||||
void processBuffer();
|
||||
|
||||
std::string m_host;
|
||||
long m_port = 4001;
|
||||
int m_fd = -1;
|
||||
|
||||
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;
|
||||
|
||||
// TCP 流式接收缓冲(跨 recv 保持半包/粘包状态)
|
||||
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};
|
||||
};
|
||||
|
||||
} // namespace canbridge
|
||||
|
||||
#endif // PCANBRIDGE_CAN_ENDPOINT_H
|
||||
@@ -0,0 +1,49 @@
|
||||
/************************************************************/
|
||||
/* NAME: pCanBridge */
|
||||
/* FILE: main.cpp */
|
||||
/************************************************************/
|
||||
|
||||
#include <string>
|
||||
#include "MBUtils.h"
|
||||
#include "ColorParse.h"
|
||||
#include "CanBridge.h"
|
||||
#include "CanBridge_Info.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main(int argc, char *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();
|
||||
|
||||
canbridge::CanBridge bridge;
|
||||
bridge.Run(run_command.c_str(), mission_file.c_str());
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//============================================================================
|
||||
// pCanBridge 配置示例(多通道)
|
||||
//
|
||||
// 拓扑(双向透传,每通道一条独立 TCP 连接):
|
||||
// 上行:CANET 工作端口(TCP Server)-> pCanBridge(TCP Client)
|
||||
// -> MOOS "CAN_0x%08X"(二进制 data,m_sSrcAux=通道名)
|
||||
// 下行:MOOS "CAN_TX_0x%08X"(二进制 data,m_sSrcAux=目标通道,
|
||||
// 如 BCU 断路器控制帧 0x10XX81FF,由 pCCU 发布)
|
||||
// -> pCanBridge 按通道路由 -> CANET -> CAN 总线
|
||||
// 变量名 CAN_0x%08X = CAN ID;m_sVal = 二进制 data;
|
||||
// m_sSrcAux = 通道名;上行 m_dfVal2 = 原始帧信息字节(FF/RTR/DLC)
|
||||
//============================================================================
|
||||
|
||||
ProcessConfig = pCanBridge
|
||||
{
|
||||
AppTick = 4
|
||||
CommsTick = 4
|
||||
|
||||
//======== 通道配置(可重复多行) ========
|
||||
// 格式:channel = <通道名>,<转换器IP>,<工作端口>
|
||||
// CANET 工作端口:CAN0=4001,CAN1=4002,...,CAN7=4008
|
||||
channel = CAN0,192.168.0.222,4001
|
||||
// channel = CAN1,192.168.0.222,4002
|
||||
// channel = CAN2,192.168.0.222,4003
|
||||
// ...
|
||||
|
||||
//======== 下行默认通道(可空) ========
|
||||
// CAN_TX 消息 m_sSrcAux 为空时:配置了本项则用它;
|
||||
// 未配置且仅单通道则用该通道;多通道则告警丢弃。
|
||||
default_channel = CAN0
|
||||
|
||||
//======== 存储 ========
|
||||
// CAN 帧 SQLite 落库路径(can_frame 表,channel 列区分通道)
|
||||
dbpath = pCanBridge_data.db
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#--------------------------------------------------------
|
||||
# The CMakeLists.txt for: pELoad
|
||||
# 电子负载(IT6000C 双向电源)控制程序:
|
||||
# 以太网 SCPI 直连 <-> 100ms 功率控制 <- MOOSDB 功率消息
|
||||
# 网页人机交互 + sqlite3 数据存储
|
||||
#--------------------------------------------------------
|
||||
|
||||
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}/sqlit3/sqlite3.c
|
||||
${PM_DIR}/httpserver/mongoose.c
|
||||
)
|
||||
|
||||
SET(ELOAD_DRIVER_SRC
|
||||
driver/ScpiClient.cpp
|
||||
driver/IT6000C.cpp
|
||||
)
|
||||
|
||||
SET(ELOAD_STORE_SRC
|
||||
store/DbStore.cpp
|
||||
)
|
||||
|
||||
SET(ELOAD_WEB_SRC
|
||||
web/WebServer.cpp
|
||||
)
|
||||
|
||||
SET(SRC
|
||||
${SHARED_SRC}
|
||||
${ELOAD_DRIVER_SRC}
|
||||
${ELOAD_STORE_SRC}
|
||||
${ELOAD_WEB_SRC}
|
||||
ELoad.cpp
|
||||
ELoad_Info.cpp
|
||||
main.cpp
|
||||
)
|
||||
|
||||
ADD_EXECUTABLE(pELoad ${SRC})
|
||||
|
||||
TARGET_INCLUDE_DIRECTORIES(pELoad PRIVATE
|
||||
${PM_DIR}
|
||||
${PM_DIR}/sqlit3
|
||||
${PM_DIR}/httpserver
|
||||
${PM_DIR}/logc
|
||||
)
|
||||
|
||||
TARGET_LINK_LIBRARIES(pELoad
|
||||
${MOOS_LIBRARIES}
|
||||
apputil
|
||||
mbutil
|
||||
m
|
||||
pthread
|
||||
jsoncpp
|
||||
dl
|
||||
${SYSTEM_LIBS}
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
#ifndef PELOAD_ELOAD_H
|
||||
#define PELOAD_ELOAD_H
|
||||
|
||||
#define UNIX
|
||||
#include "MOOS/libMOOS/Thirdparty/AppCasting/AppCastingMOOSApp.h"
|
||||
#include "driver/ScpiClient.h"
|
||||
#include "driver/IT6000C.h"
|
||||
#include "store/DbStore.h"
|
||||
#include "web/WebServer.h"
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// ELoad:pELoad 程序主类(MOOS 应用外壳)——电子负载(IT6000C)控制程序。
|
||||
//
|
||||
// 数据流:
|
||||
// 控制回路(独立线程,默认 100ms 周期):
|
||||
// MOOSDB 功率消息(power_var,实时电机功率 W) -> 目标功率 -> I = P/U
|
||||
// -> SCPI CURR 指令 -> 以太网 Raw Socket(默认 30000) -> IT6000C
|
||||
// (CC 优先模式,负电流=吸收电能,模拟电机负载特性)
|
||||
// 测量回路:MEAS:VOLT?/MEAS:CURR?/MEAS:POW? 轮询 -> 状态快照
|
||||
// 上行发布:ELOAD_STATUS(JSON,1Hz)、ELOAD_MEAS_POWER(double)
|
||||
// 人机交互:网页(WebSocket 1Hz 快照 + /api/* 控制接口)
|
||||
// 数据存储:sqlite3(eload_log 运行快照 + scpi_log 非常规指令记录)
|
||||
//
|
||||
// 设备说明见 docs/电子负载说明手册/
|
||||
// (IT6000C User Manual-CN.pdf / IT6000C-Programming-Guide.pdf)。
|
||||
//============================================================================
|
||||
|
||||
class ELoad : public AppCastingMOOSApp {
|
||||
public:
|
||||
ELoad();
|
||||
~ELoad();
|
||||
|
||||
protected:
|
||||
bool OnNewMail(MOOSMSG_LIST &NewMail);
|
||||
bool Iterate();
|
||||
bool OnConnectToServer();
|
||||
bool OnStartUp();
|
||||
bool buildReport();
|
||||
void registerVariables();
|
||||
|
||||
private:
|
||||
//---- 控制回路(独立 100ms 周期线程)----
|
||||
void ctrlThreadFunc();
|
||||
void processWebCmds();
|
||||
void doControlCycle(double now);
|
||||
void applyEnable(double now); // 使能序列:远控+模式+限值+开输出
|
||||
void applyDisable(); // 关输出
|
||||
double computeTargetPower(double now); // 目标功率(含超时/斜率处理)
|
||||
double computeCmdCurrent(double targetP); // 功率折算电流(含限幅)
|
||||
void syncDeviceConfigOnce(double now); // 连接后从设备回读真实配置并接管
|
||||
void pollDevice(); // 异步测量轮询(不阻塞控制循环)
|
||||
void pollError(double now); // 异步错误/FUNC 回读(1Hz 节流)
|
||||
void requestFuncModeReadback(); // 异步回读 FUNC:MODE?(真实反馈驱动显示)
|
||||
void dbPeriodic(double now); // 状态落库 + 历史清理
|
||||
void pruneHistory(double now);
|
||||
|
||||
//---- 网页 ----
|
||||
std::string buildSnapshot();
|
||||
// 返回 (Content-Type, body);body 空串表示 404
|
||||
std::pair<std::string, std::string> handleApi(const std::string& uri, const std::string& query);
|
||||
std::string handleCmdApi(const std::string& query);
|
||||
bool getQueryParam(const std::string& query, const char* key, std::string& out);
|
||||
|
||||
//---- SCPI 收发落库过滤(例行测量不落库;cmd/resp 由 IO 线程配对)----
|
||||
void scpiLogSink(const std::string& cmd, const std::string& resp);
|
||||
|
||||
//---- 网页指令 ----
|
||||
enum WebCmdType {
|
||||
WCMD_CONNECT, WCMD_DISCONNECT,
|
||||
WCMD_OUTP_ON, WCMD_OUTP_OFF,
|
||||
WCMD_REMOTE, WCMD_LOCAL,
|
||||
WCMD_MODE_CC, WCMD_MODE_CV,
|
||||
WCMD_CTRL_FOLLOW, WCMD_CTRL_MANUAL, WCMD_CTRL_CURRENT,
|
||||
WCMD_CLEAR_PROT, WCMD_RESET_DEV, WCMD_IDENTIFY,
|
||||
WCMD_SET_POWER, WCMD_SET_CURRENT, WCMD_SET_VOLT, WCMD_SET_VOLTLIM,
|
||||
WCMD_SET_POWLIM, WCMD_SET_CURRLIM,
|
||||
WCMD_FUNC_MODE // 功能模式 FUNC:MODE(text=模式名)
|
||||
};
|
||||
struct WebCmd {
|
||||
WebCmdType type;
|
||||
double value;
|
||||
std::string text;
|
||||
WebCmd() : type(WCMD_CONNECT), value(0) {}
|
||||
};
|
||||
bool queueWebCmd(WebCmdType t, double v, std::string& err);
|
||||
bool queueWebCmd(WebCmdType t, const std::string& text, std::string& err);
|
||||
|
||||
private:
|
||||
//---- 配置 ----
|
||||
std::string m_host = "192.168.0.10";
|
||||
long m_port = 30000; // Raw Socket 端口(出厂默认 30000)
|
||||
std::string m_powerVar = "MOTOR_POWER";
|
||||
int m_ctrlPeriodMs = 100; // 功率控制周期
|
||||
double m_powerStaleSec= 3.0; // 功率消息超时(0=不判超时)
|
||||
bool m_sinkNegative = true; // true=负电流吸收(负载模拟)
|
||||
double m_nominalVolt = 500.0; // 无电压反馈时的折算电压 V
|
||||
double m_maxPower = 0; // 主机侧功率限幅 W(0=不限)
|
||||
double m_maxCurrent = 0; // 主机侧电流限幅 A(0=不限)
|
||||
double m_slewLimitWps = 0; // 功率斜率限制 W/s(0=不限)
|
||||
std::string m_dbPath = "pELoad.db";
|
||||
std::string m_logPath = "pELoad.log";
|
||||
int m_webPort = 18082;
|
||||
bool m_webEnable = true;
|
||||
double m_logKeepHours = 72; // 历史保留时长(0=永久)
|
||||
int m_measLogPeriodMs = 100; // 测量落库周期(默认=控制周期100ms)
|
||||
bool m_measUseFetch = true; // 测量命令用 FETCh(读缓存,快);false=MEAS(触发测量,慢)
|
||||
|
||||
//---- 设备配置值(web/配置可改;>0 表示需要下发)----
|
||||
double m_voltLimit = 0;
|
||||
double m_currLimitPos = 0;
|
||||
double m_currLimitNeg = 0;
|
||||
double m_powLimitPos = 0;
|
||||
double m_powLimitNeg = 0;
|
||||
bool m_ccPriority = true; // true=CC 优先(负载模拟),false=CV 优先
|
||||
|
||||
//---- 组件 ----
|
||||
ScpiClient m_scpi;
|
||||
IT6000C m_dev;
|
||||
DbStore* m_db = nullptr;
|
||||
WebServer* m_web = nullptr;
|
||||
|
||||
//---- 共享状态(m_stateMutex 保护;控制线程写,MOOS/Web 线程读)----
|
||||
mutable std::mutex m_stateMutex;
|
||||
|
||||
// 控制状态
|
||||
std::string m_mode = "follow"; // follow / manual / current / volt
|
||||
double m_manualPower = 0; // 手动功率目标 W
|
||||
double m_manualCurrent = 0; // 手动电流目标 A
|
||||
double m_voltSet = 0; // CV 优先目标电压 V
|
||||
double m_moosPower = 0; // 最近 MOOS 功率消息
|
||||
double m_moosPowerTime = 0; // 消息到达时刻(MOOSTime)
|
||||
bool m_loadOn = false; // 期望输出状态(控制使能)
|
||||
double m_appliedPower = 0; // 斜率处理后的实际下发功率
|
||||
|
||||
// 设备/链路状态
|
||||
bool m_linkEnable = true; // 自动连接使能
|
||||
std::string m_idn;
|
||||
std::string m_lastError;
|
||||
double m_measV = 0, m_measI = 0, m_measP = 0;
|
||||
double m_measVTime = 0, m_measITime = 0, m_measPTime = 0;
|
||||
// 测量查询诊断(定位"设备在输出但测量不更新")
|
||||
unsigned long m_measOk = 0; // 查询成功次数
|
||||
unsigned long m_measFail = 0; // 查询失败次数
|
||||
std::string m_lastMeasErr; // 最近一次失败原因
|
||||
std::string m_devFunc; // FUNC? 回读:CURR / VOLT
|
||||
std::string m_devFuncMode; // FUNC:MODE? 回读:FIX/LIST/...
|
||||
bool m_funcModeFixWarn = false; // 回读非 FIX 时告警
|
||||
std::string m_funcModeFeed; // 最近功能模式切换指令
|
||||
bool m_funcModeFeedOk = false;
|
||||
double m_funcModeFeedTime = 0;
|
||||
double m_cmdCurrent = 0; // 最近下发的电流指令(CC)
|
||||
double m_cmdVolt = 0; // 最近下发的电压指令(CV)
|
||||
|
||||
//---- 远控/本控状态(IT6000C 无 SYST:REM? 查询,按最近指令+发送结果推断)----
|
||||
bool m_remoteRequested = true; // true=需要远控(每次连接自动发 SYST:REM)
|
||||
std::string m_remoteState = "unknown"; // remote / local / unknown
|
||||
std::string m_remoteFeed = "-"; // 最近远控/本控指令执行结果
|
||||
double m_remoteFeedTime = 0; // 最近反馈时间
|
||||
|
||||
//---- 控制线程私有(无需加锁)----
|
||||
bool m_remoteApplied = false;
|
||||
bool m_cfgApplied = false;
|
||||
bool m_devCfgSyncDone = false; // 连接后是否已回读设备真实配置
|
||||
double m_devCfgSyncTime = 0; // 最近一次配置回读时刻
|
||||
double m_lastSentCurrent = 0;
|
||||
double m_lastSentVolt = 0;
|
||||
double m_lastSendTime = 0;
|
||||
double m_lastErrPoll = 0;
|
||||
double m_lastDbWrite = 0;
|
||||
double m_lastPrune = 0;
|
||||
int m_measRotate = 0;
|
||||
std::atomic<bool> m_measPending{false}; // 异步测量查询进行中(节流)
|
||||
std::vector<WebCmd> m_webCmdQueue; // web 线程入队 -> 控制线程出队
|
||||
|
||||
std::atomic<bool> m_ctrlRunning{false};
|
||||
std::thread m_ctrlThread;
|
||||
|
||||
//---- 统计 ----
|
||||
unsigned long m_pubCount = 0;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_ELOAD_H
|
||||
@@ -0,0 +1,117 @@
|
||||
/****************************************************************/
|
||||
/* NAME: ELoad_Info */
|
||||
/* FILE: ELoad_Info.cpp */
|
||||
/****************************************************************/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include "ELoad_Info.h"
|
||||
#include "ColorParse.h"
|
||||
#include "ReleaseInfo.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
void showSynopsis() {
|
||||
blk("SYNOPSIS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" The pELoad application controls the IT6000C bi-directional ");
|
||||
blk(" DC power supply / electronic load over Ethernet (raw socket ");
|
||||
blk(" SCPI, default port 30000). It provides 100ms periodic power ");
|
||||
blk(" control: subscribes the MOOS power variable (real-time motor ");
|
||||
blk(" power published by other processes) and drives the load to ");
|
||||
blk(" reproduce the motor power profile. A web page is provided ");
|
||||
blk(" for human-machine interaction and measurements are stored ");
|
||||
blk(" in SQLite. Device manual: ");
|
||||
blk(" docs/电子负载说明手册 (IT6000C User Manual-CN / ");
|
||||
blk(" IT6000C-Programming-Guide). ");
|
||||
blk(" ");
|
||||
}
|
||||
|
||||
void showHelpAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("Usage: pELoad file.moos [OPTIONS] ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("Options: ");
|
||||
mag(" --alias","=<ProcessName> ");
|
||||
blk(" Launch pELoad with the given process name ");
|
||||
blk(" rather than pELoad. ");
|
||||
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 pELoad. ");
|
||||
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("pELoad Example MOOS Configuration ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
blk("ProcessConfig = pELoad ");
|
||||
blk("{ ");
|
||||
blk(" AppTick = 10 // 100ms MOOS 节拍 ");
|
||||
blk(" CommsTick = 4 ");
|
||||
blk(" ");
|
||||
blk(" eload_ip = 192.168.0.10 // 电子负载 IP(LAN 配置) ");
|
||||
blk(" eload_port = 30000 // Raw Socket 端口号 ");
|
||||
blk(" power_var = MOTOR_POWER // MOOS 功率消息变量名(W) ");
|
||||
blk(" ctrl_period_ms= 100 // 功率控制周期 ms ");
|
||||
blk(" power_stale_sec = 3 // 功率消息超时秒数(0=不判超时) ");
|
||||
blk(" current_sign = negative // negative=吸收(负载模拟) ");
|
||||
blk(" nominal_volt = 500 // 无电压反馈时的折算电压(V) ");
|
||||
blk(" max_power = 0 // 主机侧功率限幅(W),0=不限 ");
|
||||
blk(" max_current = 0 // 主机侧电流限幅(A),0=不限 ");
|
||||
blk(" slew_limit_wps= 0 // 功率斜率限制(W/s),0=不限 ");
|
||||
blk(" func_priority = cc // 环路优先:cc=电流型负载 / cv=恒压吸收 ");
|
||||
blk(" volt_set = 0 // CV 优先目标电压(V),func_priority=cv 时用 ");
|
||||
blk(" dbpath = pELoad.db // SQLite 数据库路径 ");
|
||||
blk(" log_keep_hours= 72 // 历史保留时长(小时,0=永久) ");
|
||||
blk(" meas_log_period_ms = 100 // 实测V/I/P落库周期ms(0=仅1Hz快照) ");
|
||||
blk(" meas_source = fetch // 测量命令: fetch=FETCh(快) / meas=MEAS(慢) ");
|
||||
blk(" web_port = 18082 // 网页端口(避开8080/8090/18080/18081) ");
|
||||
blk(" web_enable = true // 是否启用网页 ");
|
||||
blk(" logpath = pELoad.log // 日志路径 ");
|
||||
blk("} ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showInterfaceAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("pELoad INTERFACE ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("SUBSCRIPTIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" MOTOR_POWER = 实时电机功率消息(W),变量名可配置 ");
|
||||
blk(" (power_var),正值=负载吸收功率 ");
|
||||
blk(" ");
|
||||
blk("PUBLICATIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" ELOAD_STATUS = 电子负载状态 JSON 串(1Hz) ");
|
||||
blk(" ELOAD_MEAS_POWER = 实测功率 (W, double) ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showReleaseInfoAndExit() {
|
||||
showReleaseInfo("pELoad", "gpl");
|
||||
exit(0);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/****************************************************************/
|
||||
/* NAME: ELoad_Info */
|
||||
/* FILE: ELoad_Info.h */
|
||||
/****************************************************************/
|
||||
|
||||
#ifndef ELOAD_INFO_HEADER
|
||||
#define ELOAD_INFO_HEADER
|
||||
|
||||
void showSynopsis();
|
||||
void showHelpAndExit();
|
||||
void showExampleConfigAndExit();
|
||||
void showInterfaceAndExit();
|
||||
void showReleaseInfoAndExit();
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "IT6000C.h"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// 基础
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::identify(std::string& idn) {
|
||||
return m_scpi.query("*IDN?", idn, 300);
|
||||
}
|
||||
|
||||
bool IT6000C::remote() {
|
||||
// 连接建立后必须先进入远控,否则设置命令被面板本地态拒绝。
|
||||
// 用 cmdConfirmed 等待真实写入结果,明确反馈成功/失败。
|
||||
return m_scpi.cmdConfirmed("SYST:REM", 300);
|
||||
}
|
||||
|
||||
bool IT6000C::local() {
|
||||
return m_scpi.cmdConfirmed("SYST:LOC", 300);
|
||||
}
|
||||
|
||||
bool IT6000C::reset() {
|
||||
return m_scpi.cmd("*RST");
|
||||
}
|
||||
|
||||
bool IT6000C::clearStatus() {
|
||||
return m_scpi.cmd("*CLS");
|
||||
}
|
||||
|
||||
bool IT6000C::opComplete() {
|
||||
std::string r;
|
||||
return m_scpi.query("*OPC?", r, 300);
|
||||
}
|
||||
|
||||
bool IT6000C::nextError(std::string& err) {
|
||||
return m_scpi.query("SYST:ERR?", err, 200);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 输出
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::output(bool on) {
|
||||
return m_scpi.cmd(on ? "OUTP 1" : "OUTP 0");
|
||||
}
|
||||
|
||||
bool IT6000C::queryOutput(bool& on) {
|
||||
std::string r;
|
||||
if (!m_scpi.query("OUTP?", r, 200)) return false;
|
||||
on = (r.find("1") != std::string::npos);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IT6000C::clearProtection() {
|
||||
return m_scpi.cmd("OUTP:PROT:CLE");
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 工作模式
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::setFunctionCC() {
|
||||
return m_scpi.cmd("FUNC CURR");
|
||||
}
|
||||
|
||||
bool IT6000C::setFunctionCV() {
|
||||
return m_scpi.cmd("FUNC VOLT");
|
||||
}
|
||||
|
||||
bool IT6000C::queryFunction(std::string& func) {
|
||||
return m_scpi.query("FUNC?", func, 200);
|
||||
}
|
||||
|
||||
bool IT6000C::setFunctionModeFixed() {
|
||||
return m_scpi.cmd("FUNC:MODE FIX");
|
||||
}
|
||||
|
||||
bool IT6000C::queryFunctionMode(std::string& mode) {
|
||||
return m_scpi.query("FUNC:MODE?", mode, 200);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 设定值
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::setDouble(const char* scpi, double v) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%s %.6f", scpi, v);
|
||||
return m_scpi.cmd(buf);
|
||||
}
|
||||
|
||||
bool IT6000C::setCurrent(double amp) { return setDouble("CURR", amp); }
|
||||
bool IT6000C::setVoltage(double volt) { return setDouble("VOLT", volt); }
|
||||
bool IT6000C::setVoltageLimit(double volt){ return setDouble("VOLT:LIM", volt); }
|
||||
bool IT6000C::setCurrentLimitPos(double amp) { return setDouble("CURR:LIM", amp); }
|
||||
bool IT6000C::setCurrentLimitNeg(double amp) { return setDouble("CURR:LIM:NEG", amp); }
|
||||
bool IT6000C::setPowerLimitPos(double watt) { return setDouble("POW:LIM", watt); }
|
||||
bool IT6000C::setPowerLimitNeg(double watt) { return setDouble("POW:LIM:NEG", watt); }
|
||||
|
||||
bool IT6000C::queryCurrent(double& amp) { return queryDouble("CURR?", amp); }
|
||||
bool IT6000C::queryVoltage(double& volt) { return queryDouble("VOLT?", volt); }
|
||||
bool IT6000C::queryVoltageLimit(double& volt) { return queryDouble("VOLT:LIM?", volt); }
|
||||
bool IT6000C::queryCurrentLimitPos(double& amp) { return queryDouble("CURR:LIM?", amp); }
|
||||
bool IT6000C::queryCurrentLimitNeg(double& amp) { return queryDouble("CURR:LIM:NEG?", amp); }
|
||||
bool IT6000C::queryPowerLimitPos(double& watt) { return queryDouble("POW:LIM?", watt); }
|
||||
bool IT6000C::queryPowerLimitNeg(double& watt) { return queryDouble("POW:LIM:NEG?", watt); }
|
||||
|
||||
//============================================================================
|
||||
// 测量
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::queryDouble(const char* scpi, double& v) {
|
||||
std::string r;
|
||||
if (!m_scpi.query(scpi, r, 150)) return false;
|
||||
if (r.empty()) return false;
|
||||
char* end = nullptr;
|
||||
double d = std::strtod(r.c_str(), &end);
|
||||
if (end == r.c_str()) return false;
|
||||
v = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IT6000C::measureVoltage(double& v) { return queryDouble("MEAS:VOLT?", v); }
|
||||
bool IT6000C::measureCurrent(double& i) { return queryDouble("MEAS:CURR?", i); }
|
||||
bool IT6000C::measurePower(double& p) { return queryDouble("MEAS:POW?", p); }
|
||||
|
||||
bool IT6000C::fetchVoltage(double& v) { return queryDouble("FETCh:VOLT?", v); }
|
||||
bool IT6000C::fetchCurrent(double& i) { return queryDouble("FETCh:CURR?", i); }
|
||||
bool IT6000C::fetchPower(double& p) { return queryDouble("FETCh:POW?", p); }
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,91 @@
|
||||
#ifndef PELOAD_IT6000C_H
|
||||
#define PELOAD_IT6000C_H
|
||||
|
||||
#include <string>
|
||||
#include "ScpiClient.h"
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// IT6000C:ITECH IT6000C 系列双向可编程直流电源(电子负载)SCPI 命令封装。
|
||||
//
|
||||
// 命令来源:docs/电子负载说明手册/IT6000C-Programming-Guide.pdf
|
||||
// - 双向电源:正电流=输出电能(源),负电流=吸收电能(负载模拟);
|
||||
// - 环路优先模式 FUNC CURR|VOLT:
|
||||
// CC 优先:按设定电流调节,电压限值用 VOLT:LIM(电流型负载模拟);
|
||||
// CV 优先:按设定电压调节,电流限值用 CURR:LIM(恒压吸收);
|
||||
// - 功能模式 FUNC:MODE FIX|LIST|BATT|SOLAR|CARProfile:
|
||||
// 实时功率控制必须保持 FIX,其余为瞬态/波形/电池测试模式;
|
||||
// - 输出开关 OUTP 0|1;
|
||||
// - 测量 MEAS:VOLT? / MEAS:CURR? / MEAS:POW?;
|
||||
// - 远控 SYST:REM(连接后必须先发,否则面板锁定状态不接受设置)。
|
||||
//============================================================================
|
||||
|
||||
struct IT6000CMeas {
|
||||
double voltage;
|
||||
double current;
|
||||
double power;
|
||||
IT6000CMeas() : voltage(0), current(0), power(0) {}
|
||||
};
|
||||
|
||||
class IT6000C {
|
||||
public:
|
||||
explicit IT6000C(ScpiClient& scpi) : m_scpi(scpi) {}
|
||||
|
||||
// 基础
|
||||
bool identify(std::string& idn); // *IDN?
|
||||
bool remote(); // SYST:REM 进入远控
|
||||
bool local(); // SYST:LOC 回面板控制
|
||||
bool reset(); // *RST
|
||||
bool clearStatus(); // *CLS
|
||||
bool opComplete(); // *OPC?
|
||||
bool nextError(std::string& err); // SYST:ERR?
|
||||
|
||||
// 输出
|
||||
bool output(bool on); // OUTP 1|0
|
||||
bool queryOutput(bool& on); // OUTP?
|
||||
bool clearProtection(); // OUTP:PROT:CLE
|
||||
|
||||
// 工作模式
|
||||
bool setFunctionCC(); // FUNC CURR(CC 优先,电流型负载模拟)
|
||||
bool setFunctionCV(); // FUNC VOLT(CV 优先,恒压吸收)
|
||||
bool queryFunction(std::string& func); // FUNC?
|
||||
bool setFunctionModeFixed(); // FUNC:MODE FIX(实时控制须保持 FIX)
|
||||
bool queryFunctionMode(std::string& mode); // FUNC:MODE?
|
||||
|
||||
// 设定值
|
||||
bool setCurrent(double amp); // CURR(正=输出 / 负=吸收)
|
||||
bool setVoltage(double volt); // VOLT(CV 模式设定值)
|
||||
bool setVoltageLimit(double volt); // VOLT:LIM(CC 模式电压限值)
|
||||
bool setCurrentLimitPos(double amp); // CURR:LIM
|
||||
bool setCurrentLimitNeg(double amp); // CURR:LIM:NEG
|
||||
bool setPowerLimitPos(double watt); // POW:LIM
|
||||
bool setPowerLimitNeg(double watt); // POW:LIM:NEG
|
||||
|
||||
// 配置回读(程序重启/重连后从设备同步真实状态)
|
||||
bool queryCurrent(double& amp); // CURR?
|
||||
bool queryVoltage(double& volt); // VOLT?
|
||||
bool queryVoltageLimit(double& volt); // VOLT:LIM?
|
||||
bool queryCurrentLimitPos(double& amp); // CURR:LIM?
|
||||
bool queryCurrentLimitNeg(double& amp); // CURR:LIM:NEG?
|
||||
bool queryPowerLimitPos(double& watt); // POW:LIM?
|
||||
bool queryPowerLimitNeg(double& watt); // POW:LIM:NEG?
|
||||
|
||||
// 测量
|
||||
bool measureVoltage(double& v); // MEAS:VOLT?(触发一次测量,较慢)
|
||||
bool measureCurrent(double& i); // MEAS:CURR?
|
||||
bool measurePower(double& p); // MEAS:POW?
|
||||
// 读取表头缓存测量值(不触发测量,响应快,适合 100ms 轮询)
|
||||
bool fetchVoltage(double& v); // FETCh:VOLT?
|
||||
bool fetchCurrent(double& i); // FETCh:CURR?
|
||||
bool fetchPower(double& p); // FETCh:POW?
|
||||
|
||||
private:
|
||||
bool setDouble(const char* scpi, double v);
|
||||
bool queryDouble(const char* scpi, double& v);
|
||||
|
||||
ScpiClient& m_scpi;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_IT6000C_H
|
||||
@@ -0,0 +1,437 @@
|
||||
#include "ScpiClient.h"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
|
||||
namespace eload {
|
||||
|
||||
namespace {
|
||||
// 非阻塞 connect 超时(ms)
|
||||
const int kConnectTOms = 2000;
|
||||
// recv 读超时(s):保证断线能及时检出、stop() 能及时退出
|
||||
const int kRecvTimeoutS = 1;
|
||||
// 连接失败后的重试间隔(ms)
|
||||
const int kRetryInterval = 2000;
|
||||
// 单条命令最大长度(防异常数据撑爆缓冲)
|
||||
const size_t kMaxLineLen = 4096;
|
||||
} // namespace
|
||||
|
||||
ScpiClient::ScpiClient() {
|
||||
m_buf.reserve(4096);
|
||||
}
|
||||
|
||||
ScpiClient::~ScpiClient() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void ScpiClient::configure(const std::string& host, long port) {
|
||||
m_host = host;
|
||||
m_port = port;
|
||||
}
|
||||
|
||||
void ScpiClient::setLogSink(LogSink cb) {
|
||||
m_logSink = std::move(cb);
|
||||
}
|
||||
|
||||
void ScpiClient::setStateCallback(StateCallback cb) {
|
||||
m_stateCb = std::move(cb);
|
||||
}
|
||||
|
||||
bool ScpiClient::start() {
|
||||
if (m_running) return true;
|
||||
if (m_host.empty() || m_port <= 0) {
|
||||
std::cerr << "[ScpiClient] invalid target: " << m_host << ":" << m_port << std::endl;
|
||||
return false;
|
||||
}
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() { threadFunc(); });
|
||||
std::cout << "[ScpiClient] started, target " << m_host << ":" << m_port << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScpiClient::stop() {
|
||||
if (!m_running) return;
|
||||
m_running = false;
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
closeSocket();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 连接一次目标(非阻塞 connect + poll 超时)
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::connectOnce() {
|
||||
m_fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (m_fd < 0) return false;
|
||||
|
||||
// 非阻塞 connect,避免目标不可达时线程长时间卡死
|
||||
int flags = ::fcntl(m_fd, F_GETFL, 0);
|
||||
::fcntl(m_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
struct sockaddr_in addr;
|
||||
std::memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<uint16_t>(m_port));
|
||||
if (::inet_pton(AF_INET, m_host.c_str(), &addr.sin_addr) != 1) {
|
||||
std::cerr << "[ScpiClient] bad host: " << m_host << std::endl;
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = ::connect(m_fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
|
||||
if (rc < 0 && errno != EINPROGRESS) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
if (rc < 0) {
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLOUT;
|
||||
int pr = ::poll(&pfd, 1, kConnectTOms);
|
||||
if (pr <= 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
int err = 0;
|
||||
socklen_t elen = sizeof(err);
|
||||
if (::getsockopt(m_fd, SOL_SOCKET, SO_ERROR, &err, &elen) < 0 || err != 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复阻塞模式 + 读超时 + 禁用 Nagle(100ms 控制环要求低延迟)
|
||||
::fcntl(m_fd, F_SETFL, flags);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = kRecvTimeoutS;
|
||||
tv.tv_usec = 0;
|
||||
::setsockopt(m_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
int one = 1;
|
||||
::setsockopt(m_fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||
|
||||
m_connected = true;
|
||||
++m_reconnectCount;
|
||||
std::cout << "[ScpiClient] connected to " << m_host << ":" << m_port << std::endl;
|
||||
if (m_stateCb) m_stateCb(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScpiClient::closeSocket() {
|
||||
bool was = m_connected.exchange(false);
|
||||
if (m_fd >= 0) {
|
||||
::close(m_fd);
|
||||
m_fd = -1;
|
||||
}
|
||||
if (was && m_stateCb) m_stateCb(false);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 写一行 SCPI(LF 结尾),全部写出才算成功
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::writeLine(const std::string& scpi) {
|
||||
std::string frame = scpi;
|
||||
if (frame.empty() || frame[frame.size() - 1] != '\n')
|
||||
frame += '\n';
|
||||
|
||||
size_t sent = 0;
|
||||
while (sent < frame.size()) {
|
||||
ssize_t n = ::send(m_fd, frame.data() + sent, frame.size() - sent, MSG_NOSIGNAL);
|
||||
if (n > 0) {
|
||||
sent += static_cast<size_t>(n);
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
std::cerr << "[ScpiClient] send error: " << std::strerror(errno) << std::endl;
|
||||
return false;
|
||||
}
|
||||
++m_txCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 读一行(LF 结尾),deadlineMs 内未完成返回 false
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::readLine(std::string& line, int deadlineMs) {
|
||||
double t0 = MOOSTime(false);
|
||||
while (m_running) {
|
||||
// 先检查缓冲中是否已有完整行
|
||||
for (size_t i = 0; i < m_buf.size(); ++i) {
|
||||
if (m_buf[i] == '\n') {
|
||||
line.assign(reinterpret_cast<const char*>(m_buf.data()), i);
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + (i + 1));
|
||||
if (!line.empty() && line[line.size() - 1] == '\r')
|
||||
line.erase(line.size() - 1);
|
||||
++m_rxCount;
|
||||
m_lastRxTime = MOOSTime(false);
|
||||
// 收发记录由 runQueue 按“命令-响应”配对后统一回调
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (m_buf.size() > kMaxLineLen * 4) {
|
||||
// 异常数据流,丢弃防止内存膨胀
|
||||
std::cerr << "[ScpiClient] rx buffer overflow, reset" << std::endl;
|
||||
m_buf.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
int elapsed = static_cast<int>((MOOSTime(false) - t0) * 1000.0);
|
||||
int remain = deadlineMs - elapsed;
|
||||
if (remain <= 0) return false;
|
||||
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLIN;
|
||||
int pr = ::poll(&pfd, 1, remain < 20 ? remain : 20);
|
||||
if (pr < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return false;
|
||||
}
|
||||
if (pr == 0) continue;
|
||||
|
||||
uint8_t tmp[1024];
|
||||
ssize_t n = ::recv(m_fd, tmp, sizeof(tmp), 0);
|
||||
if (n > 0) {
|
||||
m_buf.insert(m_buf.end(), tmp, tmp + n);
|
||||
} else if (n == 0) {
|
||||
std::cerr << "[ScpiClient] connection closed by peer" << std::endl;
|
||||
return false;
|
||||
} else {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||
std::cerr << "[ScpiClient] recv error: " << std::strerror(errno) << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// IO 线程:连接 -> 取队列 -> 写出 ->(查询时)等响应 -> 断线重连
|
||||
//----------------------------------------------------------------------
|
||||
void ScpiClient::threadFunc() {
|
||||
while (m_running) {
|
||||
if (!m_connected) {
|
||||
if (!connectOnce()) {
|
||||
++m_errorCount;
|
||||
// 分片休眠,保证 stop() 能及时退出
|
||||
for (int i = 0; i < kRetryInterval / 100 && m_running; ++i) {
|
||||
struct timespec ts = {0, 100 * 1000 * 1000}; // 100ms
|
||||
nanosleep(&ts, nullptr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
m_buf.clear();
|
||||
}
|
||||
|
||||
runQueue();
|
||||
|
||||
if (!m_connected) continue;
|
||||
|
||||
// 无命令时短暂让出 CPU(poll 空转兼作断线检测)
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLIN;
|
||||
::poll(&pfd, 1, 10);
|
||||
// 仪器主动上报的数据(一般没有)读出丢弃,防止缓冲堆积
|
||||
uint8_t tmp[512];
|
||||
ssize_t n = ::recv(m_fd, tmp, sizeof(tmp), MSG_DONTWAIT);
|
||||
if (n > 0) {
|
||||
m_buf.insert(m_buf.end(), tmp, tmp + n);
|
||||
// 丢弃已完成的行(无查询对应的响应),仅保留可能存在的部分行
|
||||
size_t lastLF = std::string::npos;
|
||||
for (size_t i = m_buf.size(); i > 0; --i) {
|
||||
if (m_buf[i - 1] == '\n') { lastLF = i - 1; break; }
|
||||
}
|
||||
if (lastLF != std::string::npos) {
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + (lastLF + 1));
|
||||
}
|
||||
} else if (n == 0) {
|
||||
std::cerr << "[ScpiClient] connection closed by peer" << std::endl;
|
||||
closeSocket();
|
||||
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
|
||||
std::cerr << "[ScpiClient] recv error: " << std::strerror(errno) << std::endl;
|
||||
closeSocket();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScpiClient::runQueue() {
|
||||
while (m_running && m_connected) {
|
||||
std::shared_ptr<Work> item;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.empty()) return;
|
||||
item = m_queue.front();
|
||||
m_queue.pop_front();
|
||||
}
|
||||
|
||||
if (!writeLine(item->cmd)) {
|
||||
// 发送失败:命令未送达,标记失败并视为断线
|
||||
item->ok = false;
|
||||
item->done = true;
|
||||
++m_txErrorCount;
|
||||
if (m_logSink) m_logSink(item->cmd, "<send failed>");
|
||||
m_queueCv.notify_all();
|
||||
// 异步回调(IO 线程内,注意不要在锁内调用)
|
||||
if (item->cb) item->cb(false, "");
|
||||
closeSocket();
|
||||
return;
|
||||
}
|
||||
if (item->expectReply) {
|
||||
std::string line;
|
||||
if (readLine(line, 500)) {
|
||||
item->resp = line;
|
||||
item->ok = true;
|
||||
if (m_logSink) m_logSink(item->cmd, line);
|
||||
} else {
|
||||
item->ok = false;
|
||||
if (m_logSink) m_logSink(item->cmd, m_connected ? "<timeout>" : "<disconnected>");
|
||||
if (!m_connected) {
|
||||
item->done = true;
|
||||
m_queueCv.notify_all();
|
||||
if (item->cb) item->cb(false, "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item->ok = true;
|
||||
if (m_logSink) m_logSink(item->cmd, "");
|
||||
}
|
||||
item->done = true;
|
||||
m_queueCv.notify_all();
|
||||
if (item->cb) item->cb(item->ok, item->resp);
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 对外接口
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::cmd(const std::string& scpi) {
|
||||
std::shared_ptr<Work> item(new Work());
|
||||
item->cmd = scpi;
|
||||
item->expectReply = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.size() >= 64) {
|
||||
++m_txErrorCount;
|
||||
return false;
|
||||
}
|
||||
m_queue.push_back(item);
|
||||
}
|
||||
m_queueCv.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScpiClient::cmdConfirmed(const std::string& scpi, int timeoutMs) {
|
||||
std::shared_ptr<Work> item(new Work());
|
||||
item->cmd = scpi;
|
||||
item->expectReply = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.size() >= 64) {
|
||||
++m_txErrorCount;
|
||||
return false;
|
||||
}
|
||||
m_queue.push_back(item);
|
||||
}
|
||||
m_queueCv.notify_all();
|
||||
|
||||
// 等待 IO 线程处理完成:done 表示已尝试写入,ok 为写入结果
|
||||
double t0 = MOOSTime(false);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_queueMutex);
|
||||
while (!item->done) {
|
||||
int elapsed = static_cast<int>((MOOSTime(false) - t0) * 1000.0);
|
||||
int remain = timeoutMs - elapsed;
|
||||
if (remain <= 0) break;
|
||||
m_queueCv.wait_for(lock, std::chrono::milliseconds(remain < 5 ? 5 : remain));
|
||||
}
|
||||
if (!item->done) {
|
||||
// 超时未处理:从队列移除(若仍存在),防止后续错配
|
||||
for (std::deque<std::shared_ptr<Work> >::iterator it = m_queue.begin();
|
||||
it != m_queue.end(); ++it) {
|
||||
if (*it == item) {
|
||||
m_queue.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
++m_errorCount;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!item->ok) {
|
||||
++m_txErrorCount;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScpiClient::query(const std::string& scpi, std::string& resp, int timeoutMs) {
|
||||
// 同一时刻仅一个同步查询(避免控制线程/网页线程交叉)
|
||||
std::lock_guard<std::mutex> qlock(m_queryMutex);
|
||||
|
||||
std::shared_ptr<Work> item(new Work());
|
||||
item->cmd = scpi;
|
||||
item->expectReply = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.size() >= 64) return false;
|
||||
m_queue.push_back(item);
|
||||
}
|
||||
m_queueCv.notify_all();
|
||||
|
||||
// 等待完成(含查询响应或失败标记)
|
||||
double t0 = MOOSTime(false);
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_queueMutex);
|
||||
while (!item->done) {
|
||||
int elapsed = static_cast<int>((MOOSTime(false) - t0) * 1000.0);
|
||||
int remain = timeoutMs - elapsed;
|
||||
if (remain <= 0) break;
|
||||
m_queueCv.wait_for(lock, std::chrono::milliseconds(
|
||||
remain < 5 ? 5 : remain));
|
||||
}
|
||||
if (!item->done) {
|
||||
// 超时:从队列移除,防止幽灵响应错配
|
||||
for (std::deque<std::shared_ptr<Work> >::iterator it = m_queue.begin();
|
||||
it != m_queue.end(); ++it) {
|
||||
if (*it == item) {
|
||||
m_queue.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
++m_errorCount;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!item->ok) {
|
||||
++m_errorCount;
|
||||
return false;
|
||||
}
|
||||
resp = item->resp;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScpiClient::queryAsync(const std::string& scpi, QueryCallback cb) {
|
||||
std::shared_ptr<Work> item(new Work());
|
||||
item->cmd = scpi;
|
||||
item->expectReply = true;
|
||||
item->cb = std::move(cb);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.size() >= 64) return false;
|
||||
m_queue.push_back(item);
|
||||
}
|
||||
m_queueCv.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,129 @@
|
||||
#ifndef PELOAD_SCPI_CLIENT_H
|
||||
#define PELOAD_SCPI_CLIENT_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <functional>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// ScpiClient:IT6000C Raw Socket SCPI 客户端(TCP,LF 结尾,自动重连)。
|
||||
//
|
||||
// 协议要点(docs/电子负载说明手册/IT6000C User Manual-CN 2.5.2.3 使用套接字):
|
||||
// - 仪器提供 SCPI 套接字服务(Socket Port 可配置,出厂默认 30000);
|
||||
// - 所有命令必须以换行符(\n)结尾;查询响应同样以换行符结束;
|
||||
// - 仪器最多同时允许 6 个套接字/telnet 连接。
|
||||
//
|
||||
// 线程模型:
|
||||
// - 内部一个 IO 线程:负责连接/重连、命令写出、响应读取;
|
||||
// - cmd() 非阻塞入队(写后无响应);
|
||||
// - query() 同步等待响应(同一时刻仅允许一个查询,互斥保护);
|
||||
// - 本线程不调用任何 MOOS 接口,跨线程数据仅通过回调投递。
|
||||
//============================================================================
|
||||
|
||||
class ScpiClient {
|
||||
public:
|
||||
// SCPI 收发记录回调(落库用,IO 线程内调用):
|
||||
// 命令型:resp 为空;查询型:resp 为响应文本(含失败标记)
|
||||
using LogSink = std::function<void(const std::string& cmd, const std::string& resp)>;
|
||||
// 连接状态变化回调(IO 线程内调用)
|
||||
using StateCallback = std::function<void(bool connected)>;
|
||||
|
||||
ScpiClient();
|
||||
~ScpiClient();
|
||||
|
||||
void configure(const std::string& host, long port); // start 前调用
|
||||
void setLogSink(LogSink cb); // start 前调用
|
||||
void setStateCallback(StateCallback cb); // start 前调用
|
||||
|
||||
bool start(); // 启动 IO 线程(自动连接/重连)
|
||||
void stop();
|
||||
|
||||
bool isConnected() const { return m_connected; }
|
||||
|
||||
// 写命令(无响应期待)。未连接/发送失败按错误计数并丢弃。
|
||||
// 返回 true 仅表示"已入队",不保证写入成功。
|
||||
bool cmd(const std::string& scpi);
|
||||
|
||||
// 写命令并等待确认:阻塞到 IO 线程真正把命令写入 socket(或超时)。
|
||||
// 用于需要明确成功/失败反馈的关键指令(如 SYST:REM / SYST:LOC)。
|
||||
bool cmdConfirmed(const std::string& scpi, int timeoutMs = 300);
|
||||
|
||||
// 同步查询:发送 scpi 并等待一行响应。
|
||||
// timeoutMs 建议不大于控制周期(100ms 循环里建议 <= 80)。
|
||||
bool query(const std::string& scpi, std::string& resp, int timeoutMs = 200);
|
||||
|
||||
// 异步查询:发送 scpi,IO 线程收到响应后调用回调(IO 线程内)。
|
||||
// 不阻塞调用线程——用于测量轮询,避免拖慢 100ms 功率控制循环。
|
||||
// 回调注意:运行在 IO 线程,勿直接调用会阻塞的操作。
|
||||
using QueryCallback = std::function<void(bool ok, const std::string& resp)>;
|
||||
bool queryAsync(const std::string& scpi, QueryCallback cb);
|
||||
|
||||
// 统计
|
||||
unsigned long txCount() const { return m_txCount; }
|
||||
unsigned long txErrorCount() const { return m_txErrorCount; }
|
||||
unsigned long rxCount() const { return m_rxCount; }
|
||||
unsigned long errorCount() const { return m_errorCount; }
|
||||
unsigned long reconnectCount() const { return m_reconnectCount; }
|
||||
double lastRxTime() const { return m_lastRxTime; }
|
||||
|
||||
const std::string& host() const { return m_host; }
|
||||
long port() const { return m_port; }
|
||||
|
||||
private:
|
||||
struct Work {
|
||||
std::string cmd;
|
||||
bool expectReply;
|
||||
std::string resp;
|
||||
bool done;
|
||||
bool ok;
|
||||
QueryCallback cb; // 异步查询回调(IO 线程调用)
|
||||
Work() : expectReply(false), done(false), ok(false) {}
|
||||
};
|
||||
|
||||
void threadFunc();
|
||||
bool connectOnce();
|
||||
void closeSocket();
|
||||
bool writeLine(const std::string& scpi);
|
||||
// 从 socket 读一行(LF 结尾),deadlineMs 内未完成返回 false
|
||||
bool readLine(std::string& line, int deadlineMs);
|
||||
void runQueue();
|
||||
|
||||
std::string m_host;
|
||||
long m_port = 30000;
|
||||
int m_fd = -1;
|
||||
|
||||
std::atomic<bool> m_running{false};
|
||||
std::atomic<bool> m_connected{false};
|
||||
std::thread m_thread;
|
||||
|
||||
// 命令队列:cmd() 与 query() 共用;IO 线程逐条处理
|
||||
std::mutex m_queueMutex;
|
||||
std::condition_variable m_queueCv;
|
||||
std::deque<std::shared_ptr<Work> > m_queue;
|
||||
// 同一时刻仅允许一个同步查询
|
||||
std::mutex m_queryMutex;
|
||||
|
||||
LogSink m_logSink; // 任意线程注册,IO 线程调用(start 前 set)
|
||||
StateCallback m_stateCb;
|
||||
|
||||
std::vector<uint8_t> m_buf; // 流式接收缓冲(半包/粘包按 \n 切分)
|
||||
|
||||
std::atomic<unsigned long> m_txCount{0};
|
||||
std::atomic<unsigned long> m_txErrorCount{0};
|
||||
std::atomic<unsigned long> m_rxCount{0};
|
||||
std::atomic<unsigned long> m_errorCount{0};
|
||||
std::atomic<unsigned long> m_reconnectCount{0};
|
||||
std::atomic<double> m_lastRxTime{0.0};
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_SCPI_CLIENT_H
|
||||
@@ -0,0 +1,54 @@
|
||||
/************************************************************/
|
||||
/* NAME: pELoad */
|
||||
/* FILE: main.cpp */
|
||||
/************************************************************/
|
||||
|
||||
#include <string>
|
||||
#include "MBUtils.h"
|
||||
#include "ColorParse.h"
|
||||
#include "ELoad.h"
|
||||
#include "ELoad_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, "pELoad launching as %s", run_command.c_str());
|
||||
|
||||
eload::ELoad ELoad;
|
||||
ELoad.Run(run_command.c_str(), mission_file.c_str());
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// MOOS file
|
||||
// pELoad 电子负载(IT6000C 双向可编程直流电源)控制程序 配置示例
|
||||
//
|
||||
// 功能:
|
||||
// - 以太网 Raw Socket SCPI 直连 IT6000C(出厂默认 Socket Port 30000)
|
||||
// - 100ms 周期功率控制:订阅 MOOS 功率消息(实时电机功率,W),
|
||||
// 按功率/电压折算电流指令(CC 优先模式,负电流=吸收电能=负载模拟)
|
||||
// - 网页人机交互(默认 18082 端口)
|
||||
// - sqlite3 数据存储(eload_log 运行快照 + scpi_log 指令记录)
|
||||
//
|
||||
// 设备说明:docs/电子负载说明手册
|
||||
|
||||
ServerHost = localhost
|
||||
ServerPort = 9000
|
||||
Community = h100
|
||||
|
||||
ProcessConfig = pELoad
|
||||
{
|
||||
AppTick = 10
|
||||
CommsTick = 4
|
||||
|
||||
//======== 电子负载以太网地址 ========
|
||||
// 仪器 LAN 配置的 IP 与 Raw Socket 端口号
|
||||
// (前面板 [Shift]+[P-set] -> System -> I/O -> LAN 查看配置)
|
||||
eload_ip = 192.168.0.10
|
||||
eload_port = 30000
|
||||
|
||||
//======== 功率控制 ========
|
||||
// MOOS 功率消息变量名(其他程序发布的实时电机功率,单位 W,
|
||||
// 正值=电机消耗功率/负载吸收,负值=电机回馈功率/负载释放)
|
||||
power_var = MOTOR_POWER
|
||||
|
||||
// 功率控制周期 ms(需求:100ms)
|
||||
ctrl_period_ms = 100
|
||||
|
||||
// 功率消息超时秒数:超时后目标功率归零(安全),0=不判超时
|
||||
power_stale_sec = 3
|
||||
|
||||
// 电流符号约定:negative=负电流吸收(负载模拟,默认);
|
||||
// positive=正电流输出(电源模拟)
|
||||
current_sign = negative
|
||||
|
||||
// 无电压测量反馈时用于功率->电流折算的电压(V),
|
||||
// 运行后自动改用实测电压(MEAS:VOLT?)
|
||||
nominal_volt = 500
|
||||
|
||||
// 主机侧安全限幅(0=不限;设备侧 POW:LIM/CURR:LIM 为第一道防线)
|
||||
max_power = 0
|
||||
max_current = 0
|
||||
slew_limit_wps = 0
|
||||
|
||||
// 使能时下发的设备限值(0=不下发该项)
|
||||
volt_limit = 0
|
||||
curr_limit = 0
|
||||
curr_limit_neg= 0
|
||||
pow_limit = 0
|
||||
pow_limit_neg = 0
|
||||
|
||||
// 工作模式优先级:cc=CC 优先(负载/电流型),cv=CV 优先
|
||||
func_priority = cc
|
||||
|
||||
// CV 优先时的目标电压(V),func_priority=cv 时生效(恒压吸收)。
|
||||
// CC 优先下此项仅作为 VOLT 折算参考,不直接下发。
|
||||
volt_set = 0
|
||||
|
||||
//======== 数据存储 ========
|
||||
dbpath = pELoad.db
|
||||
|
||||
// 历史数据保留时长(小时,0=永久保留;对 eload_log/scpi_log/meas_log 统一生效)
|
||||
log_keep_hours = 72
|
||||
|
||||
// 高采样测量落库周期 ms(默认=控制周期 100ms,实测 V/I/P 全量记录供分析;
|
||||
// 0=关闭高频记录,仅保留 1Hz 状态快照)
|
||||
meas_log_period_ms = 100
|
||||
|
||||
// 测量命令来源:fetch=FETCh(读设备表头缓存,不触发测量,响应快,推荐);
|
||||
// meas=MEAS(每次触发设备重新测量,响应慢)
|
||||
meas_source = fetch
|
||||
|
||||
//======== 网页 ========
|
||||
// (避开 pCCU 8080 / pPowerManger 8090 / pPowerMangerHost 18080 / pMotor 18081)
|
||||
web_port = 18082
|
||||
web_enable = true
|
||||
|
||||
logpath = pELoad.log
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "DbStore.h"
|
||||
#include "sqlite3.h"
|
||||
#include <ctime>
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace eload {
|
||||
|
||||
namespace {
|
||||
// 小数秒精度的 unix 时间(与 eload_log 的整秒对齐,供跨表分析)
|
||||
double nowUnix() {
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
return static_cast<double>(tv.tv_sec) + static_cast<double>(tv.tv_usec) / 1e6;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
DbStore::DbStore(const std::string& dbPath) : m_dbPath(dbPath) {}
|
||||
|
||||
DbStore::~DbStore() {
|
||||
close();
|
||||
}
|
||||
|
||||
bool DbStore::exec(const char* sql) {
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql, nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
m_lastError = err ? err : "sqlite error";
|
||||
sqlite3_free(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::open() {
|
||||
if (m_db) return true;
|
||||
if (sqlite3_open(m_dbPath.c_str(), &m_db) != SQLITE_OK) {
|
||||
m_lastError = m_db ? sqlite3_errmsg(m_db) : "cannot open db";
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
sqlite3_busy_timeout(m_db, 2000);
|
||||
exec("PRAGMA journal_mode=WAL;");
|
||||
exec("PRAGMA synchronous=NORMAL;");
|
||||
|
||||
// eload_log:运行状态周期快照
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS eload_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time INTEGER NOT NULL,"
|
||||
" connected INTEGER NOT NULL,"
|
||||
" outp INTEGER NOT NULL,"
|
||||
" mode TEXT NOT NULL,"
|
||||
" target_power REAL,"
|
||||
" volt_set REAL,"
|
||||
" cmd_current REAL,"
|
||||
" cmd_volt REAL,"
|
||||
" meas_voltage REAL,"
|
||||
" meas_current REAL,"
|
||||
" meas_power REAL,"
|
||||
" func_mode TEXT,"
|
||||
" tx_count INTEGER,"
|
||||
" rx_count INTEGER,"
|
||||
" tx_err INTEGER"
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_eload_log_time ON eload_log(time);");
|
||||
|
||||
// scpi_log:非常规 SCPI 收发记录(例行 MEAS 轮询不落库)
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS scpi_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time INTEGER NOT NULL,"
|
||||
" direction INTEGER NOT NULL," // 0=收 1=发
|
||||
" text TEXT NOT NULL"
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_scpi_log_time ON scpi_log(time);");
|
||||
|
||||
// meas_log:高采样测量记录(默认每控制周期 100ms 一条,供波形分析)
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS meas_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time REAL NOT NULL,"
|
||||
" meas_voltage REAL,"
|
||||
" meas_current REAL,"
|
||||
" meas_power REAL,"
|
||||
" cmd_current REAL,"
|
||||
" cmd_volt REAL,"
|
||||
" target_power REAL,"
|
||||
" outp INTEGER"
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_meas_log_time ON meas_log(time);");
|
||||
|
||||
// 旧库缺列自动迁移(协议/字段演进后旧数据库可自动补齐)
|
||||
ensureColumn("eload_log", "volt_set", "REAL");
|
||||
ensureColumn("eload_log", "cmd_volt", "REAL");
|
||||
ensureColumn("meas_log", "cmd_volt", "REAL");
|
||||
|
||||
return prepareInsert() && prepareMeasInsert() && prepareScpiInsert();
|
||||
}
|
||||
|
||||
// 检查列是否存在,缺失则 ALTER TABLE ADD COLUMN(旧库迁移)
|
||||
bool DbStore::ensureColumn(const std::string& table, const std::string& col, const std::string& type) {
|
||||
if (!m_db) return false;
|
||||
std::string sql = "SELECT COUNT(*) FROM pragma_table_info('" + table + "') WHERE name='" + col + "';";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
int n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
if (n > 0) return true; // 列已存在
|
||||
|
||||
std::string alt = "ALTER TABLE " + table + " ADD COLUMN " + col + " " + type + ";";
|
||||
return exec(alt.c_str());
|
||||
}
|
||||
|
||||
bool DbStore::prepareInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO eload_log (time, connected, outp, mode, target_power, volt_set, "
|
||||
"cmd_current, cmd_volt, meas_voltage, meas_current, meas_power, func_mode, "
|
||||
"tx_count, rx_count, tx_err) "
|
||||
"VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::prepareMeasInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO meas_log (time, meas_voltage, meas_current, meas_power, "
|
||||
"cmd_current, cmd_volt, target_power, outp) VALUES (?1,?2,?3,?4,?5,?6,?7,?8);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtMeasInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::prepareScpiInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO scpi_log (time, direction, text) VALUES (?1,?2,?3);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtScpiInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DbStore::close() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_stmtInsert) {
|
||||
sqlite3_finalize(m_stmtInsert);
|
||||
m_stmtInsert = nullptr;
|
||||
}
|
||||
if (m_stmtMeasInsert) {
|
||||
sqlite3_finalize(m_stmtMeasInsert);
|
||||
m_stmtMeasInsert = nullptr;
|
||||
}
|
||||
if (m_stmtScpiInsert) {
|
||||
sqlite3_finalize(m_stmtScpiInsert);
|
||||
m_stmtScpiInsert = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertStatus(bool connected, bool outp, const std::string& mode,
|
||||
double targetPower, double voltSet,
|
||||
double cmdCurrent, double cmdVolt,
|
||||
double measV, double measI, double measP,
|
||||
const std::string& funcMode,
|
||||
unsigned long tx, unsigned long rx, unsigned long txErr) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtInsert);
|
||||
sqlite3_clear_bindings(m_stmtInsert);
|
||||
int i = 1;
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(::time(nullptr)));
|
||||
sqlite3_bind_int(m_stmtInsert, i++, connected ? 1 : 0);
|
||||
sqlite3_bind_int(m_stmtInsert, i++, outp ? 1 : 0);
|
||||
sqlite3_bind_text(m_stmtInsert, i++, mode.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, targetPower);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, voltSet);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, cmdCurrent);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, cmdVolt);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, measV);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, measI);
|
||||
sqlite3_bind_double(m_stmtInsert, i++, measP);
|
||||
sqlite3_bind_text(m_stmtInsert, i++, funcMode.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(tx));
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(rx));
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(txErr));
|
||||
|
||||
int rc = sqlite3_step(m_stmtInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertMeas(double measV, double measI, double measP,
|
||||
double cmdCurrent, double cmdVolt, double targetPower, bool outp) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtMeasInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtMeasInsert);
|
||||
sqlite3_clear_bindings(m_stmtMeasInsert);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 1, nowUnix());
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 2, measV);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 3, measI);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 4, measP);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 5, cmdCurrent);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 6, cmdVolt);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 7, targetPower);
|
||||
sqlite3_bind_int(m_stmtMeasInsert, 8, outp ? 1 : 0);
|
||||
|
||||
int rc = sqlite3_step(m_stmtMeasInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertScpi(int direction, const std::string& text) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtScpiInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtScpiInsert);
|
||||
sqlite3_clear_bindings(m_stmtScpiInsert);
|
||||
sqlite3_bind_int64(m_stmtScpiInsert, 1, static_cast<sqlite3_int64>(::time(nullptr)));
|
||||
sqlite3_bind_int(m_stmtScpiInsert, 2, direction);
|
||||
sqlite3_bind_text(m_stmtScpiInsert, 3, text.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(m_stmtScpiInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ELoadLogRow> DbStore::queryRecentStatus(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<ELoadLogRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 100;
|
||||
if (limit > 1000) limit = 1000;
|
||||
|
||||
const char* sql =
|
||||
"SELECT id, time, connected, outp, mode, target_power, volt_set, "
|
||||
"cmd_current, cmd_volt, meas_voltage, meas_current, meas_power, func_mode, "
|
||||
"tx_count, rx_count, tx_err "
|
||||
"FROM eload_log ORDER BY id DESC LIMIT ";
|
||||
std::string q = sql + std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ELoadLogRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_int64(stmt, 1);
|
||||
r.connected = sqlite3_column_int(stmt, 2);
|
||||
r.outp = sqlite3_column_int(stmt, 3);
|
||||
r.mode = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
|
||||
r.targetPower = sqlite3_column_double(stmt, 5);
|
||||
r.voltSet = sqlite3_column_double(stmt, 6);
|
||||
r.cmdCurrent = sqlite3_column_double(stmt, 7);
|
||||
r.cmdVolt = sqlite3_column_double(stmt, 8);
|
||||
r.measVoltage = sqlite3_column_double(stmt, 9);
|
||||
r.measCurrent = sqlite3_column_double(stmt, 10);
|
||||
r.measPower = sqlite3_column_double(stmt, 11);
|
||||
const char* fm = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 12));
|
||||
r.funcMode = fm ? fm : "";
|
||||
r.txCount = sqlite3_column_int64(stmt, 13);
|
||||
r.rxCount = sqlite3_column_int64(stmt, 14);
|
||||
r.txErr = sqlite3_column_int64(stmt, 15);
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::vector<DbStore::ScpiRow> DbStore::queryRecentScpi(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<ScpiRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
|
||||
std::string q = "SELECT id, time, direction, text FROM scpi_log ORDER BY id DESC LIMIT "
|
||||
+ std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ScpiRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_int64(stmt, 1);
|
||||
r.direction = sqlite3_column_int(stmt, 2);
|
||||
const char* t = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
r.text = t ? t : "";
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
long long DbStore::countStatus() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM eload_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
long long DbStore::countScpi() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM scpi_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
long long DbStore::countMeas() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM meas_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
std::vector<MeasRow> DbStore::queryMeas(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<MeasRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 200;
|
||||
if (limit > 5000) limit = 5000;
|
||||
|
||||
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
|
||||
"cmd_current, cmd_volt, target_power, outp FROM meas_log "
|
||||
"ORDER BY id DESC LIMIT " + std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
MeasRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_double(stmt, 1);
|
||||
r.measVoltage = sqlite3_column_double(stmt, 2);
|
||||
r.measCurrent = sqlite3_column_double(stmt, 3);
|
||||
r.measPower = sqlite3_column_double(stmt, 4);
|
||||
r.cmdCurrent = sqlite3_column_double(stmt, 5);
|
||||
r.cmdVolt = sqlite3_column_double(stmt, 6);
|
||||
r.targetPower = sqlite3_column_double(stmt, 7);
|
||||
r.outp = sqlite3_column_int(stmt, 8);
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::vector<MeasRow> DbStore::queryMeasRange(double startSec, double endSec, int maxRows) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<MeasRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (maxRows <= 0) maxRows = 5000;
|
||||
if (maxRows > 20000) maxRows = 20000;
|
||||
if (endSec <= startSec) return rows;
|
||||
|
||||
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
|
||||
"cmd_current, cmd_volt, target_power, outp FROM meas_log "
|
||||
"WHERE time >= " + std::to_string(startSec) +
|
||||
" AND time <= " + std::to_string(endSec) +
|
||||
" ORDER BY id LIMIT " + std::to_string(maxRows) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
MeasRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_double(stmt, 1);
|
||||
r.measVoltage = sqlite3_column_double(stmt, 2);
|
||||
r.measCurrent = sqlite3_column_double(stmt, 3);
|
||||
r.measPower = sqlite3_column_double(stmt, 4);
|
||||
r.cmdCurrent = sqlite3_column_double(stmt, 5);
|
||||
r.cmdVolt = sqlite3_column_double(stmt, 6);
|
||||
r.targetPower = sqlite3_column_double(stmt, 7);
|
||||
r.outp = sqlite3_column_int(stmt, 8);
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
long long DbStore::pruneOlderThan(long long cutoff) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return -1;
|
||||
std::string sql = "DELETE FROM eload_log WHERE time < " + std::to_string(cutoff) +
|
||||
"; DELETE FROM scpi_log WHERE time < " + std::to_string(cutoff) +
|
||||
"; DELETE FROM meas_log WHERE time < " + std::to_string(cutoff) + ";";
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
m_lastError = err ? err : "sqlite error";
|
||||
sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return sqlite3_changes(m_db);
|
||||
}
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,120 @@
|
||||
#ifndef PELOAD_DB_STORE_H
|
||||
#define PELOAD_DB_STORE_H
|
||||
|
||||
#include <string>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// DbStore:pELoad SQLite 存储(复用仓库内 sqlite3.c)。
|
||||
//
|
||||
// eload_log:运行状态低频快照(1Hz),含连接/输出/模式/限值等状态;
|
||||
// meas_log :高采样测量记录(默认每控制周期 100ms 一条),
|
||||
// 实测电压/电流/功率 + 指令电流/目标功率 —— 供功率波形分析;
|
||||
// scpi_log :非常规 SCPI 收发记录(设定/控制/识别/错误查询等,
|
||||
// 例行的 MEAS 测量轮询不落库,防止表无限膨胀)。
|
||||
// log_keep_hours 对三张表统一做历史清理。
|
||||
//============================================================================
|
||||
|
||||
struct ELoadLogRow {
|
||||
long long id;
|
||||
long long time;
|
||||
int connected;
|
||||
int outp;
|
||||
std::string mode; // follow / manual / current / volt
|
||||
double targetPower; // 目标功率 W(volt 模式为 0)
|
||||
double voltSet; // 目标电压 V(volt 模式)
|
||||
double cmdCurrent; // 指令电流 A
|
||||
double cmdVolt; // 指令电压 V(volt 模式)
|
||||
double measVoltage; // 实测电压 V
|
||||
double measCurrent; // 实测电流 A
|
||||
double measPower; // 实测功率 W
|
||||
std::string funcMode; // CURR / VOLT
|
||||
long long txCount;
|
||||
long long rxCount;
|
||||
long long txErr;
|
||||
};
|
||||
|
||||
// 高采样测量记录(meas_log)
|
||||
struct MeasRow {
|
||||
long long id;
|
||||
double time; // unix 时间(秒,带小数)
|
||||
double measVoltage;
|
||||
double measCurrent;
|
||||
double measPower;
|
||||
double cmdCurrent;
|
||||
double cmdVolt;
|
||||
double targetPower;
|
||||
int outp;
|
||||
};
|
||||
|
||||
class DbStore {
|
||||
public:
|
||||
explicit DbStore(const std::string& dbPath);
|
||||
~DbStore();
|
||||
|
||||
bool open();
|
||||
void close();
|
||||
bool isOpen() const { return m_db != nullptr; }
|
||||
std::string lastError() const { return m_lastError; }
|
||||
|
||||
// 运行状态快照落库(控制线程周期调用)
|
||||
void insertStatus(bool connected, bool outp, const std::string& mode,
|
||||
double targetPower, double voltSet,
|
||||
double cmdCurrent, double cmdVolt,
|
||||
double measV, double measI, double measP,
|
||||
const std::string& funcMode,
|
||||
unsigned long tx, unsigned long rx, unsigned long txErr);
|
||||
|
||||
// 高采样测量落库(每个控制周期调用,meas_log)
|
||||
void insertMeas(double measV, double measI, double measP,
|
||||
double cmdCurrent, double cmdVolt, double targetPower, bool outp);
|
||||
|
||||
// SCPI 非常规收发落库:text 为 "CMD" 或 "CMD => RESP"(已配对)
|
||||
// direction: 1=命令 0=查询应答
|
||||
void insertScpi(int direction, const std::string& text);
|
||||
|
||||
// 历史查询(网页展示)
|
||||
std::vector<ELoadLogRow> queryRecentStatus(int limit);
|
||||
// 测量查询:limit 条最近记录(倒序);或指定时间范围(unix 秒)
|
||||
std::vector<MeasRow> queryMeas(int limit);
|
||||
std::vector<MeasRow> queryMeasRange(double startSec, double endSec, int maxRows);
|
||||
struct ScpiRow {
|
||||
long long id;
|
||||
long long time;
|
||||
int direction;
|
||||
std::string text;
|
||||
};
|
||||
std::vector<ScpiRow> queryRecentScpi(int limit);
|
||||
|
||||
long long countStatus() const;
|
||||
long long countScpi() const;
|
||||
long long countMeas() const;
|
||||
|
||||
// 历史清理:删除 time < cutoff 的记录,返回删除行数(<0 表示出错)
|
||||
long long pruneOlderThan(long long cutoff);
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
bool ensureColumn(const std::string& table, const std::string& col, const std::string& type);
|
||||
bool prepareInsert();
|
||||
bool prepareMeasInsert();
|
||||
bool prepareScpiInsert();
|
||||
|
||||
std::string m_dbPath;
|
||||
sqlite3* m_db = nullptr;
|
||||
sqlite3_stmt* m_stmtInsert = nullptr;
|
||||
sqlite3_stmt* m_stmtMeasInsert = nullptr;
|
||||
sqlite3_stmt* m_stmtScpiInsert = nullptr;
|
||||
mutable std::mutex m_mutex;
|
||||
std::string m_lastError;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
|
||||
#endif // PELOAD_DB_STORE_H
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "WebServer.h"
|
||||
#include "pages/index.h"
|
||||
#include "pages/trend.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//---------------------------------------------------------
|
||||
// 事件处理: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/* 接口:交给宿主程序处理,返回 (Content-Type, body)
|
||||
std::string path(hm->uri.buf, hm->uri.len);
|
||||
if (path.rfind("/api/", 0) == 0) {
|
||||
std::string body, contentType = "application/json";
|
||||
if (m_apiHandler) {
|
||||
std::string query(hm->query.buf, hm->query.len);
|
||||
std::pair<std::string, std::string> r = m_apiHandler(path, query);
|
||||
body = r.second;
|
||||
if (!r.first.empty()) contentType = r.first;
|
||||
}
|
||||
if (!body.empty()) {
|
||||
std::string headers = "Content-Type: " + contentType + "\r\n";
|
||||
mg_http_reply(c, 200, headers.c_str(),
|
||||
"%.*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;
|
||||
}
|
||||
|
||||
// 实时曲线页(独立标签页)
|
||||
if (path == "/trend" || path == "/trend.html") {
|
||||
mg_http_reply(c, 200, "Content-Type: text/html; charset=utf-8\r\n",
|
||||
"%.*s", (int)TREND_HTML.size(), TREND_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<std::string()> handler) {
|
||||
m_onOpen = std::move(handler);
|
||||
}
|
||||
|
||||
void WebServer::setApiHandler(std::function<std::pair<std::string, std::string>(const std::string&, const std::string&)> 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<WebServer*>(c->fn_data);
|
||||
if (server) server->handleEvent(c, ev, ev_data);
|
||||
}, this);
|
||||
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() { serverThreadFunc(); });
|
||||
|
||||
std::cout << "pELoad 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<WebServer*>(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<std::mutex> 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<std::mutex> lock(m_pendingMutex);
|
||||
m_pending = text;
|
||||
}
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,55 @@
|
||||
#ifndef PELOAD_WEB_SERVER_H
|
||||
#define PELOAD_WEB_SERVER_H
|
||||
|
||||
#define UNIX
|
||||
#include "mongoose.h"
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
|
||||
namespace eload {
|
||||
|
||||
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<std::string()> handler);
|
||||
|
||||
// 处理 /api/* 请求:返回 (Content-Type, body)。
|
||||
// 默认内容类型 application/json;文本/CSV 接口由处理器自行指定。
|
||||
void setApiHandler(std::function<std::pair<std::string, std::string>(const std::string& uri, const std::string& query)> handler);
|
||||
|
||||
private:
|
||||
static bool serverThreadCB(void* pParam);
|
||||
bool serverThreadFunc();
|
||||
void handleEvent(struct mg_connection* c, int ev, void* ev_data);
|
||||
void flushPending();
|
||||
|
||||
struct mg_mgr m_mgr;
|
||||
int m_port = 0;
|
||||
std::atomic<bool> m_running{false};
|
||||
std::thread m_thread;
|
||||
|
||||
std::mutex m_pendingMutex;
|
||||
std::string m_pending; // latest snapshot to broadcast
|
||||
std::vector<mg_connection*> m_wsConnections; // web thread only
|
||||
std::function<std::string()> m_onOpen;
|
||||
std::function<std::pair<std::string, std::string>(const std::string&, const std::string&)> m_apiHandler;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_WEB_SERVER_H
|
||||
@@ -0,0 +1,605 @@
|
||||
#ifndef PELOAD_PAGE_INDEX_H
|
||||
#define PELOAD_PAGE_INDEX_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pELoad 电子负载控制页面(纯前端,内嵌 HTML)。
|
||||
//
|
||||
// 操作顺序(防误触 + 步骤引导):
|
||||
// ① 连接设备(链路绿 → 步骤2 可操作)
|
||||
// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值)
|
||||
// ③ 开启输出(确认后下发使能序列)
|
||||
// 所有下发的指令均带确认对话框;按钮按当前状态禁用。
|
||||
//
|
||||
// 关键设计:页面为静态骨架(输入框/按钮是稳定 DOM,永不重建),
|
||||
// render() 只按 id 更新动态数值/状态/按钮禁用 —— 输入框聚焦编辑时
|
||||
// 不受 1Hz 快照刷新影响,光标稳定、输入流畅(setInput 聚焦时不覆盖)。
|
||||
//
|
||||
// 模式说明:
|
||||
// CC 优先(电流型负载模拟):控制量 = 电流,功率折算 I=P/U,可跟随 MOOS
|
||||
// 功率 / 手动功率 / 手动电流;
|
||||
// CV 优先(恒压吸收):控制量 = 电压(VOLT),功率由外部源决定。
|
||||
// FUNC:MODE 必须保持 FIX(实时功率控制前提),非 FIX 时页面红色告警。
|
||||
//
|
||||
// 设备说明:docs/电子负载说明手册(IT6000C 双向可编程直流电源)
|
||||
//============================================================================
|
||||
|
||||
namespace eload {
|
||||
const std::string INDEX_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pELoad 电子负载控制</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--ok:#2ea043;--warn:#d29922;--bad:#f85149;--blue:#1f6feb;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:16px;padding-bottom:60px;}
|
||||
h1{font-size:18px;margin-bottom:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
h2{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;border-bottom:1px solid var(--line);padding-bottom:6px;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);white-space:nowrap;}
|
||||
.badge.ok{color:var(--ok);border-color:var(--ok);}
|
||||
.badge.warn{color:var(--warn);border-color:var(--warn);}
|
||||
.badge.err{color:var(--bad);border-color:var(--bad);}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;}
|
||||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed #222c3d;gap:12px;}
|
||||
.row:last-child{border-bottom:none;}
|
||||
.row .k{color:var(--dim);flex-shrink:0;}
|
||||
.row .v{font-family:Consolas,monospace;color:var(--fg);text-align:right;}
|
||||
.sub{font-size:11px;color:#79c0ff;margin:10px 0 2px;border-bottom:1px solid #22304a;padding-bottom:2px;}
|
||||
.btn{padding:6px 14px;margin:0 8px 8px 0;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||||
.btn:hover:not(:disabled){border-color:#3b4a63;}
|
||||
.btn:disabled{opacity:.35;cursor:not-allowed;}
|
||||
.btn.on{background:#1f6feb;border-color:#1f6feb;color:#fff;font-weight:600;}
|
||||
.btn.off{background:#3d2226;border-color:#f85149;color:#f85149;}
|
||||
.btn.warn{background:#3a2e12;border-color:var(--warn);color:var(--warn);}
|
||||
.btn.big{padding:10px 22px;font-size:14px;}
|
||||
.btn.sel{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600;}
|
||||
input[type=number],input[type=text]{width:120px;background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);font-family:Consolas,monospace;padding:5px 8px;font-size:13px;}
|
||||
input:focus{outline:none;border-color:var(--blue);}
|
||||
input:disabled{opacity:.4;}
|
||||
.inline{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.inline label{color:var(--dim);}
|
||||
.dim{color:var(--dim);}
|
||||
.errline{color:var(--bad);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||||
.okline{color:var(--ok);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||||
table{width:100%;border-collapse:collapse;font-size:11px;font-family:Consolas,monospace;}
|
||||
th,td{padding:3px 6px;border-bottom:1px solid #222c3d;text-align:right;white-space:nowrap;}
|
||||
th{color:var(--dim);font-weight:400;}
|
||||
td.l,th.l{text-align:left;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
/* 顶部状态条 */
|
||||
#topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:12px;}
|
||||
#topbar .sep{width:1px;height:20px;background:var(--line);margin:0 6px;}
|
||||
/* 操作顺序步骤条 */
|
||||
.steps{display:flex;align-items:center;gap:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.step{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;border:1px solid var(--line);color:var(--dim);font-size:12px;}
|
||||
.step .n{width:18px;height:18px;border-radius:50%;background:#2a3447;color:var(--fg);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||||
.step.done{border-color:var(--ok);color:var(--ok);}
|
||||
.step.done .n{background:var(--ok);color:#fff;}
|
||||
.step.active{border-color:var(--blue);color:#fff;font-weight:600;}
|
||||
.step.active .n{background:var(--blue);color:#fff;}
|
||||
.step.err{border-color:var(--bad);color:var(--bad);}
|
||||
.step.err .n{background:var(--bad);color:#fff;}
|
||||
.sarrow{color:var(--dim);}
|
||||
.tip{font-size:11px;color:var(--dim);line-height:1.5;}
|
||||
.tip b{color:#79c0ff;}
|
||||
.bigstat{font-size:24px;font-weight:700;font-family:Consolas,monospace;text-align:center;padding:10px 0;}
|
||||
.bigstat.on{color:var(--ok);}
|
||||
.bigstat.off{color:var(--dim);}
|
||||
.bigstat.wait{color:var(--warn);}
|
||||
.meas{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center;margin-bottom:8px;}
|
||||
.meas .v{font-size:24px;font-family:Consolas,monospace;}
|
||||
.meas .k{color:var(--dim);font-size:11px;}
|
||||
.fixwarn{background:rgba(248,81,73,.15);border:1px solid var(--bad);color:var(--bad);border-radius:6px;padding:6px 10px;font-size:12px;margin-bottom:8px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="topbar">
|
||||
<h1 style="margin:0">pELoad 电子负载控制</h1>
|
||||
<span id="status" class="badge">加载中...</span>
|
||||
<span class="sep"></span>
|
||||
<span id="badgeLink" class="badge err">未连接</span>
|
||||
<span id="badgeOutp" class="badge err">输出关</span>
|
||||
<span id="badgeMode" class="badge">模式 -</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/trend" target="_blank">📈 实时曲线</a>
|
||||
</div>
|
||||
|
||||
<div class="steps" id="steps">
|
||||
<div class="step" id="step1"><span class="n">1</span>连接设备</div>
|
||||
<span class="sarrow">▸</span>
|
||||
<div class="step" id="step2"><span class="n">2</span>设置参数</div>
|
||||
<span class="sarrow">▸</span>
|
||||
<div class="step" id="step3"><span class="n">3</span>开启输出</div>
|
||||
<span style="flex:1"></span>
|
||||
<span class="tip" id="stepTip"></span>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="pages">
|
||||
|
||||
<!--======== 卡1:以太网链路 ========-->
|
||||
<div class="card">
|
||||
<h2>① 连接设备 · 以太网链路</h2>
|
||||
<div class="row"><span class="k">设备地址</span><span class="v" id="linkAddr">-</span></div>
|
||||
<div class="row"><span class="k">连接状态</span><span class="v" id="linkConn">未连接</span></div>
|
||||
<div class="row"><span class="k">自动重连</span><span class="v" id="linkAuto">-</span></div>
|
||||
<div class="row"><span class="k">重连次数</span><span class="v" id="linkReconn">-</span></div>
|
||||
<div class="row"><span class="k">收发统计</span><span class="v" id="linkStats">-</span></div>
|
||||
<div class="row"><span class="k">最近收帧</span><span class="v" id="linkLastRx">-</span></div>
|
||||
<div class="row"><span class="k">设备识别 *IDN?</span><span class="v" id="linkIdn">未识别</span></div>
|
||||
<div class="errline" id="linkError" style="display:none"></div>
|
||||
<div class="sub">操作(指令均需确认)</div>
|
||||
<button id="btnConnect" class="btn on" onclick="sendCmd('connect',null,'确认连接电子负载?')">连接</button>
|
||||
<button id="btnDisconnect" class="btn off" onclick="sendCmd('disconnect',null,'确认断开以太网链路?\n断开前会先关闭输出。')">断开</button>
|
||||
<button id="btnIdentify" class="btn" onclick="sendCmd('identify')">识别 (*IDN?)</button>
|
||||
<button id="btnClearProt" class="btn warn" onclick="sendCmd('clear_protect',null,'确认清除输出保护锁定?')">清保护</button>
|
||||
<button id="btnReset" class="btn off" onclick="sendCmd('reset_dev',null,'确认复位设备 (*RST)?\n所有设定将恢复默认!')">复位设备</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡2:当前状态 + 实时测量 ========-->
|
||||
<div class="card">
|
||||
<h2>当前状态 · 实时测量</h2>
|
||||
<div class="bigstat off" id="bigOutp">输出关</div>
|
||||
<div class="fixwarn" id="fixWarn" style="display:none"></div>
|
||||
<div class="meas">
|
||||
<div><div class="v" style="color:#79c0ff" id="measV">-</div><div class="k">电压 V</div></div>
|
||||
<div><div class="v" style="color:#3fb950" id="measI">-</div><div class="k">电流 A</div></div>
|
||||
<div><div class="v" style="color:#d29922" id="measP">-</div><div class="k">功率 W</div></div>
|
||||
</div>
|
||||
<div class="row"><span class="k">数据时效</span><span class="v" id="measAge">-</span></div>
|
||||
<div class="row"><span class="k">测量查询</span><span class="v" id="measDiag">-</span></div>
|
||||
<div class="errline" id="measDiagErr" style="display:none"></div>
|
||||
<div class="row"><span class="k">环路优先(回读)</span><span class="v" id="devFunc">-</span></div>
|
||||
<div class="row"><span class="k">功能模式(回读)</span><span class="v" id="devFuncMode">-</span></div>
|
||||
<div class="row"><span class="k">设备配置同步</span><span class="v" id="cfgSync">-</span></div>
|
||||
<div class="row"><span class="k">切换结果</span><span class="v" id="funcModeFeed">-</span></div>
|
||||
<div class="sub">功能模式切换(FUNC:MODE)</div>
|
||||
<div class="inline">
|
||||
<select id="funcModeSel">
|
||||
<option value="FIX">FIX 固定输出(实时控制)</option>
|
||||
<option value="LIST">LIST 列表波形</option>
|
||||
<option value="BATT">BATT 电池测试</option>
|
||||
<option value="SOLAR">SOLAR 光伏曲线</option>
|
||||
<option value="CAR">CAR 汽车波形</option>
|
||||
</select>
|
||||
<button id="btnFuncMode" class="btn" onclick="switchFuncMode()">切换功能模式</button>
|
||||
</div>
|
||||
<div class="tip" id="funcModeTip">实时功率控制必须保持 <b>FIX</b>。切换到 LIST/BATT/SOLAR/CAR 后,输出将按该模式自主运行,脱离本程序的实时功率控制(页面会红色告警),需切回 FIX 恢复。</div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡3:参数与输出控制 ========-->
|
||||
<div class="card">
|
||||
<h2>②③ 参数与输出控制</h2>
|
||||
<div class="sub">环路优先模式(决定控制量类型)</div>
|
||||
<button id="btnModeCC" class="btn sel" onclick="sendCmd('mode_cc',null,'确认切换为 CC 优先(电流型负载,功率折算 I=P/U)?')">CC 优先</button>
|
||||
<button id="btnModeCV" class="btn sel" onclick="sendCmd('mode_cv',null,'确认切换为 CV 优先(恒压吸收,按电压控制)?')">CV 优先</button>
|
||||
<div class="tip" id="ctrlTip"></div>
|
||||
|
||||
<div id="ccArea">
|
||||
<div class="sub">控制模式(目标来源)</div>
|
||||
<button id="btnModeFollow" class="btn" onclick="sendCmd('mode_follow',null,'确认切换为跟随 MOOS 功率消息(实时电机功率)?')">跟随MOOS</button>
|
||||
<button id="btnModeManual" class="btn" onclick="sendCmd('mode_manual',null,'确认切换为手动功率?')">手动功率</button>
|
||||
<button id="btnModeCurrent" class="btn" onclick="sendCmd('mode_current',null,'确认切换为手动电流?')">手动电流</button>
|
||||
<div class="tip" id="curModeTip"></div>
|
||||
<div class="inline"><label>功率 (W)</label>
|
||||
<input type="number" id="powerInput" step="1" value="0">
|
||||
<button id="btnSetPower" class="btn" onclick="sendFromInput('set_power','powerInput','手动目标功率','W')">设定功率</button></div>
|
||||
<div class="inline"><label>电流 (A, 负=吸收)</label>
|
||||
<input type="number" id="currInput" step="0.1" value="0">
|
||||
<button id="btnSetCurr" class="btn" onclick="sendFromInput('set_current','currInput','手动电流','A')">设定电流</button></div>
|
||||
<div class="row"><span class="k">MOOS 功率消息</span><span class="v" id="moosPower">-</span></div>
|
||||
<div class="row"><span class="k">指令电流 CURR</span><span class="v" id="cmdCurrent">-</span></div>
|
||||
<div class="row"><span class="k">符号约定</span><span class="v" id="signTip">-</span></div>
|
||||
</div>
|
||||
|
||||
<div id="cvArea" style="display:none">
|
||||
<div class="inline"><label>目标电压 (V)</label>
|
||||
<input type="number" id="voltInput" step="1" value="0">
|
||||
<button id="btnSetVolt" class="btn" onclick="sendFromInput('set_volt','voltInput','CV 目标电压','V')">设定电压</button></div>
|
||||
<div class="row"><span class="k">指令电压 VOLT</span><span class="v" id="cmdVolt">-</span></div>
|
||||
<div class="tip">恒压吸收:设备维持该电压,吸收电流由外部源决定;功率大小请以实测为准。</div>
|
||||
</div>
|
||||
|
||||
<div class="sub">输出控制</div>
|
||||
<button id="btnOutpOn" class="btn big on" onclick="sendCmd('outp_on',null,'确认开启输出?\n电子负载将按当前设定开始吸收电能!')">▶ 开启输出</button>
|
||||
<button id="btnOutpOff" class="btn big off" onclick="sendCmd('outp_off',null,'确认关闭输出?\n电子负载停止吸收电能。')">■ 关闭输出</button>
|
||||
<div class="tip" id="outpTip"></div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡4:设备参数与限值 ========-->
|
||||
<div class="card">
|
||||
<h2>设备参数(限值下发)</h2>
|
||||
<div class="tip">限值在输出开启时统一下发,修改后需“关闭输出→重新开启”生效(或在运行中直接下发,设备实时响应)。</div>
|
||||
<div class="inline"><label>电压限值 VOLT:LIM (V)</label>
|
||||
<input type="number" id="voltlimInput" step="1" value="0">
|
||||
<button id="btnSetVoltlim" class="btn" onclick="sendFromInput('set_voltlim','voltlimInput','电压限值','V')">下发</button></div>
|
||||
<div class="inline"><label>电流限值 CURR:LIM (A)</label>
|
||||
<input type="number" id="currlimInput" step="0.1" value="0">
|
||||
<button id="btnSetCurrlim" class="btn" onclick="sendFromInput('set_currlimit','currlimInput','电流限值','A')">下发</button></div>
|
||||
<div class="inline"><label>功率限值 POW:LIM (W)</label>
|
||||
<input type="number" id="powlimInput" step="10" value="0">
|
||||
<button id="btnSetPowlim" class="btn" onclick="sendFromInput('set_powlimit','powlimInput','功率限值','W')">下发</button></div>
|
||||
<div class="sub">远控 / 本控</div>
|
||||
<div class="row"><span class="k">当前控制端</span><span class="v" id="remoteState">-</span></div>
|
||||
<div class="row"><span class="k">最近指令结果</span><span class="v" id="remoteFeed">-</span></div>
|
||||
<div class="tip">IT6000C 无远控状态查询命令,此状态按最近一次远控/本控指令的发送结果推断;若面板被手动按回本控,需重新发送“远控”。</div>
|
||||
<button id="btnRemote" class="btn" onclick="sendCmd('remote',null,'确认进入远控模式 (SYST:REM)?\n面板按键将被锁定。')">远控 SYST:REM</button>
|
||||
<button id="btnLocal" class="btn warn" onclick="sendCmd('local',null,'确认切回面板本控 (SYST:LOC)?\n输出将关闭且远控失效。')">本控 SYST:LOC</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡5:SCPI 调试 ========-->
|
||||
<div class="card">
|
||||
<h2>SCPI 调试(高级)</h2>
|
||||
<div class="inline"><input type="text" id="rawCmd" style="flex:1;min-width:180px" placeholder="例如: *IDN? 或 VOLT:LIM? MAX" />
|
||||
<button id="btnRawSend" class="btn" onclick="sendRaw()">发送</button></div>
|
||||
<div class="sub">最近响应</div>
|
||||
<div class="okline" id="rawResp">-</div>
|
||||
<div class="sub">非常规 SCPI 收发记录 (sqlite scpi_log)</div>
|
||||
<div id="scpiLog" style="max-height:200px;overflow:auto"><span class="dim">点击“刷新记录”加载</span></div>
|
||||
<button class="btn" onclick="loadScpiLog()">刷新记录</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡6:数据分析 ========-->
|
||||
<div class="card">
|
||||
<h2>数据分析 (高采样 meas_log)</h2>
|
||||
<div class="row"><span class="k">测量记录条数</span><span class="v" id="measCount">-</span></div>
|
||||
<div class="tip">实测电压/电流/功率按控制周期(默认 100ms)落库,用于功率波形分析。可预览最近数据或导出 CSV 离线分析(Excel/Python)。</div>
|
||||
<div class="sub">最近数据预览</div>
|
||||
<div id="measPreview" style="max-height:220px;overflow:auto"><span class="dim">点击“加载预览”</span></div>
|
||||
<button class="btn" onclick="loadMeas()">加载预览 (最近50条)</button>
|
||||
<button class="btn" onclick="downloadMeasCsv()">导出 CSV (最近2000条)</button>
|
||||
<div class="sub">按时间范围导出(unix 秒)</div>
|
||||
<div class="inline"><label>起始</label><input type="number" id="csvStart" step="1" placeholder="起始 unix 秒">
|
||||
<label>结束</label><input type="number" id="csvEnd" step="1" placeholder="结束 unix 秒">
|
||||
<button class="btn" onclick="downloadMeasRange()">按范围导出</button></div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡7:历史记录 ========-->
|
||||
<div class="card">
|
||||
<h2>历史记录 (sqlite eload_log, 1Hz)</h2>
|
||||
<div class="row"><span class="k">记录总数</span><span class="v" id="statusCount">-</span></div>
|
||||
<div id="history" style="max-height:240px;overflow:auto"><span class="dim">点击“查询”加载</span></div>
|
||||
<button class="btn" onclick="loadHistory()">查询最近 60 条</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status');
|
||||
|
||||
function ageText(a){
|
||||
if(typeof a !== 'number' || a < 0) return '从未';
|
||||
if(a < 90) return a.toFixed(1)+' 秒前';
|
||||
if(a < 3600) return Math.floor(a/60)+' 分 '+Math.floor(a%60)+' 秒前';
|
||||
return Math.floor(a/3600)+' 时 '+Math.floor((a%3600)/60)+' 分前';
|
||||
}
|
||||
function ageCell(d, key){
|
||||
if(!d || !d[key+'Valid']) return '<span class="dim">从未</span>';
|
||||
const online = d[key+'Age'] < 3;
|
||||
return (online ? '<span style="color:var(--ok)">' : '<span style="color:var(--bad)">')
|
||||
+ ageText(d[key+'Age']) + (online ? ' (在线)' : ' (超时)') + '</span>';
|
||||
}
|
||||
function fmt(v, n){ return (typeof v === 'number') ? v.toFixed(n===undefined?2:n) : '-'; }
|
||||
|
||||
//------------------ 指令下发(全部带确认,防误触) ------------------
|
||||
// 链路: 网页 fetch -> pELoad 入队 -> 控制线程(100ms) -> SCPI -> IT6000C
|
||||
function sendCmd(action, value, confirmMsg){
|
||||
if(confirmMsg && !confirm(confirmMsg)) return;
|
||||
let url = '/api/eload_cmd?action='+action;
|
||||
if(value !== undefined && value !== null) url += '&value='+value;
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if(j.ok){ if(action!=='identify') statusEl.textContent = '已下发: '+action; }
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(() => alert('网络错误,指令未下发'));
|
||||
}
|
||||
function sendRaw(){
|
||||
const cmd = $('rawCmd').value.trim();
|
||||
if(!cmd){ alert('请输入 SCPI 命令'); return; }
|
||||
if(!confirm('确认发送原始 SCPI 指令:\n'+cmd+'\n\n原始指令不做任何校验,请谨慎操作!')) return;
|
||||
fetch('/api/eload_cmd?action=raw&cmd='+encodeURIComponent(cmd))
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
$('rawResp').textContent = j.ok ? (j.response || '(无响应)') : ('错误: '+(j.error||''));
|
||||
if(j.ok && j.response) loadScpiLog();
|
||||
})
|
||||
.catch(() => { $('rawResp').textContent = '网络错误'; });
|
||||
}
|
||||
// 从输入框取数并下发(带确认)
|
||||
function sendFromInput(action, inputId, label, unit){
|
||||
const v = parseFloat($(inputId).value);
|
||||
if(isNaN(v)){ alert('请输入有效数值'); return; }
|
||||
sendCmd(action, v, '确认设定'+label+' = '+v+' '+unit+' ?');
|
||||
}
|
||||
// 功能模式切换(带确认 + 脱离实时控制警告)
|
||||
function switchFuncMode(){
|
||||
const sel = $('funcModeSel');
|
||||
const m = sel.value;
|
||||
if(m === 'FIX'){
|
||||
if(!confirm('确认切换为 FIX(固定输出)?\n将恢复本程序的实时功率控制。')) return;
|
||||
} else {
|
||||
if(!confirm('确认切换到 '+m+' 模式?\n\n⚠ 该模式会脱离本程序的实时功率控制,\n输出将按 '+m+' 的模式自主运行!\n请确保已理解风险后再确认。')) return;
|
||||
}
|
||||
fetch('/api/eload_cmd?action=func_mode&mode='+m)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if(j.ok) statusEl.textContent = '已下发: func_mode '+m;
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(() => alert('网络错误,指令未下发'));
|
||||
}
|
||||
|
||||
//------------------ 渲染(WebSocket 1Hz 快照,仅按 id 更新,不重建 DOM)------------------
|
||||
let lastSnap = null;
|
||||
// 设置输入框值:用户聚焦编辑中不覆盖(保证输入流畅、光标稳定)
|
||||
function setInput(id, v){
|
||||
const el = $(id);
|
||||
if(!el) return;
|
||||
if(document.activeElement === el) return;
|
||||
const str = (v === undefined || v === null) ? '' : String(v);
|
||||
if(el.value !== str) el.value = str;
|
||||
}
|
||||
function setDisabled(id, cond){ const el = $(id); if(el) el.disabled = !!cond; }
|
||||
|
||||
function render(s){
|
||||
lastSnap = s;
|
||||
const link = s.link || {}, ctrl = s.ctrl || {}, dev = s.dev || {}, db = s.db || {};
|
||||
const connected = !!link.connected, cc = !!ctrl.ccPriority;
|
||||
const loadOn = !!ctrl.loadOn, outpApplied = !!ctrl.outpApplied;
|
||||
const modeName = {follow:'跟随 MOOS 功率', manual:'手动功率', current:'手动电流', volt:'恒压吸收'};
|
||||
const powerStale = ctrl.moosPowerValid && ctrl.moosPowerAge > (ctrl.staleSec||0);
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
//---- 顶部状态条 ----
|
||||
statusEl.textContent = connected ? '已连接 '+link.host+':'+link.port : '未连接';
|
||||
statusEl.className = 'badge ' + (connected ? 'ok' : 'err');
|
||||
$('badgeLink').textContent = '链路: ' + (connected ? '已连接' : '未连接');
|
||||
$('badgeLink').className = 'badge ' + (connected ? 'ok' : 'err');
|
||||
$('badgeOutp').textContent = '输出: ' + (outpApplied ? '开' : (loadOn ? '待开' : '关'));
|
||||
$('badgeOutp').className = 'badge ' + (outpApplied ? 'ok' : (loadOn ? 'warn' : 'err'));
|
||||
$('badgeMode').textContent = '模式: ' + (cc ? 'CC优先' : 'CV优先') + ' · ' + (modeName[ctrl.mode]||ctrl.mode);
|
||||
$('badgeMode').className = 'badge' + (dev.funcModeFixWarn ? ' err' : '');
|
||||
|
||||
//---- 步骤条 ----
|
||||
let curStep = 1, tipText = '';
|
||||
if(!connected){ curStep = 1; tipText = '请先连接设备(步骤1)'; }
|
||||
else if(!outpApplied){ curStep = 2; tipText = '设置环路优先/控制模式与限值(步骤2),然后开启输出(步骤3)'; }
|
||||
else { curStep = 3; tipText = '输出已开启,正在进行实时功率控制'; }
|
||||
if(dev.funcModeFixWarn) tipText = '!! FUNC:MODE 非 FIX,实时控制失效 !!';
|
||||
$('step1').className = 'step ' + (connected ? 'done' : 'active');
|
||||
$('step2').className = 'step ' + (outpApplied ? 'done' : (curStep===2 ? 'active' : ''));
|
||||
$('step3').className = 'step ' + (outpApplied ? 'done active' : '');
|
||||
$('stepTip').innerHTML = tipText;
|
||||
|
||||
//---- 卡1:链路 ----
|
||||
$('linkAddr').textContent = link.host + ':' + link.port;
|
||||
$('linkConn').innerHTML = connected
|
||||
? '<span style="color:var(--ok)">已连接</span>'
|
||||
: '<span style="color:var(--bad)">未连接</span>';
|
||||
$('linkAuto').textContent = link.enabled ? '开启' : '关闭';
|
||||
$('linkReconn').textContent = link.reconnects;
|
||||
$('linkStats').textContent = 'tx='+link.tx+' rx='+link.rx+' txErr='+link.txErr;
|
||||
$('linkLastRx').innerHTML = ageCell(link, 'lastRx');
|
||||
$('linkIdn').innerHTML = link.idn || '<span class="dim">未识别</span>';
|
||||
$('linkError').style.display = link.lastError ? '' : 'none';
|
||||
$('linkError').textContent = link.lastError || '';
|
||||
setDisabled('btnConnect', connected);
|
||||
setDisabled('btnDisconnect', !connected);
|
||||
setDisabled('btnIdentify', !connected);
|
||||
setDisabled('btnClearProt', !connected);
|
||||
setDisabled('btnReset', !connected);
|
||||
|
||||
//---- 卡2:状态 + 测量 ----
|
||||
const bo = $('bigOutp');
|
||||
bo.textContent = outpApplied ? '输出开' : (loadOn ? '待应用' : '输出关');
|
||||
bo.className = 'bigstat ' + (outpApplied ? 'on' : (loadOn ? 'wait' : 'off'));
|
||||
const fw = $('fixWarn');
|
||||
if(dev.funcModeFixWarn){
|
||||
fw.style.display = '';
|
||||
fw.innerHTML = 'FUNC:MODE = '+(dev.funcMode||'?')+'(非 FIX)——实时功率控制已失效!请复位或手动恢复 FIX。';
|
||||
} else fw.style.display = 'none';
|
||||
$('measV').textContent = fmt(dev.measV, 2);
|
||||
$('measI').textContent = fmt(dev.measI, 3);
|
||||
$('measP').textContent = fmt(dev.measP, 1);
|
||||
$('measAge').innerHTML = 'V '+ageCell(dev,'measV')+' · I '+ageCell(dev,'measI')+' · P '+ageCell(dev,'measP');
|
||||
$('measDiag').innerHTML = '成功 <span style="color:var(--ok)">'+dev.measOk+'</span> · 失败 <span style="color:'+(dev.measFail>0?'var(--bad)':'var(--dim)')+'">'+dev.measFail+'</span>';
|
||||
const mde = $('measDiagErr');
|
||||
if(dev.lastMeasErr){
|
||||
mde.style.display = '';
|
||||
mde.textContent = dev.lastMeasErr;
|
||||
} else mde.style.display = 'none';
|
||||
$('devFunc').textContent = dev.func || '-';
|
||||
const fmName = (dev.funcMode || '').trim();
|
||||
$('devFuncMode').innerHTML = fmName
|
||||
? '<b style="font-size:15px">' + fmName + '</b>'
|
||||
+ (dev.funcModeFixWarn ? ' <span class="badge err">非FIX</span>' : ' <span class="badge ok">FIX</span>')
|
||||
: '<span class="dim">回读中...</span>';
|
||||
const cfs = $('cfgSync');
|
||||
if(dev.cfgSynced){
|
||||
cfs.innerHTML = '<span style="color:var(--ok)">已同步</span>('+ageText(now - dev.cfgSyncTime)+')';
|
||||
} else {
|
||||
cfs.innerHTML = '<span style="color:var(--warn)">同步中…</span>';
|
||||
}
|
||||
const fmf = $('funcModeFeed');
|
||||
if(dev.funcModeFeedValid){
|
||||
const ok = dev.funcModeFeedOk ? '已发送成功' : '发送失败(未连接或超时)';
|
||||
fmf.textContent = dev.funcModeFeed + ':' + ok + '('+ageText(now - dev.funcModeFeedTime)+')';
|
||||
fmf.style.color = dev.funcModeFeedOk ? 'var(--ok)' : 'var(--bad)';
|
||||
} else { fmf.textContent = '-'; fmf.style.color = ''; }
|
||||
setDisabled('btnFuncMode', !connected);
|
||||
setDisabled('funcModeSel', !connected);
|
||||
// 下拉框同步当前设备功能模式(仅当未聚焦时)
|
||||
const fmsel = $('funcModeSel');
|
||||
if(document.activeElement !== fmsel && dev.funcMode){
|
||||
const opts = ['FIX','LIST','BATT','SOLAR','CAR'];
|
||||
const idx = opts.indexOf(dev.funcMode);
|
||||
if(idx >= 0 && fmsel.selectedIndex !== idx) fmsel.selectedIndex = idx;
|
||||
}
|
||||
|
||||
//---- 卡3:控制 ----
|
||||
setDisabled('btnModeCC', !connected || cc);
|
||||
setDisabled('btnModeCV', !connected || !cc);
|
||||
$('ctrlTip').innerHTML = cc
|
||||
? '<b>CC 优先</b>:控制量 = 电流。目标功率/电流 → CURR 指令(负值=吸收电能)。'
|
||||
: '<b>CV 优先</b>:控制量 = 电压。恒压吸收,功率由外部源决定。';
|
||||
$('ccArea').style.display = cc ? '' : 'none';
|
||||
$('cvArea').style.display = cc ? 'none' : '';
|
||||
setDisabled('btnModeFollow', !connected || ctrl.mode==='follow');
|
||||
setDisabled('btnModeManual', !connected || ctrl.mode==='manual');
|
||||
setDisabled('btnModeCurrent', !connected || ctrl.mode==='current');
|
||||
$('curModeTip').innerHTML = '当前:<b>'+(modeName[ctrl.mode]||ctrl.mode)+'</b>';
|
||||
setInput('powerInput', ctrl.manualPower || 0);
|
||||
setInput('currInput', ctrl.manualCurrent || 0);
|
||||
setInput('voltInput', ctrl.voltSet || 0);
|
||||
setDisabled('btnSetPower', !connected);
|
||||
setDisabled('btnSetCurr', !connected);
|
||||
setDisabled('btnSetVolt', !connected);
|
||||
setDisabled('powerInput', !connected);
|
||||
setDisabled('currInput', !connected);
|
||||
setDisabled('voltInput', !connected);
|
||||
$('moosPower').innerHTML = (ctrl.moosPowerValid
|
||||
? fmt(ctrl.moosPower,1)+' W ('+ageText(ctrl.moosPowerAge)+')'
|
||||
: '<span class="dim">未收到</span>')
|
||||
+ (powerStale ? ' <span class="badge err">超时!</span>' : '');
|
||||
$('cmdCurrent').textContent = fmt(ctrl.cmdCurrent,4) + ' A';
|
||||
$('signTip').textContent = ctrl.sign==='negative' ? '负电流=吸收(负载模拟)' : '正电流=输出(电源模拟)';
|
||||
$('cmdVolt').textContent = fmt(ctrl.cmdVolt,2) + ' V';
|
||||
setDisabled('btnOutpOn', !connected || loadOn);
|
||||
setDisabled('btnOutpOff', !connected || !loadOn);
|
||||
$('outpTip').innerHTML = outpApplied
|
||||
? '<b style="color:var(--ok)">输出已开启</b>,正在实时控制'
|
||||
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>'
|
||||
: '输出未开启,开启前请完成步骤②参数设置');
|
||||
|
||||
//---- 卡4:参数 ----
|
||||
setInput('voltlimInput', dev.voltLimit || 0);
|
||||
setInput('currlimInput', dev.currLimit || 0);
|
||||
setInput('powlimInput', dev.powLimit || 0);
|
||||
setDisabled('voltlimInput', !connected);
|
||||
setDisabled('currlimInput', !connected);
|
||||
setDisabled('powlimInput', !connected);
|
||||
setDisabled('btnSetVoltlim', !connected);
|
||||
setDisabled('btnSetCurrlim', !connected);
|
||||
setDisabled('btnSetPowlim', !connected);
|
||||
setDisabled('btnRemote', !connected);
|
||||
setDisabled('btnLocal', !connected);
|
||||
const rs = $('remoteState');
|
||||
rs.textContent = dev.remoteState==='remote' ? '远控(SCPI 控制中)'
|
||||
: dev.remoteState==='local' ? '本控(面板控制)' : '未知';
|
||||
rs.style.color = dev.remoteState==='remote' ? 'var(--ok)'
|
||||
: dev.remoteState==='local' ? 'var(--warn)' : 'var(--dim)';
|
||||
const rf = $('remoteFeed');
|
||||
rf.textContent = dev.remoteFeedValid
|
||||
? dev.remoteFeed + '('+ageText(now - dev.remoteFeedTime)+')'
|
||||
: '-';
|
||||
const feedFail = (dev.remoteFeed||'').indexOf('失败') >= 0;
|
||||
rf.style.color = feedFail ? 'var(--bad)' : '';
|
||||
rf.style.fontFamily = feedFail ? 'Consolas,monospace' : '';
|
||||
|
||||
//---- 卡5:SCPI ----
|
||||
setDisabled('btnRawSend', !connected);
|
||||
setDisabled('rawCmd', !connected);
|
||||
|
||||
//---- 卡6/7:数据 ----
|
||||
$('measCount').textContent = db.measCount;
|
||||
$('statusCount').textContent = db.statusCount + ' / SCPI ' + db.scpiCount;
|
||||
}
|
||||
|
||||
//------------------ 数据分析(高采样测量 meas_log) ------------------
|
||||
function loadMeas(){
|
||||
fetch('/api/meas?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th class="l">V</th><th class="l">I(A)</th>'
|
||||
+ '<th class="l">P(W)</th><th class="l">指令I</th><th class="l">指令V</th><th class="l">目标P</th><th>输出</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString('zh-CN', {hour12:false}) + '.' + Math.floor((r.time%1)*1000);
|
||||
html += '<tr><td class="l">'+t+'</td><td class="l">'+fmt(r.v,2)+'</td><td class="l">'+fmt(r.i,3)+'</td>'
|
||||
+ '<td class="l">'+fmt(r.p,1)+'</td><td class="l">'+fmt(r.cmdI,3)+'</td><td class="l">'+fmt(r.cmdV,1)+'</td>'
|
||||
+ '<td class="l">'+fmt(r.targetP,1)+'</td>'
|
||||
+ '<td>'+(r.outp?1:0)+'</td></tr>';
|
||||
}
|
||||
$('measPreview').innerHTML = rows.length ? html : '<span class="dim">暂无数据</span>';
|
||||
})
|
||||
.catch(() => { $('measPreview').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
function downloadMeasCsv(){
|
||||
if(!confirm('导出最近 2000 条测量记录 (CSV) 用于离线分析?')) return;
|
||||
window.location.href = '/api/meas_csv?limit=2000';
|
||||
}
|
||||
function downloadMeasRange(){
|
||||
const st = parseFloat($('csvStart').value), et = parseFloat($('csvEnd').value);
|
||||
if(isNaN(st) || isNaN(et)){ alert('请输入有效的起始/结束 unix 秒'); return; }
|
||||
if(et <= st){ alert('结束时间必须大于起始时间'); return; }
|
||||
if(!confirm('导出时间范围 ['+st+' ~ '+et+'] 的测量记录 (CSV)?')) return;
|
||||
window.location.href = '/api/meas_csv?start='+st+'&end='+et;
|
||||
}
|
||||
function loadHistory(){
|
||||
fetch('/api/history?limit=60')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th>连</th><th>输出</th><th class="l">模式</th>'
|
||||
+ '<th>目标W</th><th>目标V</th><th>指令A</th><th>指令V</th><th>V</th><th>A</th><th>W</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||||
html += '<tr><td class="l">'+t+'</td><td>'+(r.connected?1:0)+'</td><td>'+(r.outp?1:0)+'</td>'
|
||||
+ '<td class="l">'+r.mode+'</td><td>'+fmt(r.targetPower,1)+'</td><td>'+fmt(r.voltSet,0)+'</td>'
|
||||
+ '<td>'+fmt(r.cmdCurrent,3)+'</td><td>'+fmt(r.cmdVolt,1)+'</td>'
|
||||
+ '<td>'+fmt(r.measVoltage,1)+'</td><td>'+fmt(r.measCurrent,3)+'</td><td>'+fmt(r.measPower,1)+'</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
$('history').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||||
})
|
||||
.catch(() => { $('history').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
function loadScpiLog(){
|
||||
fetch('/api/scpi_log?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th class="l">收/发</th><th class="l">内容</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||||
const dir = r.dir ? '<span style="color:#79c0ff">发</span>' : '<span style="color:#3fb950">收</span>';
|
||||
html += '<tr><td class="l">'+t+'</td><td class="l">'+dir+'</td><td class="l">'+r.text+'</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
$('scpiLog').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||||
})
|
||||
.catch(() => { $('scpiLog').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
|
||||
//------------------ WebSocket 1Hz 快照 ------------------
|
||||
let ws = null, wsTimer = null;
|
||||
function connectWs(){
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(proto + location.host + '/ws');
|
||||
ws.onopen = () => { statusEl.textContent = '已连接'; statusEl.className = 'badge ok'; };
|
||||
ws.onmessage = e => {
|
||||
try { render(JSON.parse(e.data)); } catch(err) {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
statusEl.textContent = '连接断开, 重连中...';
|
||||
statusEl.className = 'badge err';
|
||||
wsTimer = setTimeout(connectWs, 2000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWs();
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_PAGE_INDEX_H
|
||||
@@ -0,0 +1,199 @@
|
||||
#ifndef PELOAD_PAGE_TREND_H
|
||||
#define PELOAD_PAGE_TREND_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pELoad 实时曲线页(独立标签页)。
|
||||
//
|
||||
// - 通过 WebSocket /ws 接收 5Hz 快照,绘制电压/电流/功率三条实时曲线;
|
||||
// - 可从 /api/meas(100ms 高采样 meas_log)加载历史数据回填;
|
||||
// - 纯前端 canvas 绘制,无外部依赖。
|
||||
//============================================================================
|
||||
|
||||
namespace eload {
|
||||
const std::string TREND_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pELoad 实时曲线</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--blue:#58a6ff;--green:#3fb950;--orange:#d29922;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:14px;}
|
||||
h1{font-size:17px;margin-bottom:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);}
|
||||
.badge.ok{color:var(--green);border-color:var(--green);}
|
||||
.badge.err{color:#f85149;border-color:#f85149;}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;}
|
||||
.panel h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;}
|
||||
.panel h2 .now{float:right;font-size:15px;font-family:Consolas,monospace;color:var(--fg);text-transform:none;}
|
||||
canvas{width:100%;height:180px;display:block;background:#0b1018;border:1px solid #22304a;border-radius:4px;}
|
||||
.controls{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.controls select{background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:4px 8px;}
|
||||
.btn{padding:5px 14px;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||||
.btn:hover{border-color:#3b4a63;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pELoad 实时曲线 <span id="status" class="badge">连接中...</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/" target="_blank">控制页面</a>
|
||||
</h1>
|
||||
|
||||
<div class="controls">
|
||||
<span style="color:var(--dim)">时间窗口</span>
|
||||
<select id="winSel">
|
||||
<option value="30">30 秒</option>
|
||||
<option value="60" selected>60 秒</option>
|
||||
<option value="120">120 秒</option>
|
||||
<option value="300">300 秒 (5分钟)</option>
|
||||
<option value="600">600 秒 (10分钟)</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadHistory()">加载历史数据 (最近窗口)</button>
|
||||
<span style="color:var(--dim)" id="info">采样: 0 点</span>
|
||||
</div>
|
||||
|
||||
<div class="panel"><h2>电压 V <span class="now" id="nowV" style="color:var(--blue)">-</span></h2><canvas id="cvV"></canvas></div>
|
||||
<div class="panel"><h2>电流 A <span class="now" id="nowI" style="color:var(--green)">-</span></h2><canvas id="cvI"></canvas></div>
|
||||
<div class="panel"><h2>功率 W <span class="now" id="nowP" style="color:var(--orange)">-</span></h2><canvas id="cvP"></canvas></div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status');
|
||||
const MAXPTS = 8000;
|
||||
// 数据缓冲:{t: ms时间戳, v, i, p}
|
||||
const data = [];
|
||||
let winSec = 60;
|
||||
|
||||
// 裁剪到时间窗口
|
||||
function trim(){
|
||||
const cutoff = Date.now() - winSec*1000;
|
||||
while(data.length && data[0].t < cutoff) data.shift();
|
||||
if(data.length > MAXPTS) data.splice(0, data.length - MAXPTS);
|
||||
$('info').textContent = '采样: ' + data.length + ' 点';
|
||||
}
|
||||
|
||||
function draw(canvas, key, color){
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width = canvas.clientWidth * (window.devicePixelRatio||1);
|
||||
const H = canvas.height = 180 * (window.devicePixelRatio||1);
|
||||
ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
|
||||
const w = canvas.clientWidth, h = 180;
|
||||
ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,w,h);
|
||||
|
||||
// 纵向范围(动态,含 0)
|
||||
let min = 0, max = 0, has = false;
|
||||
for(const p of data){
|
||||
const v = p[key];
|
||||
if(!isFinite(v)) continue;
|
||||
if(!has){ min = max = v; has = true; }
|
||||
else { if(v<min)min=v; if(v>max)max=v; }
|
||||
}
|
||||
if(!has){ min = 0; max = 1; }
|
||||
const pad = (max-min)*0.15 || 1;
|
||||
min -= pad; max += pad;
|
||||
if(max <= min){ max = min+1; }
|
||||
|
||||
// 网格(横向 5 条 + 纵向约 10 格)
|
||||
ctx.strokeStyle = '#1c2740'; ctx.lineWidth = 1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const y = (h-8) - (h-16) * (i/5) + 4;
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
const tstep = winSec/10;
|
||||
for(let i=0;i<=10;i++){
|
||||
const x = (w-4) * (i/10) + 2;
|
||||
ctx.beginPath(); ctx.moveTo(x,4); ctx.lineTo(x,h-4); ctx.stroke();
|
||||
}
|
||||
|
||||
// 曲线
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
const nowT = Date.now();
|
||||
for(const p of data){
|
||||
const x = 2 + (w-4) * (1 - (nowT - p.t)/ (winSec*1000));
|
||||
const y = 4 + (h-16) * (1 - (p[key]-min)/(max-min));
|
||||
if(x < -10) continue;
|
||||
if(!started){ ctx.moveTo(x,y); started = true; }
|
||||
else ctx.lineTo(x,y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Y 轴刻度(最大值/最小值标注)
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '11px Consolas,monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(max.toFixed(max>100?0:1), 4, 14);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(min.toFixed(min>100?0:1), w-4, h-6);
|
||||
}
|
||||
|
||||
function redraw(){
|
||||
trim();
|
||||
draw($('cvV'), 'v', '#58a6ff');
|
||||
draw($('cvI'), 'i', '#3fb950');
|
||||
draw($('cvP'), 'p', '#d29922');
|
||||
}
|
||||
|
||||
// 实时快照(WebSocket 5Hz)
|
||||
function onSnapshot(s){
|
||||
const d = s.dev || {};
|
||||
if(!d.measVValid && !d.measIValid && !d.measPValid) return;
|
||||
const t = Date.now();
|
||||
data.push({t, v:d.measV, i:d.measI, p:d.measP});
|
||||
$('nowV').textContent = d.measV.toFixed(2);
|
||||
$('nowI').textContent = d.measI.toFixed(3);
|
||||
$('nowP').textContent = d.measP.toFixed(1);
|
||||
redraw();
|
||||
}
|
||||
|
||||
// 历史数据加载(/api/meas 时间范围查询,覆盖整个窗口)
|
||||
function loadHistory(){
|
||||
const nowSec = Date.now()/1000;
|
||||
const start = nowSec - winSec;
|
||||
$('info').textContent = '加载历史...';
|
||||
const url = '/api/meas?start=' + start.toFixed(3) + '&end=' + nowSec.toFixed(3) + '&limit=20000';
|
||||
fetch(url).then(r=>r.json()).then(j => {
|
||||
const arr = j.rows || []; // 时间升序
|
||||
if(!arr.length){ $('info').textContent = '暂无历史数据'; return; }
|
||||
const nowMs = Date.now();
|
||||
data.length = 0;
|
||||
for(let i=0;i<arr.length;i++){
|
||||
const r = arr[i];
|
||||
data.push({t: nowMs - (nowSec - r.time)*1000, v:r.v, i:r.i, p:r.p});
|
||||
}
|
||||
const last = arr[arr.length-1];
|
||||
$('nowV').textContent = last.v.toFixed(2);
|
||||
$('nowI').textContent = last.i.toFixed(3);
|
||||
$('nowP').textContent = last.p.toFixed(1);
|
||||
redraw();
|
||||
$('info').textContent = '已加载 ' + arr.length + ' 条历史';
|
||||
}).catch(()=>{ $('info').textContent = '历史加载失败'; });
|
||||
}
|
||||
|
||||
$('winSel').addEventListener('change', function(){ winSec = parseInt(this.value,10); redraw(); });
|
||||
|
||||
// WebSocket 5Hz 快照
|
||||
let ws=null, wsTimer=null;
|
||||
function connectWs(){
|
||||
const proto = location.protocol==='https:'?'wss://':'ws://';
|
||||
ws = new WebSocket(proto + location.host + '/ws');
|
||||
ws.onopen = () => { statusEl.textContent='已连接'; statusEl.className='badge ok'; };
|
||||
ws.onmessage = e => { try{ onSnapshot(JSON.parse(e.data)); }catch(err){} };
|
||||
ws.onclose = () => { statusEl.textContent='连接断开, 重连中...'; statusEl.className='badge err';
|
||||
wsTimer = setTimeout(connectWs, 2000); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWs();
|
||||
// 初始加载历史(有数据时立即有曲线)
|
||||
loadHistory();
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_PAGE_TREND_H
|
||||
@@ -0,0 +1,8 @@
|
||||
本程序用于整个能源-动力系统中的电子负载控制,具备以下功能/需求
|
||||
1.通过以太网与docs/电子负载说明手册中的电子负载连接,并可以进行基本的控制、操作、参数设定等
|
||||
2.具备100ms周期的功率控制能力,后续有其他程序会通过MOOSDB发布功率消息,改功率消息为实施计算的电机功率,通过电子负载模拟电机功率的变化
|
||||
3.使用web页面进行人机交互
|
||||
4.具备sqlit3的数据存储功能
|
||||
5.使用moos文件进行配置电子负载地址、端口等信息
|
||||
|
||||
电子负载的详细操作及指令见docs/电子负载说明手册中的文件
|
||||
@@ -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}
|
||||
)
|
||||
@@ -0,0 +1,467 @@
|
||||
#include "Motor.h"
|
||||
#include "MBUtils.h"
|
||||
#include "../pPowerManger/logc/loguru.hpp"
|
||||
#include "json/json.h"
|
||||
#include <cstdio>
|
||||
#include <sstream>
|
||||
|
||||
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<std::mutex> lock(m_mutex);
|
||||
if (!decodeMotorFrame(canId, d, static_cast<int>(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<int16_t>(atoi(speedStr.c_str()));
|
||||
} else {
|
||||
return "{\"ok\":false,\"error\":\"bad action (enable/disable/reset/speed)\"}";
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(m_mutex);
|
||||
m_txCount += static_cast<unsigned long>(okCount);
|
||||
m_txErr += static_cast<unsigned long>(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<Json::UInt64>(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<int>(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<std::mutex> 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<std::mutex> 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;
|
||||
}
|
||||
@@ -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 <mutex>
|
||||
|
||||
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<MotorCmd> 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
|
||||
@@ -0,0 +1,103 @@
|
||||
/****************************************************************/
|
||||
/* NAME: Motor_Info */
|
||||
/* FILE: Motor_Info.cpp */
|
||||
/****************************************************************/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#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","=<ProcessName> ");
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
/************************************************************/
|
||||
/* NAME: pMotor */
|
||||
/* FILE: main.cpp */
|
||||
/************************************************************/
|
||||
|
||||
#include <string>
|
||||
#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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
#include "MotorCan.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
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<uint16_t>(d[byteIdx] | (static_cast<uint16_t>(d[byteIdx + 1]) << 8));
|
||||
}
|
||||
|
||||
// 重建某支路的故障/报警描述列表(word 更新后调用)
|
||||
void rebuildBranchTexts(MotorBranchState& br) {
|
||||
br.faults.clear();
|
||||
br.alarms.clear();
|
||||
for (int w = 0; w < 3; ++w) {
|
||||
std::vector<std::string> 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<std::string>& 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<int>(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<uint8_t>((enable ? 0x01u : 0u) | (reset ? 0x02u : 0u));
|
||||
uint16_t sp = static_cast<uint16_t>(speed);
|
||||
out[2] = static_cast<uint8_t>(sp & 0xFFu);
|
||||
out[3] = static_cast<uint8_t>((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<int16_t>(sp);
|
||||
st.busVoltage = wordOf(data, 2);
|
||||
st.state3Valid = true;
|
||||
return true;
|
||||
}
|
||||
case MOTOR_CAN_STATE4:
|
||||
case MOTOR_CAN_STATE4_RED: {
|
||||
st.tempA = static_cast<int8_t>(data[0]);
|
||||
st.tempB = static_cast<int8_t>(data[1]);
|
||||
st.tempC = static_cast<int8_t>(data[2]);
|
||||
st.tempInv = static_cast<int8_t>(data[3]);
|
||||
st.tempFilter = static_cast<int8_t>(data[4]);
|
||||
st.tempAir = static_cast<int8_t>(data[5]);
|
||||
st.state4Valid = true;
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace motor
|
||||
@@ -0,0 +1,154 @@
|
||||
#ifndef PMOTOR_CAN_H
|
||||
#define PMOTOR_CAN_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> faults; // 当前置位的故障描述(word0~2)
|
||||
std::vector<std::string> 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<std::string>& 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
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "WebServer.h"
|
||||
#include "pages/index.h"
|
||||
#include <iostream>
|
||||
|
||||
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<std::string()> handler) {
|
||||
m_onOpen = std::move(handler);
|
||||
}
|
||||
|
||||
void WebServer::setApiHandler(std::function<std::string(const std::string&, const std::string&)> 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<WebServer*>(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<WebServer*>(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<std::mutex> 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<std::mutex> lock(m_pendingMutex);
|
||||
m_pending = text;
|
||||
}
|
||||
|
||||
} // namespace motor
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef PMOTOR_WEB_SERVER_H
|
||||
#define PMOTOR_WEB_SERVER_H
|
||||
|
||||
#define UNIX
|
||||
#include "mongoose.h"
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
|
||||
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<std::string()> handler);
|
||||
|
||||
// 处理 /api/* 请求,返回 JSON 响应体(空串表示 404)
|
||||
void setApiHandler(std::function<std::string(const std::string& uri, const std::string& query)> 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<bool> m_running{false};
|
||||
std::thread m_thread;
|
||||
|
||||
std::mutex m_pendingMutex; // 保护 m_pending
|
||||
std::string m_pending; // 最新待广播快照(新快照覆盖旧快照)
|
||||
std::vector<mg_connection*> m_wsConnections; // 仅 Web 线程访问
|
||||
std::function<std::string()> m_onOpen;
|
||||
std::function<std::string(const std::string&, const std::string&)> m_apiHandler;
|
||||
};
|
||||
|
||||
} // namespace motor
|
||||
|
||||
#endif // PMOTOR_WEB_SERVER_H
|
||||
@@ -0,0 +1,309 @@
|
||||
#ifndef PMOTOR_PAGE_INDEX_H
|
||||
#define PMOTOR_PAGE_INDEX_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pMotor 推进电机控制页面(纯前端,内嵌 HTML)。
|
||||
//
|
||||
// - 通过 WebSocket /ws 接收服务器推送的 JSON 快照(每秒)
|
||||
// - 控制:按协议指令帧字段独立下发(无联动按钮)
|
||||
// 使能命令(Byte0 bit0) / 复位命令(Byte0 bit1) / 期望转速(Byte2/3)
|
||||
// (fetch /api/motor_cmd -> pMotor 入队 -> MOOS CAN_TX -> pCanBridge -> CAN1)
|
||||
// - 展示:反馈转速/母线电压、温度、支路一/支路二故障与报警(按位解析中文)
|
||||
// - 协议定义:docs/推进电机通信协议20260321.docx
|
||||
//
|
||||
// 重要:页面 DOM 只在加载时构建一次,收到快照后仅就地更新各数值叶节点,
|
||||
// 绝不再整块重建 pages(重建会销毁正被聚焦/输入的控件导致光标跳动)。
|
||||
//============================================================================
|
||||
|
||||
namespace motor {
|
||||
const std::string INDEX_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pMotor 推进电机控制</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--ok:#2ea043;--warn:#d29922;--bad:#f85149;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:16px;}
|
||||
h1{font-size:18px;margin-bottom:12px;display:flex;align-items:center;gap:10px;}
|
||||
h2{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;border-bottom:1px solid var(--line);padding-bottom:6px;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);}
|
||||
.badge.ok{color:var(--ok);border-color:var(--ok);}
|
||||
.badge.warn{color:var(--warn);border-color:var(--warn);}
|
||||
.badge.err{color:var(--bad);border-color:var(--bad);}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;}
|
||||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed #222c3d;}
|
||||
.row:last-child{border-bottom:none;}
|
||||
.row .k{color:var(--dim);}
|
||||
.row .v{font-family:Consolas,monospace;color:var(--fg);text-align:right;}
|
||||
.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:6px 14px;margin:0 8px 8px 0;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;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.warn{background:#3a2e12;border-color:var(--warn);color:var(--warn);}
|
||||
.btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
input[type=number]{width:110px;background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);font-family:Consolas,monospace;padding:5px 8px;font-size:13px;}
|
||||
input[type=number]:focus{outline:none;border-color:#1f6feb;}
|
||||
.fc-bad{color:#f85149;font-weight:700;background:rgba(248,81,73,.12);padding:1px 6px;border-radius:4px;display:inline-block;margin:2px 4px 2px 0;}
|
||||
.fc-warn{color:#d29922;font-weight:600;background:rgba(210,153,34,.10);padding:1px 6px;border-radius:4px;display:inline-block;margin:2px 4px 2px 0;}
|
||||
.fc-ok{color:#3fb950;font-weight:600;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
.state-big{font-size:22px;font-family:Consolas,monospace;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pMotor 推进电机控制 <span id="status" class="badge">连接中...</span></h1>
|
||||
<div class="grid" id="pages"></div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const pagesEl = $('pages');
|
||||
const statusEl = $('status');
|
||||
|
||||
function row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
|
||||
function sub(t){ return '<div class="sub">'+t+'</div>'; }
|
||||
function hex16(v){ return '0x' + (v & 0xFFFF).toString(16).toUpperCase().padStart(4,'0'); }
|
||||
function card(title, html){ return '<div class="card"><h2>'+title+'</h2>'+html+'</div>'; }
|
||||
function ageText(a){
|
||||
if(typeof a !== 'number' || a < 0) return '从未收到';
|
||||
if(a < 90) return a.toFixed(1)+' 秒前';
|
||||
if(a < 3600) return Math.floor(a/60)+' 分 '+Math.floor(a%60)+' 秒前';
|
||||
return Math.floor(a/3600)+' 时 '+Math.floor((a%3600)/60)+' 分前';
|
||||
}
|
||||
function ageCell(d){
|
||||
if(!d || !d.valid) return '<span style="color:var(--dim)">从未收到</span>';
|
||||
const online = d.age < 3;
|
||||
return (online ? '<span class="fc-ok">' : '<span class="fc-bad">')
|
||||
+ ageText(d.age) + (online ? ' (在线)' : ' (超时)') + '</span>';
|
||||
}
|
||||
// 就地更新:只替换叶节点内容,绝不重建含输入控件的父级 DOM
|
||||
function setH(id, html){ const el = $(id); if(el) el.innerHTML = html; }
|
||||
|
||||
//------------------ 控制指令(/api/motor_cmd) ------------------
|
||||
// 链路: 网页 fetch -> pMotor 入队 -> Iterate 周期编码 0x18EF2010
|
||||
// -> MOOS CAN_TX_0x18EF2010 -> pCanBridge -> CAN1 -> 电机控制器
|
||||
function sendCmd(action, speed, confirmMsg){
|
||||
if(confirmMsg && !confirm(confirmMsg)) return;
|
||||
let url = '/api/motor_cmd?action='+action;
|
||||
if(speed !== undefined && speed !== null) url += '&speed='+speed;
|
||||
fetch(url)
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(j){
|
||||
if(j.ok) alert('指令已下发: '+j.action+(j.action==='speed' ? ' '+(j.speed||0)+' rpm' : ''));
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(function(){ alert('网络错误,指令未下发'); });
|
||||
}
|
||||
function motorEnable(){
|
||||
sendCmd('enable', null, '确认启动推进电机(使能=1,IGBT 开管)?\n电机将按当前期望转速运行。');
|
||||
}
|
||||
function motorStop(){
|
||||
sendCmd('disable', null, '确认停机(使能=0,期望转速清零)?');
|
||||
}
|
||||
function motorReset(){
|
||||
sendCmd('reset', null, '确认下发故障复位指令?\n复位命令仅在故障状态下有效,故障支路将执行复位。');
|
||||
}
|
||||
function readSpeed(){
|
||||
const el = $('speedInput');
|
||||
const v = parseInt(el.value, 10);
|
||||
if(isNaN(v)){ alert('请输入有效整数转速'); return null; }
|
||||
if(v < -32767 || v > 32767){ alert('转速范围 -32767 ~ 32767'); return null; }
|
||||
return v;
|
||||
}
|
||||
function motorSetSpeed(){
|
||||
const v = readSpeed(); if(v === null) return;
|
||||
sendCmd('speed', v, '确认设定期望转速 '+v+' rpm?\n(对应指令帧 Byte2/3,使能状态保持不变)');
|
||||
}
|
||||
|
||||
//------------------ 页面骨架(只在加载时构建一次) ------------------
|
||||
// 所有可被聚焦的控件(输入框/按钮)都在这份骨架里,之后绝不重建。
|
||||
function buildUI(){
|
||||
let html = '';
|
||||
|
||||
// 控制卡片(含输入框,永不重建)
|
||||
let c = '';
|
||||
c += row('使能状态', '<span id="cEnable">…</span>');
|
||||
c += row('期望转速', '<span id="cSpeed">…</span> rpm');
|
||||
c += row('复位命令', '<span id="cReset">…</span>');
|
||||
c += row('最近指令', '<span id="cLast">…</span>');
|
||||
c += sub('使能命令 (Byte0 bit0)') +
|
||||
'<div>' +
|
||||
'<button class="btn on" onclick="motorEnable()">使能 (1)</button>' +
|
||||
'<button class="btn off" onclick="motorStop()">去使能 (0)</button></div>';
|
||||
c += sub('复位命令 (Byte0 bit1,仅故障状态下有效)') +
|
||||
'<div><button class="btn warn" onclick="motorReset()">复位</button></div>';
|
||||
c += sub('期望转速 (Byte2/3,s16,单位 rpm)') +
|
||||
'<div>' +
|
||||
'<input type="number" id="speedInput" value="1000" min="-32767" max="32767" step="100" title="期望转速 (rpm)">' +
|
||||
'<span style="color:var(--dim);margin-left:6px;">rpm</span> ' +
|
||||
'<button class="btn" onclick="motorSetSpeed()">设定</button></div>' +
|
||||
'<div class="sub" style="margin-top:8px;">指令帧 0x18EF2010 周期 <span id="cPeriod">…</span>' +
|
||||
' ms 持续发送(协议要求);停机=使能0且转速清0(协议示例 00 00 00 00 00 00 00 00)</div>';
|
||||
html += card('电机指令 (下行 0x18EF2010)', c);
|
||||
|
||||
// 运行状态(状态帧3:转速 / 母线电压)
|
||||
let s3 = '';
|
||||
s3 += row('反馈转速', '<span id="s3speed" class="state-big">…</span> rpm');
|
||||
s3 += row('母线电压', '<span id="s3volt">…</span>');
|
||||
s3 += row('最后更新', '<span id="s3age">…</span>');
|
||||
s3 += sub('来源 0x18EF1220(100ms,冗余 0x18EF1221)');
|
||||
html += card('运行状态', s3);
|
||||
|
||||
// 温度(状态帧4)
|
||||
let s4 = '';
|
||||
s4 += row('A相绕组温度', '<span id="tA">…</span>');
|
||||
s4 += row('B相绕组温度', '<span id="tB">…</span>');
|
||||
s4 += row('C相绕组温度', '<span id="tC">…</span>');
|
||||
s4 += row('逆变组件底板温度', '<span id="tInv">…</span>');
|
||||
s4 += row('输出滤波器温度', '<span id="tFilter">…</span>');
|
||||
s4 += row('筒内空气温度', '<span id="tAir">…</span>');
|
||||
s4 += row('最后更新', '<span id="tAge">…</span>');
|
||||
s4 += sub('来源 0x18EF1320(100ms,冗余 0x18EF1321,Byte0~5 各 1 字节 s8)');
|
||||
html += card('温度', s4);
|
||||
|
||||
// 支路一 / 支路二 故障报警卡片
|
||||
function branchCard(title, pfx){
|
||||
let b = '';
|
||||
b += '<div class="row"><span class="k" id="'+pfx+'fK">故障</span>' +
|
||||
'<span class="v"><span id="'+pfx+'faults">…</span></span></div>';
|
||||
b += '<div class="row"><span class="k" id="'+pfx+'aK">报警</span>' +
|
||||
'<span class="v"><span id="'+pfx+'alarms">…</span></span></div>';
|
||||
b += row('故障字原始值', '<span id="'+pfx+'words">…</span>');
|
||||
b += row('最后更新', '<span id="'+pfx+'age">…</span>');
|
||||
return card(title, b);
|
||||
}
|
||||
html += branchCard('支路一故障/报警 (0x18EF1020)', 'f1');
|
||||
html += branchCard('支路二故障/报警 (0x18EF1120)', 'f2');
|
||||
|
||||
// 通信链路卡片
|
||||
let l = '';
|
||||
l += row('下行通道 (CAN1)', '<span id="lch">…</span>');
|
||||
l += row('冗余通道', '<span id="lred">…</span>');
|
||||
l += row('指令帧周期', '<span id="lperiod">…</span> ms');
|
||||
l += row('指令帧累计', '<span id="ltx">…</span>');
|
||||
l += row('最近下发', '<span id="llast">…</span>');
|
||||
l += row('电机报文接收', '<span id="lrx">…</span> 帧');
|
||||
l += sub('链路: 电机 <-> CAN1 <-> CANET <-> pCanBridge <-> MOOS <-> pMotor');
|
||||
html += card('通信链路 (pCanBridge)', l);
|
||||
|
||||
// 协议参考卡片(纯静态)
|
||||
let p = '';
|
||||
p += row('0x18EF2010', '主控指令帧(下行 500ms,冗余 0x18EF2011)');
|
||||
p += row('0x18EF2110', '主控限制帧(下行,全预留,暂不使用)');
|
||||
p += row('0x18EF1020', '支路一故障报警状态帧1(上行 200ms,冗余 0x18EF1021)');
|
||||
p += row('0x18EF1120', '支路二故障报警状态帧2(上行 200ms,冗余 0x18EF1121)');
|
||||
p += row('0x18EF1220', '状态帧3:反馈转速/母线电压(上行 100ms,冗余 0x18EF1221)');
|
||||
p += row('0x18EF1320', '状态帧4:温度(上行 100ms,冗余 0x18EF1321)');
|
||||
p += row('帧格式', 'CAN2.0B 扩展帧 29 位 ID,数据域 8 字节,Intel 字节序');
|
||||
p += row('波特率', '250 kbps(默认,按客户可定制 125~1000)');
|
||||
html += card('报文定义 (推进电机通信协议 20260321)', p);
|
||||
|
||||
pagesEl.innerHTML = html;
|
||||
}
|
||||
|
||||
//------------------ 快照就地更新(不触碰输入控件) ------------------
|
||||
function updateCmd(c){
|
||||
c = c || {};
|
||||
setH('cEnable', c.enable
|
||||
? '<span class="fc-ok">已使能(运行)</span>'
|
||||
: '<span style="color:var(--dim)">未使能(停机)</span>');
|
||||
setH('cSpeed', String(c.speed || 0));
|
||||
setH('cReset', c.resetActive ? '<span class="fc-warn">生效中(保持数秒)</span>' : '无效');
|
||||
if(c.lastCmdValid){
|
||||
const names = {enable:'使能命令=1', disable:'使能命令=0(停机)', reset:'复位命令=1', speed:'期望转速'};
|
||||
setH('cLast', (names[c.lastCmdAction] || c.lastCmdAction)
|
||||
+ (c.lastCmdAction==='speed' ? ' ('+(c.lastCmdSpeed||0)+' rpm)' : '')
|
||||
+ ' · ' + ageText(c.lastCmdAge));
|
||||
} else {
|
||||
setH('cLast', '<span style="color:var(--dim)">无</span>');
|
||||
}
|
||||
}
|
||||
|
||||
function updateState3(d){
|
||||
if(!d) return;
|
||||
setH('s3speed', d.valid ? String(d.speed || 0) : '—');
|
||||
setH('s3volt', d.valid ? ((d.voltage||0) + ' V') : '—');
|
||||
setH('s3age', ageCell(d));
|
||||
}
|
||||
|
||||
function updateState4(d){
|
||||
if(!d) return;
|
||||
const temps = [
|
||||
['tA', d.tA], ['tB', d.tB], ['tC', d.tC],
|
||||
['tInv', d.tInv], ['tFilter', d.tFilter], ['tAir', d.tAir],
|
||||
];
|
||||
for(const t of temps){
|
||||
setH(t[0], d.valid ? ((t[1]|0) + ' ℃') : '—');
|
||||
}
|
||||
setH('tAge', ageCell(d));
|
||||
}
|
||||
|
||||
function updateBranch(pfx, d){
|
||||
if(!d) return;
|
||||
const faults = d.faults || [];
|
||||
const alarms = d.alarms || [];
|
||||
setH(pfx+'fK', '故障' + (faults.length ? '(' + faults.length + '条)' : ''));
|
||||
setH(pfx+'aK', '报警' + (alarms.length ? '(' + alarms.length + '条)' : ''));
|
||||
setH(pfx+'faults', faults.length
|
||||
? faults.map(function(f){ return '<span class="fc-bad">'+f+'</span>'; }).join('')
|
||||
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
|
||||
setH(pfx+'alarms', alarms.length
|
||||
? alarms.map(function(a){ return '<span class="fc-warn">'+a+'</span>'; }).join('')
|
||||
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
|
||||
setH(pfx+'words', (d.words || []).map(hex16).join(' '));
|
||||
setH(pfx+'age', ageCell(d));
|
||||
}
|
||||
|
||||
function updateLink(l){
|
||||
l = l || {};
|
||||
setH('lch', l.channel || 'CAN1');
|
||||
setH('lred', l.redChannel ? l.redChannel : '<span style="color:var(--dim)">未配置</span>');
|
||||
setH('lperiod', String(l.periodMs || 0));
|
||||
setH('ltx', (l.txCount||0) + ' 成功 / ' + (l.txErr||0) + ' 失败');
|
||||
setH('llast', ageCell({valid:!!l.lastTxValid, age:l.lastTxAge}));
|
||||
setH('lrx', String(l.rxCount || 0));
|
||||
}
|
||||
|
||||
function render(snap){
|
||||
snap = snap || {};
|
||||
const link = snap.link || {};
|
||||
updateCmd(snap.cmd);
|
||||
updateState3(snap.state3);
|
||||
updateState4(snap.state4);
|
||||
updateBranch('f1', snap.fault1);
|
||||
updateBranch('f2', snap.fault2);
|
||||
updateLink(link);
|
||||
setH('cPeriod', String(link.periodMs || 500));
|
||||
}
|
||||
|
||||
// 骨架构建一次后即保持稳定;render 只做叶节点就地更新,
|
||||
// 输入框不再被销毁重建,光标与焦点因此不受快照刷新影响。
|
||||
buildUI();
|
||||
render({});
|
||||
|
||||
function connect(){
|
||||
const proto = location.protocol==='https:'?'wss':'ws';
|
||||
const ws = new WebSocket(proto+'://'+location.host+'/ws');
|
||||
ws.onopen = ()=>{ statusEl.textContent='已连接'; statusEl.className='badge ok'; };
|
||||
ws.onclose = ()=>{ statusEl.textContent='已断开,重连中...'; statusEl.className='badge err'; setTimeout(connect, 2000); };
|
||||
ws.onerror = ()=>{ ws.close(); };
|
||||
ws.onmessage = (ev)=>{
|
||||
try{
|
||||
render(JSON.parse(ev.data));
|
||||
}catch(e){}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)HTML";
|
||||
} // namespace motor
|
||||
|
||||
#endif // PMOTOR_PAGE_INDEX_H
|
||||
@@ -75,6 +75,16 @@ bool LowerCommManager::initUdpComm(){
|
||||
|
||||
return true;
|
||||
}
|
||||
void LowerCommManager::setLocalPort(long port){
|
||||
if (port <= 0 || port == m_port) return;
|
||||
m_port = port;
|
||||
if (m_udpComm && m_udpComm->udpScoket) {
|
||||
// 套接字构造时即记录接收端口,改端口须重建(此时尚未 bind,重建安全)
|
||||
delete m_udpComm->udpScoket;
|
||||
m_udpComm->udpScoket = new XPCUdpSocket(m_port);
|
||||
}
|
||||
}
|
||||
|
||||
void LowerCommManager::setCcuAddress(const std::string& host, long port){
|
||||
m_ccuHost = host;
|
||||
m_ccuPort = port;
|
||||
|
||||
@@ -35,6 +35,9 @@ public:
|
||||
// 设置 CCU 地址(moos 配置读取后调用;须在监听/发送线程启动前调用)
|
||||
void setCcuAddress(const std::string& host, long port);
|
||||
|
||||
// 设置本地输入端口(CCU -> pPowerManger,moos 配置读取后调用;须在 bind/监听线程启动前调用)
|
||||
void setLocalPort(long port);
|
||||
|
||||
// 设置原始帧日志回调(转发给 udpComm,可为空)
|
||||
void setCommLogger(ICommLogger* logger);
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ PowerManger::PowerManger()
|
||||
// CCU 地址默认值,可在 moos 文件中用 ccuhost/ccuport 覆盖
|
||||
m_ccuHost = CCUHOST;
|
||||
m_ccuPort = CCUPORT;
|
||||
// 本地输入端口(接收 CCU 数据)默认值,可在 moos 文件中用 iport 覆盖
|
||||
m_localPort = IPORT;
|
||||
|
||||
m_pmCurrentState = {};
|
||||
m_currentDisBreakerState = {};
|
||||
@@ -69,7 +71,7 @@ PowerManger::PowerManger()
|
||||
|
||||
//初始化下位机通信
|
||||
m_lowerCommManager = new LowerCommManager(this,IPORT,m_ccuPort,m_ccuHost);
|
||||
m_lowerCommManager->initUdpComm();
|
||||
// UDP 端口 bind 延迟到 OnStartUp 读取配置后进行,保证 iport/ccuhost/ccuport 配置生效
|
||||
|
||||
//初始化上位机通信
|
||||
m_upperCommManager = new UpperCommManager(this);
|
||||
@@ -195,6 +197,7 @@ bool PowerManger::OnStartUp()
|
||||
std::string logPath; // 日志文件路径,不配置则默认当前目录 pPowerManger.log
|
||||
std::string ccuHost; // CCU 地址,不配置则沿用默认值
|
||||
long ccuPort = 0; // CCU 端口,不配置则沿用默认值
|
||||
long localPort = 0; // 本地输入端口(CCU->pPowerManger),不配置则沿用默认值
|
||||
|
||||
STRING_LIST::iterator p;
|
||||
for(p=sParams.begin(); p!=sParams.end(); p++) {
|
||||
@@ -226,6 +229,10 @@ bool PowerManger::OnStartUp()
|
||||
ccuPort = atoi(value.c_str());
|
||||
handled = true;
|
||||
}
|
||||
else if(param == "iport") {
|
||||
localPort = atoi(value.c_str());
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if(!handled)
|
||||
reportUnhandledConfigWarning(orig);
|
||||
@@ -239,6 +246,9 @@ bool PowerManger::OnStartUp()
|
||||
loguru::add_file(logPath.c_str(), loguru::Append, loguru::Verbosity_MAX);
|
||||
LOG_F(INFO, "日志文件路径: %s", logPath.c_str());
|
||||
|
||||
// 网页日志监控使用同一文件(默认相对路径在 systemd WorkingDirectory 下打不开)
|
||||
if (m_httpServer) m_httpServer->setLogFilePath(logPath);
|
||||
|
||||
// 初始化数据库:dbpath 未配置则默认当前目录
|
||||
if (dbPath.empty()) dbPath = "power_data.db";
|
||||
LOG_F(INFO, "数据库存储路径: %s", dbPath.c_str());
|
||||
@@ -251,6 +261,17 @@ bool PowerManger::OnStartUp()
|
||||
m_lowerCommManager->setCcuAddress(m_ccuHost, m_ccuPort);
|
||||
LOG_F(INFO, "CCU 地址: %s:%ld", m_ccuHost.c_str(), m_ccuPort);
|
||||
|
||||
// 应用 moos 文件中的本地输入端口配置(接收 CCU 数据),须在 bind 前生效
|
||||
if (localPort > 0) m_localPort = localPort;
|
||||
m_lowerCommManager->setLocalPort(m_localPort);
|
||||
LOG_F(INFO, "本地输入端口(CCU->pPowerManger): %ld", m_localPort);
|
||||
|
||||
// 配置生效后再 bind 本地 UDP 输入端口
|
||||
if (!m_lowerCommManager->initUdpComm()) {
|
||||
LOG_F(ERROR, "UDP bind failed on port %ld", m_localPort);
|
||||
reportRunWarning("UDP bind failed on local input port");
|
||||
}
|
||||
|
||||
// 注入原始帧日志回调(RX/TX 双向落库)
|
||||
m_lowerCommManager->setCommLogger(m_db);
|
||||
// 数据库就绪后再启动下位机监听线程
|
||||
|
||||
@@ -125,6 +125,7 @@ class PowerManger : public AppCastingMOOSApp
|
||||
bool isDeviceCmdEmpty() const;
|
||||
//===========================CONFIG========================================
|
||||
long m_lPort;
|
||||
long m_localPort; // 本地输入端口(CCU->pPowerManger),moos 配置项 iport
|
||||
std::string m_ccuHost; // CCU 地址,moos 配置项 ccuhost
|
||||
long m_ccuPort; // CCU 端口,moos 配置项 ccuport
|
||||
unsigned int m_nReceiveBufferSizeKB;
|
||||
@@ -167,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);
|
||||
|
||||
@@ -278,34 +278,192 @@ body {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
/* 数值步进控件(替代滑块,防误拖) */
|
||||
.stepper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
color: #1a1a1a;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stepper-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.stepper-btn:active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stepper-input {
|
||||
width: 90px;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stepper-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.stepper-unit {
|
||||
min-width: 84px;
|
||||
color: #4b5563;
|
||||
font-size: 0.9rem;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* 下拉框危险选项标记 */
|
||||
.select-control option.danger {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 指令确认弹窗 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.55);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
padding: 16px 20px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-summary {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.summary-row.danger {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.summary-row.danger .summary-label,
|
||||
.summary-row.danger .summary-value {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.summary-danger-mark {
|
||||
margin-right: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-note.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex-grow: 1;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
.modal-actions .button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
.button-confirm {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
.button-confirm:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.form-submit {
|
||||
@@ -521,22 +679,24 @@ body {
|
||||
<option value="00H">无效</option>
|
||||
<option value="01H">自检</option>
|
||||
<option value="02H">启动</option>
|
||||
<option value="03H">停机</option>
|
||||
<option value="04H">复位</option>
|
||||
<option value="05H">紧急停机</option>
|
||||
<option value="06H">补给</option>
|
||||
<option value="07H">制氢预热</option>
|
||||
<option value="08H">停机维护</option>
|
||||
<option value="0AH">开机</option>
|
||||
<option value="0BH">关机</option>
|
||||
<option value="03H" class="danger">⚠ 停机(危险)</option>
|
||||
<option value="05H" class="danger">⚠ 紧急停机(危险)</option>
|
||||
<option value="08H" class="danger">⚠ 停机维护(危险)</option>
|
||||
<option value="0BH" class="danger">⚠ 关机(危险)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">输出功率指令 (0~250,分辨率0.1kW)</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" name="fcPower" min="0" max="250" value="0" step="1">
|
||||
<span class="slider-value">0 (0.0 kW)</span>
|
||||
<div class="stepper-container">
|
||||
<button type="button" class="stepper-btn" data-target="fcPower" data-step="-1">−</button>
|
||||
<input type="number" class="stepper-input" id="fcPower" name="fcPower" min="0" max="250" step="1" value="0">
|
||||
<button type="button" class="stepper-btn" data-target="fcPower" data-step="1">+</button>
|
||||
<span class="stepper-unit" id="fcPowerUnit">0 (0.0 kW)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -567,9 +727,11 @@ body {
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">潜深深度 (m)</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" name="depth" min="0" max="1000" value="0" step="1">
|
||||
<span class="slider-value">0 m</span>
|
||||
<div class="stepper-container">
|
||||
<button type="button" class="stepper-btn" data-target="depth" data-step="-1">−</button>
|
||||
<input type="number" class="stepper-input" id="depth" name="depth" min="0" max="1000" step="1" value="0">
|
||||
<button type="button" class="stepper-btn" data-target="depth" data-step="1">+</button>
|
||||
<span class="stepper-unit" id="depthUnit">0 m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -612,9 +774,11 @@ body {
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">动力锂电池功率配置值 (0~400 kW)</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" name="powerBatPower" min="0" max="400" value="0" step="10">
|
||||
<span class="slider-value">0 kW</span>
|
||||
<div class="stepper-container">
|
||||
<button type="button" class="stepper-btn" data-target="powerBatPower" data-step="-10">−</button>
|
||||
<input type="number" class="stepper-input" id="powerBatPower" name="powerBatPower" min="0" max="400" step="10" value="0">
|
||||
<button type="button" class="stepper-btn" data-target="powerBatPower" data-step="10">+</button>
|
||||
<span class="stepper-unit" id="powerBatPowerUnit">0 kW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -622,18 +786,34 @@ body {
|
||||
<label class="control-label">主机状态</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option"><input type="radio" name="hostStatus" value="AAH" checked><span>运行</span></label>
|
||||
<label class="radio-option"><input type="radio" name="hostStatus" value="FFH"><span>关机</span></label>
|
||||
<label class="radio-option"><input type="radio" name="hostStatus" value="55H"><span>故障</span></label>
|
||||
<label class="radio-option"><input type="radio" name="hostStatus" value="FFH"><span>⚠ 关机(危险)</span></label>
|
||||
<label class="radio-option"><input type="radio" name="hostStatus" value="55H"><span>⚠ 故障(危险)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button form-submit">提交操控指令</button>
|
||||
<button type="submit" class="button form-submit">预览并提交操控指令</button>
|
||||
<div id="statusMessage" class="status-message"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指令确认弹窗(防误操作,提交前拦截) -->
|
||||
<div class="modal-overlay" id="confirmOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-title">⚠ 指令确认</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-hint">请逐项核对下方指令摘要,红色条目为高风险指令。点击“确认执行”后指令将立即下发。</div>
|
||||
<div class="modal-summary" id="modalSummary"></div>
|
||||
<div class="modal-note" id="modalDangerNote">本次包含高风险指令(停机/紧急停机/停机维护/关机等),请再次确认!</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="button button-secondary" id="modalCancelBtn">取消</button>
|
||||
<button type="button" class="button button-confirm" id="modalConfirmBtn">确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
|
||||
@@ -793,32 +973,42 @@ function initWebSocket() {
|
||||
};
|
||||
}
|
||||
|
||||
// 更新滑块值显示
|
||||
document.querySelectorAll('.slider').forEach(slider => {
|
||||
const valueDisplay = slider.nextElementSibling;
|
||||
if (slider.name === 'fcPower') {
|
||||
valueDisplay.textContent = slider.value + ' (0.0 kW)';
|
||||
} else if (slider.name === 'depth') {
|
||||
valueDisplay.textContent = slider.value + ' m';
|
||||
} else if (slider.name === 'powerBatPower') {
|
||||
valueDisplay.textContent = slider.value + ' kW';
|
||||
} else {
|
||||
valueDisplay.textContent = slider.value;
|
||||
}
|
||||
// 数值步进控件(替代滑块,防误拖)
|
||||
const stepperUnitText = {
|
||||
fcPower: v => v + ' (' + (v * 0.1).toFixed(1) + ' kW)',
|
||||
depth: v => v + ' m',
|
||||
powerBatPower: v => v + ' kW'
|
||||
};
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
if (slider.name === 'fcPower') {
|
||||
valueDisplay.textContent = slider.value + ' (' + (slider.value * 0.1).toFixed(1) + ' kW)';
|
||||
} else if (slider.name === 'depth') {
|
||||
valueDisplay.textContent = slider.value + ' m';
|
||||
} else if (slider.name === 'powerBatPower') {
|
||||
valueDisplay.textContent = slider.value + ' kW';
|
||||
} else {
|
||||
valueDisplay.textContent = slider.value;
|
||||
}
|
||||
function clampStepper(input) {
|
||||
const min = parseInt(input.min, 10);
|
||||
const max = parseInt(input.max, 10);
|
||||
let v = parseInt(input.value, 10);
|
||||
if (isNaN(v)) v = isNaN(min) ? 0 : min;
|
||||
if (!isNaN(min)) v = Math.max(v, min);
|
||||
if (!isNaN(max)) v = Math.min(v, max);
|
||||
input.value = v;
|
||||
const unitFn = stepperUnitText[input.id];
|
||||
if (unitFn) {
|
||||
const unitEl = document.getElementById(input.id + 'Unit');
|
||||
if (unitEl) unitEl.textContent = unitFn(v);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.stepper-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const input = document.getElementById(btn.dataset.target);
|
||||
if (!input) return;
|
||||
input.value = (parseInt(input.value, 10) || 0) + parseInt(btn.dataset.step, 10);
|
||||
clampStepper(input);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.stepper-input').forEach(input => {
|
||||
input.addEventListener('input', () => clampStepper(input));
|
||||
input.addEventListener('blur', () => clampStepper(input));
|
||||
});
|
||||
|
||||
// 处理按钮组选择
|
||||
document.querySelectorAll('.button-group button').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -838,16 +1028,71 @@ document.querySelectorAll('.button-group button').forEach(button => {
|
||||
});
|
||||
});
|
||||
|
||||
// 处理表单提交
|
||||
document.getElementById('fcsForm').addEventListener('submit', async (e) => {
|
||||
// 指令文本映射(用于确认弹窗摘要)
|
||||
const FC_MODE_TEXT = { '00H': '无效', '01H': '调试', '02H': '自动', '03H': '补给/排放' };
|
||||
const FC_CMD_TEXT = { '00H': '无效', '01H': '自检', '02H': '启动', '03H': '停机', '04H': '复位', '05H': '紧急停机', '06H': '补给', '07H': '制氢预热', '08H': '停机维护', '0AH': '开机', '0BH': '关机' };
|
||||
const EMERGENCY_TEXT = { '00H': '无', '01H': '允许降载', '02H': '允许排气', '03H': '降载+排气' };
|
||||
const SUPPLY_TEXT = { '00H': '无效', '01H': '液氧加注', '02H': '甲醇加注', '03H': '液氧排空', '04H': '甲醇排空', '05H': 'CO2排空', '07H': '合金加注', '08H': '氮气加注', '09H': '纯水加注', '0AH': '备用', '0BH': '备用' };
|
||||
const BAT_CMD_TEXT = { '00H': '无效', '10H': '启动', '20H': '关闭' };
|
||||
const HOST_STATUS_TEXT = { 'AAH': '运行', 'FFH': '关机', '55H': '故障' };
|
||||
const DANGER_FC_CMDS = ['03H', '05H', '08H', '0BH'];
|
||||
const DANGER_HOST_STATUS = ['FFH', '55H'];
|
||||
|
||||
// 生成指令摘要并打开确认弹窗(提交前拦截,防误操作)
|
||||
function openConfirmModal() {
|
||||
const form = document.getElementById('fcsForm');
|
||||
const fv = name => form.elements[name].value;
|
||||
|
||||
const rows = [
|
||||
{ label: '模式设定', value: FC_MODE_TEXT[fv('fcMode')] || fv('fcMode') },
|
||||
{ label: '操控指令', value: FC_CMD_TEXT[fv('fcCommand')] || fv('fcCommand'), danger: DANGER_FC_CMDS.includes(fv('fcCommand')) },
|
||||
{ label: '输出功率指令', value: fv('fcPower') + ' (' + (fv('fcPower') * 0.1).toFixed(1) + ' kW)' },
|
||||
{ label: '纵倾姿态数据1', value: fv('pitch') },
|
||||
{ label: '横倾姿态数据1', value: fv('roll') },
|
||||
{ label: '应急允许', value: EMERGENCY_TEXT[fv('emergencyAllow')] || fv('emergencyAllow') },
|
||||
{ label: '潜深深度', value: fv('depth') + ' m' },
|
||||
{ label: '补给/排放指令', value: SUPPLY_TEXT[fv('supplyExhaustCmd')] || fv('supplyExhaustCmd') },
|
||||
{ label: '仪表锂电池启停指令', value: BAT_CMD_TEXT[fv('insBatCmd')] || fv('insBatCmd') },
|
||||
{ label: '动力锂电池启停指令', value: BAT_CMD_TEXT[fv('powerBatCmd')] || fv('powerBatCmd') },
|
||||
{ label: '动力锂电池功率配置值', value: fv('powerBatPower') + ' kW' },
|
||||
{ label: '主机状态', value: HOST_STATUS_TEXT[fv('hostStatus')] || fv('hostStatus'), danger: DANGER_HOST_STATUS.includes(fv('hostStatus')) }
|
||||
];
|
||||
|
||||
document.getElementById('modalSummary').innerHTML = rows.map(r =>
|
||||
'<div class="summary-row' + (r.danger ? ' danger' : '') + '">' +
|
||||
'<span class="summary-label">' + (r.danger ? '<span class="summary-danger-mark">⚠</span>' : '') + r.label + '</span>' +
|
||||
'<span class="summary-value">' + r.value + '</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
|
||||
const hasDanger = rows.some(r => r.danger);
|
||||
document.getElementById('modalDangerNote').className = 'modal-note' + (hasDanger ? ' visible' : '');
|
||||
|
||||
document.getElementById('confirmOverlay').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeConfirmModal() {
|
||||
document.getElementById('confirmOverlay').classList.remove('visible');
|
||||
}
|
||||
|
||||
// 提交按钮:先弹出确认框,不直接发送
|
||||
document.getElementById('fcsForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
openConfirmModal();
|
||||
});
|
||||
|
||||
// 取消:关闭弹窗,不做任何提交
|
||||
document.getElementById('modalCancelBtn').addEventListener('click', closeConfirmModal);
|
||||
|
||||
// 确认执行:真正下发指令
|
||||
document.getElementById('modalConfirmBtn').addEventListener('click', async () => {
|
||||
const form = document.getElementById('fcsForm');
|
||||
const statusMessage = document.getElementById('statusMessage');
|
||||
closeConfirmModal();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
for (const [key, value] of new FormData(form).entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
@@ -864,18 +1109,16 @@ document.getElementById('fcsForm').addEventListener('submit', async (e) => {
|
||||
if (result.success) {
|
||||
statusMessage.textContent = '操控指令提交成功';
|
||||
statusMessage.className = 'status-message';
|
||||
statusMessage.style.display = 'block';
|
||||
} else {
|
||||
statusMessage.textContent = result.message || '操控指令提交失败';
|
||||
statusMessage.className = 'status-message error';
|
||||
statusMessage.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
statusMessage.textContent = '网络错误: ' + error.message;
|
||||
statusMessage.className = 'status-message error';
|
||||
statusMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
statusMessage.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
statusMessage.style.display = 'none';
|
||||
}, 5000);
|
||||
|
||||
@@ -86,6 +86,11 @@ private:
|
||||
static const size_t MAX_CACHE_SIZE = 1000; // 最大缓存条数
|
||||
static const size_t MAX_WS_BACKLOG = 256 * 1024; // WebSocket发送积压阈值,超过则跳过推送
|
||||
|
||||
public:
|
||||
// 设置被监控的日志文件路径(须与 logpath 配置一致;
|
||||
// 默认相对路径在 systemd WorkingDirectory 下会打开失败)
|
||||
void setLogFilePath(const std::string& path) { m_logFilePath = path; }
|
||||
|
||||
};
|
||||
|
||||
#endif // HTTP_SERVER_H
|
||||
|
||||
@@ -11,4 +11,6 @@ ProcessConfig = pPowerManger
|
||||
// CCU 地址;不配置则默认 127.0.0.1:7000
|
||||
// ccuhost = 127.0.0.1
|
||||
// ccuport = 7000
|
||||
// 本地输入端口(接收 CCU 数据);不配置则默认 5001
|
||||
// iport = 5001
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user