程序重启/刷新后页面状态与设备实际状态错配,导致按页面流程下发指令反馈错误。 新增 syncDeviceConfigOnce:连接建立进入远控后同步查询设备 OUTP/FUNC/FUNC:MODE 及各限值/设定值,以设备真实回读为准覆盖内存状态;设备输出仍为开时置 loadOn=true 并由 applyEnable 重新下发完整使能序列(接管恢复控制)。 - IT6000C 新增 7 个配置回读查询封装(CURR?/VOLT?/VOLT:LIM?/CURR:LIM?*2/POW:LIM?*2) - ELoad 实现 syncDeviceConfigOnce 并接入 doControlCycle(连接后自动,覆盖重连/复位/本控→远控) - 抽出 normFunc 辅助函数复用于 pollError;快照新增 cfgSynced/cfgSyncTime - 页面卡2 增加设备配置同步状态显示
605 lines
34 KiB
C++
605 lines
34 KiB
C++
#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
|