pELoad 新增功能模式(FUNC:MODE)远程切换与实时曲线页:切换带白名单校验/下发后立即回读确认(真实反馈驱动显示),模式名规范化兼容设备长格式回读(Fixed/LIST/BATTery/SOLar/CARProfile);新增 /trend 实时曲线页(canvas 绘制 V/I/P 三曲线、窗口 30s~10min、历史按时间范围回填),WebSocket 快照推送提至 5Hz

This commit is contained in:
zjk
2026-09-03 20:48:30 +08:00
parent 91be306a92
commit 237e17ea50
5 changed files with 387 additions and 16 deletions
+120 -13
View File
@@ -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<std::mutex> 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<char>(::toupper(static_cast<unsigned char>(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<std::mutex> 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<char>(::toupper(static_cast<unsigned char>(clean[i])));
if (u.rfind("CURR", 0) == 0) clean = "CURR";
else if (u.rfind("VOLT", 0) == 0) clean = "VOLT";
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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()) {
+8 -1
View File
@@ -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)
+8
View File
@@ -1,5 +1,6 @@
#include "WebServer.h"
#include "pages/index.h"
#include "pages/trend.h"
#include <iostream>
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 线程内:先构建欢迎快照,再登记连接
+52 -2
View File
@@ -104,6 +104,8 @@ td.l,th.l{text-align:left;}
<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">
@@ -152,6 +154,19 @@ td.l,th.l{text-align:left;}
<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="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:参数与输出控制 ========-->
@@ -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 ? ' <span class="badge err">非FIX!</span>' : ' <span class="badge ok">FIX</span>');
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 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);
+199
View File
@@ -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