diff --git a/src/pELoad/ELoad.cpp b/src/pELoad/ELoad.cpp index 1a74e4c..9420ac4 100644 --- a/src/pELoad/ELoad.cpp +++ b/src/pELoad/ELoad.cpp @@ -215,7 +215,7 @@ bool ELoad::Iterate() { double now = MOOSTime(); - // 状态发布 1Hz:ELOAD_STATUS(JSON) + ELOAD_MEAS_POWER(double) + // MOOS 状态发布 1Hz:ELOAD_STATUS(JSON) + ELOAD_MEAS_POWER(double) static double lastPub = 0; if (now - lastPub >= 1.0) { lastPub = now; @@ -226,8 +226,13 @@ bool ELoad::Iterate() { m_Comms.Notify("ELOAD_MEAS_POWER", m_measP); ++m_pubCount; } - if (m_web) - m_web->broadcast(snap); + } + + // 网页 WebSocket 快照推送 5Hz(实时曲线需要较密的点;MOOS 发布仍为 1Hz) + static double lastWebPush = 0; + if (m_web && now - lastWebPush >= 0.2) { + lastWebPush = now; + m_web->broadcast(buildSnapshot()); } AppCastingMOOSApp::PostReport(); @@ -462,6 +467,30 @@ void ELoad::processWebCmds() { LOG_F(INFO, "[ELoad/web] current limit = %.4f A", c.value); } break; + case WCMD_FUNC_MODE: + { + // 功能模式切换 FUNC:MODE(FIX=实时控制;LIST/BATT/SOLAR/CAR=波形自主运行) + // 显示更新完全依赖设备真实回读:下发成功后立即触发一次 FUNC:MODE? 回读确认。 + bool ok = false; + if (m_scpi.isConnected()) { + std::string cmd = "FUNC:MODE " + c.text; + // 功能模式切换为低频操作:cmdConfirmed 超时给足, + // 避免 IO 线程忙(查询排队)时误判发送失败 + ok = m_scpi.cmdConfirmed(cmd, 1000); + } + { + std::lock_guard lock(m_stateMutex); + m_funcModeFeed = c.text; + m_funcModeFeedOk = ok; + m_funcModeFeedTime = MOOSTime(false); + } + if (ok) { + // 下发成功:立即异步回读设备实际功能模式(真实反馈驱动显示) + requestFuncModeReadback(); + } + LOG_F(INFO, "[ELoad/web] FUNC:MODE -> %s : %s", c.text.c_str(), ok ? "ok" : "fail"); + } + break; default: break; } @@ -715,6 +744,30 @@ bool parseScpiDouble(const std::string& r, double& v) { v = d; return true; } + +// 清理 SCPI 字符串响应:去首尾空白与引号(如 `"LIST"` / `LIST ` -> `LIST`) +std::string trimScpiStr(const std::string& s) { + size_t b = s.find_first_not_of(" \t\r\n\""); + if (b == std::string::npos) return ""; + size_t e = s.find_last_not_of(" \t\r\n\""); + return s.substr(b, e - b + 1); +} + +// 规范化 SCPI 模式名:兼容长/短格式与大小写(设备回读可能返回 +// Fixed / LIST / BATTery / SOLar / CARProfile 等长格式)。 +// 返回标准名:FIX / LIST / BATT / SOLAR / CAR;无法识别返回大写原文。 +std::string normFuncMode(const std::string& m) { + std::string u; + u.reserve(m.size()); + for (size_t i = 0; i < m.size(); ++i) + u += static_cast(::toupper(static_cast(m[i]))); + if (u.rfind("FIX", 0) == 0) return "FIX"; + if (u.rfind("LIST", 0) == 0) return "LIST"; + if (u.rfind("BATT", 0) == 0) return "BATT"; + if (u.rfind("SOLAR", 0) == 0) return "SOLAR"; + if (u.rfind("CAR", 0) == 0) return "CAR"; + return u; +} } // namespace void ELoad::pollDevice() { @@ -766,18 +819,21 @@ void ELoad::pollError(double now) { switch (type) { case 0: m_scpi.queryAsync("FUNC?", [this](bool ok, const std::string& r) { - if (ok) { std::lock_guard lock(m_stateMutex); m_devFunc = r; } + if (!ok) return; + std::string clean = trimScpiStr(r); + if (clean.empty()) return; + // 兼容 CURRent/VOLTage 长格式 -> CURR/VOLT + std::string u; + for (size_t i = 0; i < clean.size(); ++i) + u += static_cast(::toupper(static_cast(clean[i]))); + if (u.rfind("CURR", 0) == 0) clean = "CURR"; + else if (u.rfind("VOLT", 0) == 0) clean = "VOLT"; + std::lock_guard lock(m_stateMutex); + m_devFunc = clean; }); break; case 1: - m_scpi.queryAsync("FUNC:MODE?", [this](bool ok, const std::string& r) { - if (!ok) return; - std::lock_guard lock(m_stateMutex); - m_devFuncMode = r; - bool warn = (r != "FIX"); - m_funcModeFixWarn = warn; - if (warn) LOG_F(WARNING, "[ELoad] FUNC:MODE = %s(非 FIX,实时功率控制失效!)", r.c_str()); - }); + requestFuncModeReadback(); break; default: m_scpi.queryAsync("SYST:ERR?", [this](bool ok, const std::string& r) { @@ -792,6 +848,24 @@ void ELoad::pollError(double now) { } } +//--------------------------------------------------------- +// requestFuncModeReadback:异步回读 FUNC:MODE?,用设备真实反馈更新显示。 +// 周期回读(pollError)与下发后立即确认共用。 + +void ELoad::requestFuncModeReadback() { + if (!m_scpi.isConnected()) return; + m_scpi.queryAsync("FUNC:MODE?", [this](bool ok, const std::string& r) { + if (!ok) return; + std::string clean = normFuncMode(trimScpiStr(r)); // 兼容 Fixed/LIST/... 长格式 + if (clean.empty()) return; + std::lock_guard lock(m_stateMutex); + m_devFuncMode = clean; + bool warn = (clean != "FIX"); + m_funcModeFixWarn = warn; + if (warn) LOG_F(WARNING, "[ELoad] FUNC:MODE = %s(非 FIX,实时功率控制失效!)", clean.c_str()); + }); +} + //--------------------------------------------------------- // dbPeriodic:状态落库(1Hz)+ 高采样测量落库 + 历史清理 @@ -915,6 +989,10 @@ std::string ELoad::buildSnapshot() { dev["funcMode"] = m_devFuncMode; dev["funcModeFixWarn"] = m_funcModeFixWarn ? 1 : 0; dev["funcModeValid"] = m_devFuncMode.empty() ? 0 : 1; + dev["funcModeFeed"] = m_funcModeFeed; + dev["funcModeFeedOk"] = m_funcModeFeedOk ? 1 : 0; + dev["funcModeFeedTime"]= m_funcModeFeedTime; + dev["funcModeFeedValid"] = m_funcModeFeedTime > 0 ? 1 : 0; dev["remoteState"] = m_remoteState; // remote / local / unknown dev["remoteFeed"] = m_remoteFeed; // 最近远控/本控指令执行结果 dev["remoteFeedTime"] = m_remoteFeedTime; @@ -1138,6 +1216,19 @@ bool ELoad::queueWebCmd(WebCmdType t, double v, std::string& err) { return true; } +bool ELoad::queueWebCmd(WebCmdType t, const std::string& text, std::string& err) { + std::lock_guard lock(m_stateMutex); + if (m_webCmdQueue.size() >= 16) { + err = "queue full"; + return false; + } + WebCmd c; + c.type = t; + c.text = text; + m_webCmdQueue.push_back(c); + return true; +} + //--------------------------------------------------------- // handleCmdApi:/api/eload_cmd // @@ -1145,9 +1236,10 @@ bool ELoad::queueWebCmd(WebCmdType t, double v, std::string& err) { // 特例:raw 原始 SCPI 调试直接同步查询(ScpiClient 线程安全)。 std::string ELoad::handleCmdApi(const std::string& query) { - std::string action, valStr, raw; + std::string action, valStr, raw, modeStr; getQueryParam(query, "action", action); getQueryParam(query, "value", valStr); + getQueryParam(query, "mode", modeStr); double v = atof(valStr.c_str()); Json::Value root(Json::objectValue); @@ -1176,6 +1268,21 @@ std::string ELoad::handleCmdApi(const std::string& query) { else if (action == "set_voltlim") queueWebCmd(WCMD_SET_VOLTLIM, v, err); else if (action == "set_powlimit") queueWebCmd(WCMD_SET_POWLIM, v, err); else if (action == "set_currlimit") queueWebCmd(WCMD_SET_CURRLIM, v, err); + else if (action == "func_mode") { + // 功能模式切换:mode=FIX|LIST|BATT|SOLAR|CAR + std::string m = toupper(modeStr); + static const char* kModes[] = {"FIX", "LIST", "BATT", "SOLAR", "CAR"}; + bool valid = false; + for (size_t i = 0; i < sizeof(kModes)/sizeof(kModes[0]); ++i) + if (m == kModes[i]) { valid = true; break; } + if (!valid) { + root["ok"] = false; + root["error"] = "bad mode (FIX/LIST/BATT/SOLAR/CAR)"; + } else { + queueWebCmd(WCMD_FUNC_MODE, m, err); + root["mode"] = m; + } + } else if (action == "raw") { getQueryParam(query, "cmd", raw); if (raw.empty()) { diff --git a/src/pELoad/ELoad.h b/src/pELoad/ELoad.h index 0972436..334d35e 100644 --- a/src/pELoad/ELoad.h +++ b/src/pELoad/ELoad.h @@ -56,6 +56,7 @@ private: double computeCmdCurrent(double targetP); // 功率折算电流(含限幅) void pollDevice(); // 异步测量轮询(不阻塞控制循环) void pollError(double now); // 异步错误/FUNC 回读(1Hz 节流) + void requestFuncModeReadback(); // 异步回读 FUNC:MODE?(真实反馈驱动显示) void dbPeriodic(double now); // 状态落库 + 历史清理 void pruneHistory(double now); @@ -78,14 +79,17 @@ private: 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_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: //---- 配置 ---- @@ -147,6 +151,9 @@ private: 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) diff --git a/src/pELoad/web/WebServer.cpp b/src/pELoad/web/WebServer.cpp index 57fa9ce..ee4e245 100644 --- a/src/pELoad/web/WebServer.cpp +++ b/src/pELoad/web/WebServer.cpp @@ -1,5 +1,6 @@ #include "WebServer.h" #include "pages/index.h" +#include "pages/trend.h" #include namespace eload { @@ -44,6 +45,13 @@ void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) { 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 线程内:先构建欢迎快照,再登记连接 diff --git a/src/pELoad/web/pages/index.h b/src/pELoad/web/pages/index.h index 9a3b27a..f1ee18a 100644 --- a/src/pELoad/web/pages/index.h +++ b/src/pELoad/web/pages/index.h @@ -104,6 +104,8 @@ td.l,th.l{text-align:left;} 未连接 输出关 模式 - + + 📈 实时曲线
@@ -152,6 +154,19 @@ td.l,th.l{text-align:left;}
环路优先(回读)-
功能模式(回读)-
+
切换结果-
+
功能模式切换(FUNC:MODE)
+
+ + +
+
实时功率控制必须保持 FIX。切换到 LIST/BATT/SOLAR/CAR 后,输出将按该模式自主运行,脱离本程序的实时功率控制(页面会红色告警),需切回 FIX 恢复。
@@ -301,6 +316,23 @@ function sendFromInput(action, inputId, label, unit){ 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; @@ -382,8 +414,26 @@ function render(s){ mde.textContent = dev.lastMeasErr; } else mde.style.display = 'none'; $('devFunc').textContent = dev.func || '-'; - $('devFuncMode').innerHTML = (dev.funcMode || '-') - + (dev.funcModeFixWarn ? ' 非FIX!' : ' FIX'); + const fmName = (dev.funcMode || '').trim(); + $('devFuncMode').innerHTML = fmName + ? '' + fmName + '' + + (dev.funcModeFixWarn ? ' 非FIX' : ' FIX') + : '回读中...'; + 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); diff --git a/src/pELoad/web/pages/trend.h b/src/pELoad/web/pages/trend.h new file mode 100644 index 0000000..440534b --- /dev/null +++ b/src/pELoad/web/pages/trend.h @@ -0,0 +1,199 @@ +#ifndef PELOAD_PAGE_TREND_H +#define PELOAD_PAGE_TREND_H + +#include + +//============================================================================ +// pELoad 实时曲线页(独立标签页)。 +// +// - 通过 WebSocket /ws 接收 5Hz 快照,绘制电压/电流/功率三条实时曲线; +// - 可从 /api/meas(100ms 高采样 meas_log)加载历史数据回填; +// - 纯前端 canvas 绘制,无外部依赖。 +//============================================================================ + +namespace eload { +const std::string TREND_HTML = R"HTML( + + + + +pELoad 实时曲线 + + + +

pELoad 实时曲线 连接中... + + 控制页面 +

+ +
+ 时间窗口 + + + 采样: 0 点 +
+ +

电压 V -

+

电流 A -

+

功率 W -

+ + + +)HTML"; + +} // namespace eload +#endif // PELOAD_PAGE_TREND_H \ No newline at end of file