新增复合管控器(pCCU) + 惯导数据解析 + 设备控制页增强 + 0824协议文档归档

- 新增 pCCU 复合管控器程序(src/pCCU):FC/PM 双链路(UDP)、协议编解码器
  (FcProtocol/PmProtocol/Frame/Message/MessageRegistry)、快照与落库(DbStore)、
  内置 WebServer;挂入 src/CMakeLists.txt 与板卡 mission(h100.moos)
- 惯导/深度数据:新增 uDevice_insData 消息解析,接入 UpperCommManager,
  pPowerMangerHost 注册监视,UpmsgTest 覆盖
- pPowerMangerHost 设备控制页增强(web/index.html)
- 部署/运维脚本改为 4 个 systemd 服务(moosdb/pPowerManger/pPowerMangerHost/pCCU):
  deploy/clean-data/fetch-data 同步适配
- 协议文档:归档 0824 控制主机与复合管控器通信协议 xlsx 及关联 docx,移除旧 md
- 测试:新增 pccu 单元/集成测试与 disSysTest Python 配电仿真
- 清理:移除未使用的 CCU 状态调试打印 coutMsg;.gitignore 补充编译产物/临时文件忽略
This commit is contained in:
zjk
2026-08-26 22:43:10 +08:00
parent e0488d513b
commit 54c32a180a
79 changed files with 8731 additions and 1398 deletions
+132
View File
@@ -0,0 +1,132 @@
#include "WebServer.h"
#include "pages/index.h"
#include <iostream>
namespace ccu {
//---------------------------------------------------------
// 事件处理: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) {
std::lock_guard<std::mutex> lock(m_wsMutex);
m_wsConnections.push_back(c);
if (m_onOpen) {
std::string welcome = m_onOpen();
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) {
std::lock_guard<std::mutex> lock(m_wsMutex);
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) {
// 纯监控,忽略客户端消息
}
}
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 << "pCCU 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();
std::lock_guard<std::mutex> lock(m_wsMutex);
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);
}
return true;
}
void WebServer::broadcast(const std::string& text) {
std::lock_guard<std::mutex> lock(m_wsMutex);
for (auto c : m_wsConnections) {
mg_ws_send(c, text.c_str(), text.size(), WEBSOCKET_OP_TEXT);
}
}
} // namespace ccu
+61
View File
@@ -0,0 +1,61 @@
#ifndef PCCU_WEB_SERVER_H
#define PCCU_WEB_SERVER_H
#define UNIX
#include "mongoose.h"
#include <string>
#include <functional>
#include <vector>
#include <mutex>
#include <atomic>
#include <thread>
namespace ccu {
//============================================================================
// WebServer:HTTP + WebSocket 监控服务器(基于 mongoose)。
//
// 路由:
// / -> 内嵌监控页面(纯展示)
// /ws -> WebSocket,推送 JSON 快照(由 CCU 周期调用 broadcast)
// /api/logs -> 最近原始帧日志(JSON)
// 采用与仓库内 pPowerMangerHost/WebServer 一致的回调模式。
//============================================================================
class WebServer {
public:
WebServer();
~WebServer();
bool start(int port);
void stop();
bool isRunning() const { return m_running; }
// 向所有 WebSocket 客户端广播文本
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);
struct mg_mgr m_mgr;
int m_port = 0;
std::atomic<bool> m_running{false};
std::thread m_thread;
std::mutex m_wsMutex;
std::vector<mg_connection*> m_wsConnections;
std::function<std::string()> m_onOpen;
std::function<std::string(const std::string&, const std::string&)> m_apiHandler;
};
} // namespace ccu
#endif // PCCU_WEB_SERVER_H
+490
View File
@@ -0,0 +1,490 @@
#ifndef PCCU_PAGE_INDEX_H
#define PCCU_PAGE_INDEX_H
#include <string>
//============================================================================
// pCCU 监控页面(纯前端,内嵌 HTML)。
//
// - 通过 WebSocket /ws 接收服务器推送的 JSON 快照(每秒)
// - 每条消息一个标签页(接收/发送/系统分组),数据按消息归类展示
// - 故障码/故障等级突出高亮;每条协议数据单独一行
// - 纯监控展示,不提供反向控制
//============================================================================
namespace ccu {
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>pCCU 复合管控器监控</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);}
.raw{font-family:Consolas,monospace;font-size:11px;color:var(--dim);word-break:break-all;max-height:200px;overflow-y:auto;}
.raw-group{margin-bottom:12px;border:1px solid var(--line);border-radius:6px;padding:8px;}
.raw-group:last-child{margin-bottom:0;}
.raw-group-hdr{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;}
.raw-id{font-family:Consolas,monospace;color:#79c0ff;font-weight:700;}
.raw-name{color:var(--fg);font-weight:600;}
.raw-count{color:var(--dim);font-size:11px;margin-left:auto;}
table{width:100%;border-collapse:collapse;font-size:12px;}
th,td{text-align:left;padding:4px 6px;border-bottom:1px solid #222c3d;}
th{color:var(--dim);font-weight:500;}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;}
.dot-ok{background:var(--ok);} .dot-warn{background:var(--warn);} .dot-bad{background:var(--bad);}
#status{color:var(--dim);font-size:12px;}
.tabs{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px;align-items:center;}
.tab-group{display:flex;align-items:center;gap:6px;padding-right:16px;margin-right:16px;border-right:1px solid var(--line);}
.tab-group:last-child{border-right:none;margin-right:0;padding-right:0;}
.tg-label{font-size:11px;color:var(--dim);font-weight:700;letter-spacing:.5px;}
.tab{padding:6px 14px;border:1px solid var(--line);border-radius:16px;background:var(--card);color:var(--dim);cursor:pointer;font-size:12px;transition:all .15s;}
.tab:hover{color:var(--fg);border-color:#3b4a63;}
.tab.active{background:#1f6feb;border-color:#1f6feb;color:#fff;font-weight:600;}
.page-hdr{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:10px 14px;background:#141b29;border:1px solid var(--line);border-radius:8px;font-size:14px;}
/* 故障等级高亮 */
.fault-lvl{display:inline-block;padding:3px 12px;border-radius:6px;font-weight:700;font-size:13px;letter-spacing:.5px;}
.lvl0{background:rgba(46,160,67,.15);color:#3fb950;border:1px solid rgba(46,160,67,.45);}
.lvl1{background:rgba(210,153,34,.15);color:#d29922;border:1px solid rgba(210,153,34,.45);}
.lvl2{background:rgba(219,97,20,.18);color:#e3841d;border:1px solid rgba(219,97,20,.55);}
.lvl3{background:rgba(248,81,73,.22);color:#f85149;border:1px solid rgba(248,81,73,.55);}
.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;}
</style>
</head>
<body>
<h1>⚡ pCCU 复合管控器监控 <span id="status" class="badge">连接中...</span></h1>
<div class="tabs">
<div class="tab-group"><span class="tg-label">接收</span>
<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>
</div>
<div class="tab-group"><span class="tg-label">发送</span>
<button class="tab" data-tab="fcCmd">FC控制指令</button>
<button class="tab" data-tab="pmStatus">PM状态报文</button>
<button class="tab" data-tab="pmFb">PM参数反馈</button>
</div>
<div class="tab-group"><span class="tg-label">系统</span>
<button class="tab" data-tab="links">链路状态</button>
<button class="tab" data-tab="raw">原始报文</button>
</div>
</div>
<div id="pages">加载中...</div>
<script>
const $ = id => document.getElementById(id);
const pagesEl = $('pages');
const statusEl = $('status');
const TABS = ['fcStatus','pmCmd','pmParam','fcCmd','pmStatus','pmFb','links','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 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'); }
//------------------ 指令码解析 ------------------
function modeText(m){ return {0:'无效',1:'调试',2:'自动',3:'补给/排放'}[m] || ('未知('+hex(m)+')'); }
function cmdText(c){
const map={0x00:'无效',0x01:'自检',0x02:'启动',0x03:'停机',0x04:'复位',0x05:'紧急停机',
0x06:'补给',0x07:'制氢预热',0x08:'停机维护',0x0A:'开机',0x0B:'关机'};
return map[c] || ('未知('+hex(c)+')');
}
function batCmdText(b){ return {0x00:'无效',0x10:'启动',0x20:'关闭'}[b] || ('未知('+hex(b)+')'); }
function hostStateText(s){ return s===0xAA?'运行':s===0xFF?'关机':'故障'; }
function supplyCmdText(s){
const map={0x00:'无效',0x01:'液氧加注',0x02:'甲醇加注',0x03:'液氧排空',0x04:'甲醇排空',
0x05:'CO2排空',0x07:'合金加注',0x08:'氮气加注',0x09:'纯水加注'};
return map[s] || ('未知('+hex(s)+')');
}
function emerAllowText(v){
const bits=[];
if(v & 0x01) bits.push('允许降载');
if(v & 0x02) bits.push('允许排气');
return bits.length ? bits.join('+') : '无';
}
function statusText(s){
const m={1:'初始化',2:'待机',3:'自检异常',4:'就绪',5:'启动',6:'运行',7:'停机',8:'故障',
9:'补给',10:'制氢预热',11:'制氢预热完成',12:'停机维护',13:'维护完成'};
return m[s] || ('未知('+hex(s)+')');
}
function flameText(f){ return {0:'无效',1:'火焰报警',2:'探测器故障'}[f] || ('未知('+hex(f)+')'); }
// 带解析的值:hex 码 + 括号解析
function parsed(v, text){ return hex(v)+' ('+text+')'; }
// 故障码高亮:0 显示绿色正常,非 0 显示红色
function faultHex(c){ return c===0 ? '<span class="fc-ok">'+hex16(c)+' 正常</span>'
: '<span class="fc-bad">'+hex16(c)+'</span>'; }
// 故障等级高亮徽标
function faultLvlBadge(level){
const names=['无故障','一级故障','二级故障','三级故障','四级故障'];
const n = names[level] || ('未知('+level+')');
const cls = ['lvl0','lvl1','lvl2','lvl3','lvl4'][level] || 'lvl4';
return '<span class="fault-lvl '+cls+'">'+n+'</span>';
}
function card(title, html){ return '<div class="card"><h2>'+title+'</h2>'+html+'</div>'; }
//------------------ FC 状态反馈(0x0002) 卡片 ------------------
function fcCard(d){
if(!d) return '';
let h='';
h+=row('运行模式', modeText(d.fc_mode));
h+=row('运行状态', statusText(d.fc_status));
h+=row('系统故障等级', faultLvlBadge(d.fc_fault_level));
h+=row('一级故障码', faultHex(d.fault_level_1));
h+=row('二级故障码', faultHex(d.fault_level_2));
h+=row('三级故障码', faultHex(d.fault_level_3));
h+=row('四级故障码', faultHex(d.fault_level_4));
h+=row('累积发电时间', (d.total_generation_time*0.1).toFixed(1)+' h');
h+=row('系统发电功率', (d.generation_power*0.01).toFixed(2)+' kW');
h+=row('储氢剩余容量', d.hydrogen_capacity+'%');
h+=row('液氧剩余容量', d.liquid_oxygen_capacity+'%');
h+=row('通信心跳', d.heartbeat);
h+=row('应急指令', hex(d.emergency_cmd));
return card('燃料电池运行', h);
}
function hydroCard(d){
if(!d) return '';
let h='';
h+=row('钯膜最高温度', (d.palladium_temp*0.1).toFixed(1)+' ℃');
h+=row('缓冲罐压力', (d.buffer_tank_pressure*0.1).toFixed(1)+' kPa');
h+=row('烟气累计排放量', (d.flue_total_emission*0.1).toFixed(1)+' kg');
h+=row('烟气压力', (d.flue_pressure*0.001).toFixed(3)+' MPa');
h+=row('反应器压力', (d.reactor_pressure*0.001).toFixed(3)+' MPa');
h+=row('电动阀开度', (d.electric_valve_open*0.1).toFixed(1)+'%');
h+=row('甲醇累计使用量', (d.methanol_total_use*0.1).toFixed(1)+' kg');
h+=row('甲醇溶液进料量', d.methanol_feed+' mL/min');
h+=row('I#FC最低单片电压', (d.fc1_min_cell_voltage*10)+' mV (位置'+d.fc1_min_cell_pos+')');
h+=row('I#FC平均单片电压', (d.fc1_avg_cell_voltage*10)+' mV');
h+=row('2#FC最低单片电压', (d.fc2_min_cell_voltage*10)+' mV (位置'+d.fc2_min_cell_pos+')');
h+=row('2#FC平均单片电压', (d.fc2_avg_cell_voltage*10)+' mV');
return card('甲醇制氢装置', h);
}
function dcdcCard(d){
if(!d) return '';
let h='';
h+=row('通道1输入电压', (d.dcdc1_in_voltage*0.1).toFixed(1)+' V');
h+=row('通道1输入电流', (d.dcdc1_in_current*0.1).toFixed(1)+' A');
h+=row('通道2输入电压', (d.dcdc2_in_voltage*0.1).toFixed(1)+' V');
h+=row('通道2输入电流', (d.dcdc2_in_current*0.1).toFixed(1)+' A');
h+=row('输出电压', (d.dcdc_out_voltage*0.1).toFixed(1)+' V');
h+=row('输出电流', (d.dcdc_out_current*0.1).toFixed(1)+' A');
h+=row('控制电源电压', (d.dcdc_ctrl_voltage*0.25).toFixed(2)+' V');
h+=row('辅电输出电压', (d.dcdc_aux_voltage*0.125).toFixed(3)+' V');
return card('DC/DC', h);
}
function exhaustCard(d){
if(!d) return '';
let h='';
h+=row('进气压力', (d.exhaust_inlet_pressure*0.01).toFixed(2)+' MPa');
h+=row('排气压力', (d.exhaust_outlet_pressure*0.01).toFixed(2)+' MPa');
h+=row('运行频率', (d.exhaust_run_freq*0.01).toFixed(2)+' Hz');
h+=row('进气温度', (d.exhaust_inlet_temp*0.01).toFixed(2)+' ℃');
h+=row('排气温度', (d.exhaust_outlet_temp*0.01).toFixed(2)+' ℃');
h+=row('进水压力', (d.exhaust_water_in_pressure*0.01).toFixed(2)+' kPa');
h+=row('排水压力', (d.exhaust_water_out_pressure*0.01).toFixed(2)+' kPa');
return card('尾气装置', h);
}
function tankCard(d){
if(!d) return '';
let h='';
h+=row('液氧罐压力', (d.tank_lo2_pressure*0.01).toFixed(2)+' MPa');
h+=row('二氧化碳压力', (d.tank_co2_pressure*0.01).toFixed(2)+' MPa');
h+=row('液氧罐液位', (d.tank_lo2_level*0.01).toFixed(2)+' mm');
h+=row('合金供氢流量', (d.alloy_h2_flow*0.01).toFixed(2)+' L/min');
h+=row('FC供氢流量', (d.fc_h2_flow*0.01).toFixed(2)+' L/min');
h+=row('FC供氧流量', (d.fc_o2_flow*0.01).toFixed(2)+' L/min');
h+=row('应急上浮深度', d.emergency_float_depth+' m');
h+=row('应急上浮时间', d.emergency_float_time+' min');
return card('一体化罐装置', h);
}
function cabinCard(d){
if(!d) return '';
let h='';
h+=row('舱室压力1', (d.cabin_pressure1*0.01).toFixed(2)+' kPa');
h+=row('舱室压力2', (d.cabin_pressure2*0.01).toFixed(2)+' kPa');
h+=row('舱室温度1', (d.cabin_temp1*0.01).toFixed(2)+' ℃');
h+=row('舱室温度2', (d.cabin_temp2*0.01).toFixed(2)+' ℃');
h+=row('舱室湿度1', (d.cabin_humidity1*0.01).toFixed(2)+' %RH');
h+=row('舱室湿度2', (d.cabin_humidity2*0.01).toFixed(2)+' %RH');
h+=row('舱室H2浓度1', (d.h2_concentration1*0.01).toFixed(2)+' %LEL');
h+=row('舱室H2浓度2', (d.h2_concentration2*0.01).toFixed(2)+' %LEL');
h+=row('舱室H2浓度3', (d.h2_concentration3*0.01).toFixed(2)+' %LEL');
h+=row('舱室O2浓度1', (d.o2_concentration1*0.01).toFixed(2)+' %Vol');
h+=row('舱室O2浓度2', (d.o2_concentration2*0.01).toFixed(2)+' %Vol');
h+=row('舱室甲醇浓度1', (d.ch3oh_concentration1*0.01).toFixed(2)+' %LEL');
h+=row('舱室甲醇浓度2', (d.ch3oh_concentration2*0.01).toFixed(2)+' %LEL');
h+=row('火焰探测器1', flameText(d.flame_detector1));
h+=row('火焰探测器2', flameText(d.flame_detector2));
return card('舱室环境', h);
}
//------------------ PM 状态报文(0x0004) 的锂电池 / 应急电池卡片 ------------------
function batteryCard(d){
if(!d) return '';
let h='';
h+=row('仪表电池SOC', d.ins_soc+'%');
h+=row('仪表电池总能量', (d.ins_total_energy*0.1).toFixed(1)+' kWh');
h+=row('动力电池SOC', d.dyn_soc+'%');
h+=row('动力电池总能量', (d.dyn_total_energy*0.1).toFixed(1)+' kWh');
h+=row('仪表LINK电压', (d.ins_voltage_link*0.01).toFixed(2)+' V');
h+=row('仪表PACK电压', (d.ins_voltage_pack*0.01).toFixed(2)+' V');
h+=row('动力LINK电压', (d.dyn_voltage_link*0.01).toFixed(2)+' V');
h+=row('动力PACK电压', (d.dyn_voltage_pack*0.01).toFixed(2)+' V');
h+=row('仪表电流', (d.ins_current*0.05).toFixed(2)+' A');
h+=row('动力电流', (d.dyn_current*0.05).toFixed(2)+' A');
h+=row('仪表充电状态', hex(d.ins_charge_status));
h+=row('动力充电状态', hex(d.dyn_charge_status));
h+=row('仪表紧急状态', hex(d.ins_emergency_status));
h+=row('动力紧急状态', hex(d.dyn_emergency_status));
h+=row('仪表最高放电功率', (d.ins_max_discharge_power*0.05).toFixed(2)+' kW');
h+=row('动力最高放电功率', (d.dyn_max_discharge_power*0.05).toFixed(2)+' kW');
return card('锂电池', h);
}
function emergCard(d){
if(!d) return '';
let h='';
h+=row('应急电池1总电压', (d.emergency_battery1_voltage*0.1).toFixed(1)+' V');
h+=row('应急电池1总电流', (d.emergency_battery1_current*0.05).toFixed(2)+' A');
h+=row('应急电池1最高温度', (d.emergency_battery1_max_temp*0.1).toFixed(1)+' ℃');
h+=row('应急电池1故障字', hex(d.emergency_battery1_fault_word));
h+=row('应急电池2总电压', (d.emergency_battery2_voltage*0.1).toFixed(1)+' V');
h+=row('应急电池2总电流', (d.emergency_battery2_current*0.05).toFixed(2)+' A');
h+=row('应急电池2最高温度', (d.emergency_battery2_max_temp*0.1).toFixed(1)+' ℃');
h+=row('应急电池2故障字', hex(d.emergency_battery2_fault_word));
return card('应急电池', h);
}
function linkCard(s){
if(!s || !s.links) return '';
const l = s.links;
const online = (t)=> (Date.now()/1000 - t) < 10;
let h='';
h+=row('<span><span class="status-dot '+dot(online(l.fc.lastRx))+'"></span>FC 链路</span>',
'收:'+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);
h+=row('FC 状态接收计数', s.fcStatusCount);
h+=row('PM 指令接收计数', s.pmControlCount);
h+=row('FC 控制转发计数', s.fcControlCount);
h+=row('PM 状态发送计数', s.pmStatusCount);
return card('链路状态', h);
}
//------------------ 控制指令卡片(代码 + 解析) ------------------
function cmdCard(d){
if(!d) return '';
let h='';
h+=row('系统时间', d.year+'/'+d.month+'/'+d.day+' '+d.hour+':'+d.minute+':'+d.second);
h+=row('模式设定', parsed(d.mode, modeText(d.mode)));
h+=row('操控指令', parsed(d.cmd, cmdText(d.cmd)));
h+=row('输出功率指令', d.outputPower+' (×0.1kW)');
h+=row('纵倾姿态', (d.pitch*0.1).toFixed(1)+'°');
h+=row('横倾姿态', (d.roll*0.1).toFixed(1)+'°');
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('通信心跳', d.heartbeat);
h+=row('主机状态', parsed(d.hostState, hostStateText(d.hostState)));
return card('指令内容', h);
}
function fcCmdCard(d){
if(!d) return '';
let h='';
h+=row('模式设定', parsed(d.mode, modeText(d.mode)));
h+=row('操控指令', parsed(d.cmd, cmdText(d.cmd)));
h+=row('输出功率指令', d.outputPower+' (×0.1kW)');
h+=row('纵倾姿态', (d.pitch*0.1).toFixed(1)+'°');
h+=row('横倾姿态', (d.roll*0.1).toFixed(1)+'°');
h+=row('应急允许', parsed(d.emergencyAllow, emerAllowText(d.emergencyAllow)));
h+=row('潜深深度', d.depth+' m');
h+=row('补给/排放指令', parsed(d.supplyCmd, supplyCmdText(d.supplyCmd)));
h+=row('预留指令5', hex(d.reservedCmd5));
h+=row('预留指令6', hex(d.reservedCmd6));
h+=row('通信心跳', d.heartbeat);
return card('指令内容', h);
}
function pmParamCard(d){
if(!d) return '';
let h='';
h+=row('仪表SOC门限1', d.insSocHold1);
h+=row('仪表SOC门限2', d.insSocHold2);
h+=row('仪表SOC门限3', d.insSocHold3);
h+=row('仪表输出功率', d.insOutputPower+'%');
h+=row('动力SOC门限1', d.dynSocHold1);
h+=row('动力SOC门限2', d.dynSocHold2);
h+=row('动力SOC门限3', d.dynSocHold3);
h+=row('动力输出功率', d.dynOutputPower+'%');
return card('门限参数', h);
}
function pmFbCard(d){
if(!d) return '';
const flagMap = {0x00:'无效', 0x10:'设定成功', 0x20:'设定失败'};
let h='';
h+=row('设定标志', (flagMap[d.flag]||hex(d.flag)));
h+=row('失败原因', hex(d.failCode));
return card('设定结果', h);
}
function pmStatusCard(d){
if(!d) return '';
let h='';
h+=row('运行模式', modeText(d.fc_mode));
h+=row('运行状态', statusText(d.fc_status));
h+=row('系统故障等级', faultLvlBadge(d.fc_fault_level));
h+=row('一级故障码', faultHex(d.fault_level_1));
h+=row('二级故障码', faultHex(d.fault_level_2));
h+=row('三级故障码', faultHex(d.fault_level_3));
h+=row('四级故障码', faultHex(d.fault_level_4));
h+=row('系统输出功率限定', d.output_power_limit+' W');
h+=row('系统发电功率', (d.generation_power*0.01).toFixed(2)+' kW');
h+=row('储氢剩余容量', d.hydrogen_capacity+'%');
h+=row('液氧剩余容量', d.liquid_oxygen_capacity+'%');
h+=row('钯膜最高温度', (d.palladium_temp*0.1).toFixed(1)+' ℃');
h+=row('缓冲罐压力', (d.buffer_tank_pressure*0.1).toFixed(1)+' kPa');
h+=row('主水路盘管压力', (d.main_pipe_pressure*0.01).toFixed(2)+' kPa');
h+=row('辅水路盘管压力', (d.aux_pipe_pressure*0.01).toFixed(2)+' kPa');
h+=row('DC/DC输出电压', (d.dcdc_out_voltage*0.1).toFixed(1)+' V');
h+=row('DC/DC输出电流', (d.dcdc_out_current*0.1).toFixed(1)+' A');
h+=row('甲醇累计使用量', (d.methanol_total_use*0.1).toFixed(1)+' kg');
h+=row('甲醇溶液进料量', d.methanol_feed+' mL/min');
h+=row('舱室温度1', (d.cabin_temp1*0.01).toFixed(2)+' ℃');
h+=row('舱室温度2', (d.cabin_temp2*0.01).toFixed(2)+' ℃');
h+=row('舱室H2浓度1', (d.h2_concentration1*0.01).toFixed(2)+' %LEL');
h+=row('舱室O2浓度1', (d.o2_concentration1*0.01).toFixed(2)+' %Vol');
h+=row('仪表电池SOC', d.ins_soc+'%');
h+=row('动力电池SOC', d.dyn_soc+'%');
h+=row('仪表LINK电压', (d.ins_voltage_link*0.01).toFixed(2)+' V');
h+=row('动力LINK电压', (d.dyn_voltage_link*0.01).toFixed(2)+' V');
h+=row('仪表电流', (d.ins_current*0.05).toFixed(2)+' A');
h+=row('动力电流', (d.dyn_current*0.05).toFixed(2)+' A');
h+=row('设备在线标志字1', hex32(d.device_online_flag1));
h+=row('设备在线标志字2', hex32(d.device_online_flag2));
h+=row('通信心跳', d.heartbeat);
h+=row('应急指令', hex(d.emergency_cmd));
return card('整合后状态', h);
}
//------------------ 标签页头部 ------------------
function pageHeader(id, name, dir){
const idHex = '0x'+id.toString(16).toUpperCase().padStart(4,'0');
const isTx = dir.indexOf('CCU→')===0;
return '<div class="page-hdr"><span class="raw-id">'+idHex+'</span> '+
'<span class="raw-name">'+name+'</span> '+
'<span class="badge '+(isTx?'warn':'ok')+'">'+dir+'</span></div>';
}
function rawCard(list){
if(!list || !list.length) return '<div class="card"><h2>原始报文 · 按消息ID分组</h2><div class="raw">暂无</div></div>';
const groups = {};
for(const r of list){
if(!groups[r.msgId]) groups[r.msgId] = { rows: [] };
groups[r.msgId].rows.push(r);
}
let html = '<div class="card"><h2>原始报文 · 按消息ID分组(最近 '+list.length+' 条)</h2>';
for(const id in groups){
const g = groups[id];
const first = g.rows[0];
const idHex = '0x'+Number(id).toString(16).toUpperCase().padStart(4,'0');
const name = first.name || '';
const dirText = first.dirText || (first.direction===0?'接收':'发送');
html += '<div class="raw-group">';
html += '<div class="raw-group-hdr"><span class="raw-id">'+idHex+'</span> '+
'<span class="raw-name">'+name+'</span> '+
'<span class="badge '+(first.direction===0?'ok':'warn')+'">'+dirText+'</span> '+
'<span class="raw-count">'+g.rows.length+' 帧</span></div>';
html += '<table><tr><th>时间</th><th>方向</th><th>校验</th><th>帧数据</th></tr>';
for(const r of g.rows){
const t=new Date(r.time*1000).toLocaleTimeString();
const dText = r.dirText || (r.direction===0?'接收':'发送');
const dCls = r.direction===0?'ok':'warn';
html += '<tr><td>'+t+'</td>'+
'<td><span class="badge '+dCls+'">'+dText+'</span></td>'+
'<td>'+(r.checksumOk?'✓':'✗')+'</td>'+
'<td class="raw">'+r.hex+'</td></tr>';
}
html += '</table></div>';
}
html += '</div>';
return html;
}
function render(snap){
const pages = {
// 0x0002 FC 状态反馈(FC→CCU):仅含 FC 消息字段
fcStatus: pageHeader(0x0002,'FC状态反馈','FC→CCU')+'<div class="grid">'+
fcCard(snap.fc)+hydroCard(snap.fc)+dcdcCard(snap.fc)+exhaustCard(snap.fc)
+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>',
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>',
raw: rawCard(snap.logs)
};
let html = '';
for (const t of TABS) {
html += '<div class="page" id="page-'+t+'"'+(t===activeTab?'':' style="display:none"')+'>'+pages[t]+'</div>';
}
pagesEl.innerHTML = html;
}
// 标签页切换
document.addEventListener('click', function(e){
const t = e.target;
const btn = (t && t.closest) ? t.closest('.tab') : null;
if (!btn) return;
activeTab = btn.getAttribute('data-tab');
document.querySelectorAll('.tab').forEach(function(b){ b.classList.toggle('active', b===btn); });
document.querySelectorAll('.page').forEach(function(p){ p.style.display = (p.id==='page-'+activeTab)?'':'none'; });
});
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 ccu
#endif // PCCU_PAGE_INDEX_H