pELoad 多处完善:测量改为 FETCh 读缓存(不触发测量、修复真实设备 MEAS 查询超时失败)+ 异步查询不阻塞 100ms 控制循环;CV 优先控制路径与 FUNC:MODE FIX 防护;sqlite 增加 volt_set/cmd_volt 列并支持旧库自动迁移;页面重构为静态骨架(输入框永不重建、聚焦不被打断)+ 指令确认/步骤引导/测量诊断;deploy.sh 服务列表补 pMotor/pELoad,missions/h100.moos 补 pELoad 新配置

This commit is contained in:
zjk
2026-09-03 16:40:27 +08:00
parent 2a34b79461
commit 91be306a92
13 changed files with 640 additions and 297 deletions
+289 -184
View File
@@ -10,7 +10,11 @@
// ① 连接设备(链路绿 → 步骤2 可操作)
// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值)
// ③ 开启输出(确认后下发使能序列)
// 所有下发的指令均带确认对话框;按钮按当前状态禁用并提示原因。
// 所有下发的指令均带确认对话框;按钮按当前状态禁用。
//
// 关键设计:页面为静态骨架(输入框/按钮是稳定 DOM,永不重建),
// render() 只按 id 更新动态数值/状态/按钮禁用 —— 输入框聚焦编辑时
// 不受 1Hz 快照刷新影响,光标稳定、输入流畅(setInput 聚焦时不覆盖)。
//
// 模式说明:
// CC 优先(电流型负载模拟):控制量 = 电流,功率折算 I=P/U,可跟随 MOOS
@@ -58,7 +62,6 @@ 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);}
.big{font-size:22px;font-family:Consolas,monospace;}
.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;}
@@ -113,13 +116,145 @@ td.l,th.l{text-align:left;}
<span class="tip" id="stepTip"></span>
</div>
<div class="grid" id="pages">加载中...</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>
<!--======== 卡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 row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
function ageText(a){
if(typeof a !== 'number' || a < 0) return '从未';
if(a < 90) return a.toFixed(1)+' 秒前';
@@ -166,29 +301,27 @@ function sendFromInput(action, inputId, label, unit){
if(isNaN(v)){ alert('请输入有效数值'); return; }
sendCmd(action, v, '确认设定'+label+' = '+v+' '+unit+' ?');
}
// disabled 属性辅助:条件为真时输出 " disabled",否则空串
// (HTML 中 disabled="false" 也会禁用按钮,因此必须用条件拼接)
function dis(cond){ return cond ? ' disabled' : ''; }
//------------------ 渲染(WebSocket 1Hz 快照) ------------------
//------------------ 渲染(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;
const cc = !!ctrl.ccPriority;
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);
// 预计算按钮禁用条件(供 dis() 使用,避免在字符串拼接中出现引号)
const dNotConn = !connected;
const dCC = !connected || cc;
const dCV = !connected || !cc;
const dFollow = !connected || ctrl.mode==='follow';
const dManual = !connected || ctrl.mode==='manual';
const dCurrent = !connected || ctrl.mode==='current';
const dOutpOn = !connected || loadOn;
const dOutpOff = !connected || !loadOn;
const now = Date.now() / 1000;
//---- 顶部状态条 ----
statusEl.textContent = connected ? '已连接 '+link.host+':'+link.port : '未连接';
@@ -211,157 +344,160 @@ function render(s){
$('step3').className = 'step ' + (outpApplied ? 'done active' : '');
$('stepTip').innerHTML = tipText;
let html = '';
//---- 卡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);
//---- 卡1:以太网链路 ----
html += '<div class="card"><h2>① 连接设备 · 以太网链路</h2>'
+ row('设备地址', link.host+':'+link.port)
+ row('连接状态', connected
? '<span style="color:var(--ok)">已连接</span>' : '<span style="color:var(--bad)">未连接</span>')
+ row('自动重连', link.enabled ? '开启' : '关闭')
+ row('重连次数', link.reconnects)
+ row('收发统计', 'tx='+link.tx+' rx='+link.rx+' txErr='+link.txErr)
+ row('最近收帧', ageCell(link, 'lastRx'))
+ row('设备识别 *IDN?', link.idn || '<span class="dim">未识别</span>')
+ (link.lastError ? '<div class="errline">设备错误: '+link.lastError+'</div>' : '')
+ '<div class="sub">操作(指令均需确认)</div>'
+ '<button class="btn on"'+dis(connected)+' onclick="sendCmd(\'connect\',null,\'确认连接电子负载?\')">连接</button>'
+ '<button class="btn off"'+dis(dNotConn)+' onclick="sendCmd(\'disconnect\',null,\'确认断开以太网链路?\n断开前会先关闭输出。\')">断开</button>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendCmd(\'identify\')">识别 (*IDN?)</button>'
+ '<button class="btn warn"'+dis(dNotConn)+' onclick="sendCmd(\'clear_protect\',null,\'确认清除输出保护锁定?\')">清保护</button>'
+ '<button class="btn off"'+dis(dNotConn)+' onclick="sendCmd(\'reset_dev\',null,\'确认复位设备 (*RST)?\n所有设定将恢复默认!\')">复位设备</button>'
+ '</div>';
//---- 卡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 || '-';
$('devFuncMode').innerHTML = (dev.funcMode || '-')
+ (dev.funcModeFixWarn ? ' <span class="badge err">非FIX!</span>' : ' <span class="badge ok">FIX</span>');
//---- 卡2:当前状态 + 实时测量 ----
const bigOutp = outpApplied ? '输出开' : (loadOn ? '待应用' : '输出关');
const bigCls = outpApplied ? 'on' : (loadOn ? 'wait' : 'off');
html += '<div class="card"><h2>当前状态 · 实时测量</h2>'
+ '<div class="bigstat '+bigCls+'">'+bigOutp+'</div>'
+ (dev.funcModeFixWarn
? '<div class="fixwarn">FUNC:MODE = '+(dev.funcMode||'?')+'(非 FIX)——实时功率控制已失效!请复位或手动恢复 FIX。</div>'
: '')
+ '<div class="meas">'
+ '<div><div class="v" style="color:#79c0ff">'+fmt(dev.measV,2)+'</div><div class="k">电压 V</div></div>'
+ '<div><div class="v" style="color:#3fb950">'+fmt(dev.measI,3)+'</div><div class="k">电流 A</div></div>'
+ '<div><div class="v" style="color:#d29922">'+fmt(dev.measP,1)+'</div><div class="k">功率 W</div></div>'
+ '</div>'
+ row('数据时效', 'V '+ageCell(dev,'measV')+' · I '+ageCell(dev,'measI')+' · P '+ageCell(dev,'measP'))
+ row('环路优先(回读)', dev.func || '-')
+ row('功能模式(回读)', (dev.funcMode || '-') + (dev.funcModeFixWarn ? ' <span class="badge err">非FIX!</span>' : ' <span class="badge ok">FIX</span>'))
+ '</div>';
//---- 卡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>'
: '输出未开启,开启前请完成步骤②参数设置');
//---- 卡3:控制(步骤2/3)----
html += '<div class="card"><h2>②③ 参数与输出控制</h2>';
//---- 卡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' : '';
// 环路优先选择
html += '<div class="sub">环路优先模式(决定控制量类型)</div>'
+ '<button class="btn sel"'+dis(dCC)+' onclick="sendCmd(\'mode_cc\',null,\'切换为 CC 优先(电流型负载,功率折算 I=P/U)?\')">CC 优先</button>'
+ '<button class="btn sel"'+dis(dCV)+' onclick="sendCmd(\'mode_cv\',null,\'切换为 CV 优先(恒压吸收,按电压控制)?\')">CV 优先</button>'
+ '<div class="tip">'+(cc ? '<b>CC 优先</b>:控制量 = 电流。目标功率/电流 → CURR 指令(负值=吸收电能)。'
: '<b>CV 优先</b>:控制量 = 电压。恒压吸收,功率由外部源决定。')+'</div>';
//---- 卡5:SCPI ----
setDisabled('btnRawSend', !connected);
setDisabled('rawCmd', !connected);
if(cc){
// CC:控制模式
html += '<div class="sub">控制模式(目标来源)</div>'
+ '<button class="btn"'+dis(dFollow)+' onclick="sendCmd(\'mode_follow\',null,\'切换为跟随 MOOS 功率消息(实时电机功率)?\')">跟随MOOS</button>'
+ '<button class="btn"'+dis(dManual)+' onclick="sendCmd(\'mode_manual\',null,\'切换为手动功率?\')">手动功率</button>'
+ '<button class="btn"'+dis(dCurrent)+' onclick="sendCmd(\'mode_current\',null,\'切换为手动电流?\')">手动电流</button>'
+ '<div class="tip">当前:<b>'+(modeName[ctrl.mode]||ctrl.mode)+'</b></div>'
+ '<div class="inline"><label>功率 (W)</label>'
+ '<input type="number" id="powerInput" step="1" value="'+(ctrl.manualPower||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_power\',\'powerInput\',\'手动目标功率\',\'W\')">设定功率</button></div>'
+ '<div class="inline"><label>电流 (A, 负=吸收)</label>'
+ '<input type="number" id="currInput" step="0.1" value="'+(ctrl.manualCurrent||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_current\',\'currInput\',\'手动电流\',\'A\')">设定电流</button></div>'
+ row('MOOS 功率消息', (ctrl.moosPowerValid
? fmt(ctrl.moosPower,1)+' W ('+ageText(ctrl.moosPowerAge)+')'
: '<span class="dim">未收到</span>')
+ (powerStale ? ' <span class="badge err">超时!</span>' : ''))
+ row('指令电流 CURR', fmt(ctrl.cmdCurrent,4)+' A')
+ row('符号约定', ctrl.sign==='negative' ? '负电流=吸收(负载模拟)' : '正电流=输出(电源模拟)');
} else {
// CV:电压设定
html += '<div class="inline"><label>目标电压 (V)</label>'
+ '<input type="number" id="voltInput" step="1" value="'+(ctrl.voltSet||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_volt\',\'voltInput\',\'CV 目标电压\',\'V\')">设定电压</button></div>'
+ row('指令电压 VOLT', fmt(ctrl.cmdVolt,2)+' V')
+ '<div class="tip">恒压吸收:设备维持该电压,吸收电流由外部源决定;功率大小请以实测为准。</div>';
}
// 输出开/关(大按钮)
html += '<div class="sub">输出控制</div>'
+ '<button class="btn big on"'+dis(dOutpOn)+' onclick="sendCmd(\'outp_on\',null,\'确认开启输出?\n电子负载将按当前设定开始吸收电能!\')">▶ 开启输出</button>'
+ '<button class="btn big off"'+dis(dOutpOff)+' onclick="sendCmd(\'outp_off\',null,\'确认关闭输出?\n电子负载停止吸收电能。\')">■ 关闭输出</button>'
+ '<div class="tip">'+(outpApplied
? '<b style="color:var(--ok)">输出已开启</b>,正在实时控制'
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>' : '输出未开启,开启前请完成步骤②参数设置'))+'</div>'
+ '</div>';
//---- 卡4:设备参数与限值 ----
html += '<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="'+(dev.voltLimit||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' 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="'+(dev.currLimit||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_currlimit\',\'currlimInput\',\'电流限值\',\'A\')">下发</button></div>'
+ '<div class="inline"><label>功率限值 POW:LIM (W)</label>'
+ '<input type="number" id="powlimInput" step="10" value="'+(dev.powLimit||0)+'"'+dis(dNotConn)+'>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendFromInput(\'set_powlimit\',\'powlimInput\',\'功率限值\',\'W\')">下发</button></div>'
+ '<div class="sub">远控 / 本控</div>'
+ '<button class="btn"'+dis(dNotConn)+' onclick="sendCmd(\'remote\',null,\'确认进入远控模式 (SYST:REM)?\n面板按键将被锁定。\')">远控 SYST:REM</button>'
+ '<button class="btn warn"'+dis(dNotConn)+' onclick="sendCmd(\'local\',null,\'确认切回面板本控 (SYST:LOC)?\n输出将关闭且远控失效。\')">本控 SYST:LOC</button>'
+ '</div>';
//---- 卡5:SCPI 调试 ----
html += '<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"'+dis(dNotConn)+' />'
+ '<button class="btn"'+dis(dNotConn)+' 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:数据分析(高采样测量 meas_log)----
html += '<div class="card"><h2>数据分析 (高采样 meas_log)</h2>'
+ '<div class="row"><span class="k">测量记录条数</span><span class="v">'+db.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:历史记录 ----
html += '<div class="card"><h2>历史记录 (sqlite eload_log, 1Hz)</h2>'
+ '<div class="row"><span class="k">记录总数</span><span class="v">'+db.statusCount+' / SCPI '+db.scpiCount+'</span></div>'
+ '<div id="history" style="max-height:240px;overflow:auto"><span class="dim">点击“查询”加载</span></div>'
+ '<button class="btn" onclick="loadHistory()">查询最近 60 条</button>'
+ '</div>';
$('pages').innerHTML = html;
//---- 卡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>指令A</th><th>V</th><th>A</th><th>W</th></tr>';
+ '<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.cmdCurrent,3)+'</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>';
@@ -387,37 +523,6 @@ function loadScpiLog(){
.catch(() => { $('scpiLog').innerHTML = '<span class="errline">查询失败</span>'; });
}
//------------------ 数据分析(高采样测量 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">目标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.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;
}
//------------------ WebSocket 1Hz 快照 ------------------
let ws = null, wsTimer = null;
function connectWs(){