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
+8 -1
View File
@@ -126,7 +126,7 @@ ProcessConfig = pELoad
//======== 电子负载(IT6000C)以太网地址 ========
// 仪器 LAN 配置的 IP 与 Raw Socket 端口号(出厂默认 30000)
eload_ip = 192.168.0.10
eload_ip = 192.168.200.100
eload_port = 30000
//======== 功率控制 ========
@@ -141,11 +141,18 @@ ProcessConfig = pELoad
slew_limit_wps = 0
func_priority = cc
// CV 优先时的目标电压(V),func_priority=cv 时生效(恒压吸收)
volt_set = 0
//======== 数据存储 ========
dbpath = /root/work/h100/data/pELoad.db
log_keep_hours = 72
meas_log_period_ms = 100
// 测量命令来源:fetch=FETCh(读设备表头缓存,不触发测量,响应快,推荐);
// meas=MEAS(每次触发设备重新测量,响应慢,易超时)
meas_source = fetch
//======== 网页(避开 8080/8090/18080/18081)========
web_port = 18082
web_enable = true
+13 -10
View File
@@ -1,16 +1,17 @@
#!/bin/bash
#=======================================================================
# FILE: scripts/deploy.sh
# DESC: 部署 mission + 5 个 systemd 服务(moosdb / pPowerManger /
# pPowerMangerHost / pCCU / pCanBridge)到目标板卡 (RK3588 /
# Ubuntu 22.04)。只安装服务 + daemon-reload,默认不启动、不自启。
# DESC: 部署 mission + 7 个 systemd 服务(moosdb / pPowerManger /
# pPowerMangerHost / pCCU / pCanBridge / pMotor / pELoad)到目标
# 板卡 (RK3588 / Ubuntu 22.04)。只安装服务 + daemon-reload,
# 默认不启动、不自启。
#
# 注意:二进制由 ./scripts/build-board.sh 在板卡原生编译并部署到
# /root/work/h100/bin/(交叉编译已移除),本脚本不再推送二进制。
#
# 用法:
# ./scripts/deploy.sh setup-ssh 一次性:生成 ed25519 key + ssh-copy-id
# ./scripts/deploy.sh 默认:推送 mission + 装 5 个
# ./scripts/deploy.sh 默认:推送 mission + 装 7 个
# systemd 服务 + daemon-reload(不启动)
# ./scripts/deploy.sh --start 部署后按依赖顺序启动服务
# ./scripts/deploy.sh status 板卡各服务状态
@@ -31,7 +32,8 @@
# /root/work/h100/data/ 数据库存储目录 (power_data.db 等)
# /etc/systemd/system/ moosdb.service / pPowerManger.service /
# pPowerMangerHost.service / pCCU.service /
# pCanBridge.service
# pCanBridge.service / pMotor.service /
# pELoad.service
#=======================================================================
set -uo pipefail
@@ -45,7 +47,7 @@ DO_START=0
CCU_HOST=""
CCU_PORT=""
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge)
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor pELoad)
info() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
@@ -228,10 +230,11 @@ cmd_deploy() {
else
echo ""
info "部署完成。手动启动(或加 --start):"
echo " systemctl start moosdb pPowerManger pPowerMangerHost pCCU pCanBridge"
echo " systemctl start moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor pELoad"
echo " 浏览器: http://${HOST}:18080 (上位机模拟)"
echo " http://${HOST}:8090 (pPowerManger)"
echo " http://${HOST}:8080 (pCCU)"
echo " http://${HOST}:18082 (pELoad)"
fi
}
@@ -239,9 +242,9 @@ cmd_deploy() {
# 命令: start / status / stop
#-------------------------------------------------------------------
cmd_start() {
info "启动服务 (moosdb -> pPowerManger / pPowerMangerHost / pCCU / pCanBridge) ..."
info "启动服务 (moosdb -> pPowerManger / pPowerMangerHost / pCCU / pCanBridge / pMotor / pELoad) ..."
${SSH} "${SSH_TARGET}" "systemctl start moosdb && sleep 1 && \
systemctl start pPowerManger pPowerMangerHost pCCU pCanBridge" || { err "启动失败"; return 1; }
systemctl start pPowerManger pPowerMangerHost pCCU pCanBridge pMotor pELoad" || { err "启动失败"; return 1; }
ok "服务已启动"
cmd_status
}
@@ -251,7 +254,7 @@ cmd_status() {
}
cmd_stop() {
${SSH} "${SSH_TARGET}" "systemctl stop pCanBridge pPowerMangerHost pPowerManger pCCU moosdb" && \
${SSH} "${SSH_TARGET}" "systemctl stop pELoad pMotor pCanBridge pPowerMangerHost pPowerManger pCCU moosdb" && \
ok "服务已停止" || err "停止服务失败"
}
+151 -62
View File
@@ -87,6 +87,7 @@ bool ELoad::OnStartUp() {
else if (param == "dbpath") m_dbPath = value;
else if (param == "log_keep_hours") m_logKeepHours = atof(value.c_str());
else if (param == "meas_log_period_ms") m_measLogPeriodMs = atoi(value.c_str());
else if (param == "meas_source") m_measUseFetch = (tolower(value) != "meas");
else if (param == "web_port") m_webPort = atoi(value.c_str());
else if (param == "web_enable") m_webEnable = (tolower(value) == "true" || value == "1");
else if (param == "logpath") m_logPath = value;
@@ -317,16 +318,50 @@ void ELoad::processWebCmds() {
LOG_F(INFO, "[ELoad/web] output OFF requested");
break;
case WCMD_REMOTE:
if (m_scpi.isConnected()) m_dev.remote();
break;
case WCMD_LOCAL:
{
bool ok = false;
if (m_scpi.isConnected()) {
m_dev.local();
m_loadOn = false;
ok = m_dev.remote();
}
{
std::lock_guard<std::mutex> lock(m_stateMutex);
m_remoteRequested = true;
if (ok) {
m_remoteState = "remote";
m_remoteFeed = "SYST:REM 已发送成功";
} else {
m_remoteState = "unknown";
m_remoteFeed = "SYST:REM 发送失败(未连接或超时)";
}
m_remoteFeedTime = MOOSTime(false);
}
LOG_F(INFO, "[ELoad/web] remote -> %s", ok ? "ok" : "fail");
break;
}
case WCMD_LOCAL:
{
bool ok = false;
if (m_scpi.isConnected()) {
ok = m_dev.local();
}
{
std::lock_guard<std::mutex> lock(m_stateMutex);
m_remoteRequested = false; // 保持本控,不再自动切回远控
m_loadOn = false; // 本控下停止输出
m_remoteApplied = false;
m_cfgApplied = false;
if (ok) {
m_remoteState = "local";
m_remoteFeed = "SYST:LOC 已发送成功,设备处于本控";
} else {
m_remoteState = "unknown";
m_remoteFeed = "SYST:LOC 发送失败(未连接或超时)";
}
m_remoteFeedTime = MOOSTime(false);
}
LOG_F(INFO, "[ELoad/web] local -> %s", ok ? "ok" : "fail");
break;
}
case WCMD_MODE_CC:
case WCMD_MODE_CV: {
bool cc = (c.type == WCMD_MODE_CC);
@@ -453,13 +488,20 @@ void ELoad::doControlCycle(double now) {
return;
}
// 每次连接后先进入远控
if (!m_remoteApplied) {
if (m_dev.remote())
// 每次连接后进入远控(仅当需要远控;用户切本控后不自动切回)
if (m_remoteRequested && !m_remoteApplied) {
if (m_dev.remote()) {
m_remoteApplied = true;
else
std::lock_guard<std::mutex> lock(m_stateMutex);
if (m_remoteState != "remote") {
m_remoteState = "remote";
m_remoteFeed = "SYST:REM 自动发送成功(连接后进入远控)";
m_remoteFeedTime = now;
}
} else {
return;
}
}
// 输出状态切换
bool needOn = m_loadOn;
@@ -508,10 +550,10 @@ void ELoad::doControlCycle(double now) {
}
}
// 测量轮询(每周期一条,轮转 V/I/P)——在指令下发之后
pollDevice(now);
// 测量轮询(异步发起,不阻塞控制循环)
pollDevice();
// 错误队列查询(1Hz,设备故障可见)
// 错误/FUNC 回读(异步,1Hz 节流)
pollError(now);
}
@@ -659,66 +701,95 @@ double ELoad::computeCmdCurrent(double targetP) {
}
//---------------------------------------------------------
// pollDevice:测量轮询(每周期一条,轮转 V/I/P)
// pollDevice:测量轮询(异步,不阻塞 100ms 控制循环)
// 每周期发起一个 MEAS 查询(V/I/P 轮转),结果由 ScpiClient IO 线程
// 异步回调更新;上一轮未完成时跳过(节流),保证查询不堆积。
void ELoad::pollDevice(double now) {
double v;
switch (m_measRotate % 3) {
case 0:
if (m_dev.measureVoltage(v)) {
namespace {
// 解析 SCPI 数字响应(如 "500.123"),空/非数字返回 false
bool parseScpiDouble(const std::string& r, double& v) {
if (r.empty()) return false;
char* end = nullptr;
double d = std::strtod(r.c_str(), &end);
if (end == r.c_str()) return false;
v = d;
return true;
}
} // namespace
void ELoad::pollDevice() {
if (m_measPending) return; // 上一轮查询仍在 IO 线程处理中
if (!m_scpi.isConnected()) {
std::lock_guard<std::mutex> lock(m_stateMutex);
m_measV = v; m_measVTime = now;
++m_measFail;
m_lastMeasErr = "测量查询失败: 未连接";
return;
}
break;
case 1:
if (m_dev.measureCurrent(v)) {
m_measPending = true;
int type = m_measRotate++ % 3; // 0=VOLT 1=CURR 2=POW
// 用 FETCh(读表头缓存,不触发测量,响应快)还是 MEAS(触发测量,慢)
const char* q = "";
if (m_measUseFetch)
q = (type == 0) ? "FETCh:VOLT?" : (type == 1) ? "FETCh:CURR?" : "FETCh:POW?";
else
q = (type == 0) ? "MEAS:VOLT?" : (type == 1) ? "MEAS:CURR?" : "MEAS:POW?";
m_scpi.queryAsync(q, [this, type, q](bool ok, const std::string& resp) {
m_measPending = false;
double v = 0;
bool parsed = ok && parseScpiDouble(resp, v);
std::lock_guard<std::mutex> lock(m_stateMutex);
m_measI = v; m_measITime = now;
if (parsed) {
++m_measOk;
double now = MOOSTime(false);
if (type == 0) { m_measV = v; m_measVTime = now; }
else if (type == 1) { m_measI = v; m_measITime = now; }
else { m_measP = v; m_measPTime = now; }
} else {
++m_measFail;
m_lastMeasErr = std::string(q) + " 失败: "
+ (ok ? "无有效响应[" + resp + "]" : "超时或无响应");
}
break;
default:
if (m_dev.measurePower(v)) {
std::lock_guard<std::mutex> lock(m_stateMutex);
m_measP = v; m_measPTime = now;
}
break;
}
++m_measRotate;
});
}
//---------------------------------------------------------
// pollError:1Hz 周期——错误队列 + FUNC/FUNC:MODE 状态回读
// pollError:异步回读(1Hz 节流,每周期一条,轮流 FUNC/FUNC:MODE/SYST:ERR)
// 同样不阻塞控制循环。
void ELoad::pollError(double now) {
if (now - m_lastErrPoll < 1.0) return;
m_lastErrPoll = now;
// 环路优先与功能模式回读(FIX 防护:若被外部切成 LIST/BATT 等,
// 实时功率控制会失效,必须在网页/日志中醒目提示)
{
std::string func, mode;
bool hasFunc = m_dev.queryFunction(func);
bool hasMode = m_dev.queryFunctionMode(mode);
if (hasFunc || hasMode) {
int type = (m_measRotate + 1) % 3; // 与测量错开
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; }
});
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);
if (hasFunc) m_devFunc = func;
if (hasMode) m_devFuncMode = mode;
bool warn = hasMode && (mode != "FIX");
m_devFuncMode = r;
bool warn = (r != "FIX");
m_funcModeFixWarn = warn;
if (warn)
LOG_F(WARNING, "[ELoad] FUNC:MODE = %s(非 FIX,实时功率控制失效!)", mode.c_str());
}
}
std::string err;
if (!m_dev.nextError(err)) return;
if (err.empty()) return;
// "0,..." 表示无错误
if (err[0] == '0' && err.size() > 1 && err[1] == ',') return;
if (warn) LOG_F(WARNING, "[ELoad] FUNC:MODE = %s(非 FIX,实时功率控制失效!)", r.c_str());
});
break;
default:
m_scpi.queryAsync("SYST:ERR?", [this](bool ok, const std::string& r) {
if (!ok) return;
if (r.empty()) return;
if (r[0] == '0' && r.size() > 1 && r[1] == ',') return; // 无错误
std::lock_guard<std::mutex> lock(m_stateMutex);
m_lastError = err;
LOG_F(WARNING, "[ELoad] device error: %s", err.c_str());
m_lastError = r;
LOG_F(WARNING, "[ELoad] device error: %s", r.c_str());
});
break;
}
}
//---------------------------------------------------------
@@ -732,7 +803,7 @@ void ELoad::dbPeriodic(double now) {
if (m_measLogPeriodMs > 0) {
std::lock_guard<std::mutex> lock(m_stateMutex);
double target = m_mode == "volt" ? m_voltSet : m_appliedPower;
m_db->insertMeas(m_measV, m_measI, m_measP, m_cmdCurrent, target, m_cfgApplied);
m_db->insertMeas(m_measV, m_measI, m_measP, m_cmdCurrent, m_cmdVolt, target, m_cfgApplied);
}
if (now - m_lastDbWrite >= 1.0) {
@@ -742,7 +813,8 @@ void ELoad::dbPeriodic(double now) {
std::lock_guard<std::mutex> lock(m_stateMutex);
m_db->insertStatus(connected, m_cfgApplied, m_mode,
m_appliedPower, // 超时/限幅/斜率处理后的有效目标功率
m_cmdCurrent, m_measV, m_measI, m_measP,
m_voltSet, // CV 目标电压
m_cmdCurrent, m_cmdVolt, m_measV, m_measI, m_measP,
m_devFunc.empty() ? (m_ccPriority ? "CURR" : "VOLT") : m_devFunc,
m_scpi.txCount(), m_scpi.rxCount(), m_scpi.txErrorCount());
}
@@ -843,6 +915,10 @@ std::string ELoad::buildSnapshot() {
dev["funcMode"] = m_devFuncMode;
dev["funcModeFixWarn"] = m_funcModeFixWarn ? 1 : 0;
dev["funcModeValid"] = m_devFuncMode.empty() ? 0 : 1;
dev["remoteState"] = m_remoteState; // remote / local / unknown
dev["remoteFeed"] = m_remoteFeed; // 最近远控/本控指令执行结果
dev["remoteFeedTime"] = m_remoteFeedTime;
dev["remoteFeedValid"] = m_remoteFeedTime > 0 ? 1 : 0;
dev["voltLimit"] = m_voltLimit;
dev["currLimit"] = m_currLimitPos;
dev["currLimitNeg"] = m_currLimitNeg;
@@ -857,6 +933,9 @@ std::string ELoad::buildSnapshot() {
dev["measP"] = m_measP;
dev["measPValid"]= m_measPTime > 0 ? 1 : 0;
dev["measPAge"] = m_measPTime > 0 ? (now - m_measPTime) : -1;
dev["measOk"] = Json::Value((Json::UInt64)m_measOk);
dev["measFail"] = Json::Value((Json::UInt64)m_measFail);
dev["lastMeasErr"] = m_lastMeasErr;
root["dev"] = dev;
// 存储
@@ -898,7 +977,9 @@ std::pair<std::string, std::string> ELoad::handleApi(const std::string& uri, con
r["outp"] = data[i].outp;
r["mode"] = data[i].mode;
r["targetPower"] = data[i].targetPower;
r["voltSet"] = data[i].voltSet;
r["cmdCurrent"] = data[i].cmdCurrent;
r["cmdVolt"] = data[i].cmdVolt;
r["measVoltage"] = data[i].measVoltage;
r["measCurrent"] = data[i].measCurrent;
r["measPower"] = data[i].measPower;
@@ -957,6 +1038,7 @@ std::pair<std::string, std::string> ELoad::handleApi(const std::string& uri, con
r["i"] = data[i].measCurrent;
r["p"] = data[i].measPower;
r["cmdI"] = data[i].cmdCurrent;
r["cmdV"] = data[i].cmdVolt;
r["targetP"] = data[i].targetPower;
r["outp"] = data[i].outp;
rows.append(r);
@@ -973,7 +1055,7 @@ std::pair<std::string, std::string> ELoad::handleApi(const std::string& uri, con
getQueryParam(query, "start", stStr);
getQueryParam(query, "end", etStr);
std::string csv = "time,meas_voltage_V,meas_current_A,meas_power_W,"
"cmd_current_A,target_power_W,outp\n";
"cmd_current_A,cmd_volt_V,target_power_W,outp\n";
if (m_db && m_db->isOpen()) {
std::vector<MeasRow> data;
if (!stStr.empty() && !etStr.empty()) {
@@ -986,10 +1068,10 @@ std::pair<std::string, std::string> ELoad::handleApi(const std::string& uri, con
}
char buf[128];
for (size_t i = 0; i < data.size(); ++i) {
std::snprintf(buf, sizeof(buf), "%.6f,%.4f,%.4f,%.4f,%.4f,%.4f,%d\n",
std::snprintf(buf, sizeof(buf), "%.6f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%d\n",
data[i].time, data[i].measVoltage, data[i].measCurrent,
data[i].measPower, data[i].cmdCurrent, data[i].targetPower,
data[i].outp);
data[i].measPower, data[i].cmdCurrent, data[i].cmdVolt,
data[i].targetPower, data[i].outp);
csv += buf;
}
}
@@ -1157,6 +1239,13 @@ bool ELoad::buildReport() {
<< " 指令电流=" << m_cmdCurrent << " A" << "\n";
m_msgs << "实测: V=" << m_measV << " I=" << m_measI
<< " P=" << m_measP << "\n";
m_msgs << "测量查询: 成功 " << m_measOk << " 次, 失败 " << m_measFail
<< " 次"
<< (m_lastMeasErr.empty() ? std::string("")
: std::string(" 最近失败: ") + m_lastMeasErr)
<< "\n";
m_msgs << "远控状态: " << m_remoteState
<< " (" << m_remoteFeed << ")\n";
m_msgs << "MOOS 功率消息: " << m_moosPower << " W (var=" << m_powerVar
<< (m_moosPowerTime > 0
? ")"
+14 -2
View File
@@ -54,8 +54,8 @@ private:
void applyDisable(); // 关输出
double computeTargetPower(double now); // 目标功率(含超时/斜率处理)
double computeCmdCurrent(double targetP); // 功率折算电流(含限幅)
void pollDevice(double now); // 测量轮询与错误查询
void pollError(double now);
void pollDevice(); // 异步测量轮询(不阻塞控制循环)
void pollError(double now); // 异步错误/FUNC 回读(1Hz 节流)
void dbPeriodic(double now); // 状态落库 + 历史清理
void pruneHistory(double now);
@@ -105,6 +105,7 @@ private:
bool m_webEnable = true;
double m_logKeepHours = 72; // 历史保留时长(0=永久)
int m_measLogPeriodMs = 100; // 测量落库周期(默认=控制周期100ms)
bool m_measUseFetch = true; // 测量命令用 FETCh(读缓存,快);false=MEAS(触发测量,慢)
//---- 设备配置值(web/配置可改;>0 表示需要下发)----
double m_voltLimit = 0;
@@ -139,12 +140,22 @@ private:
std::string m_lastError;
double m_measV = 0, m_measI = 0, m_measP = 0;
double m_measVTime = 0, m_measITime = 0, m_measPTime = 0;
// 测量查询诊断(定位"设备在输出但测量不更新")
unsigned long m_measOk = 0; // 查询成功次数
unsigned long m_measFail = 0; // 查询失败次数
std::string m_lastMeasErr; // 最近一次失败原因
std::string m_devFunc; // FUNC? 回读:CURR / VOLT
std::string m_devFuncMode; // FUNC:MODE? 回读:FIX/LIST/...
bool m_funcModeFixWarn = false; // 回读非 FIX 时告警
double m_cmdCurrent = 0; // 最近下发的电流指令(CC)
double m_cmdVolt = 0; // 最近下发的电压指令(CV)
//---- 远控/本控状态(IT6000C 无 SYST:REM? 查询,按最近指令+发送结果推断)----
bool m_remoteRequested = true; // true=需要远控(每次连接自动发 SYST:REM)
std::string m_remoteState = "unknown"; // remote / local / unknown
std::string m_remoteFeed = "-"; // 最近远控/本控指令执行结果
double m_remoteFeedTime = 0; // 最近反馈时间
//---- 控制线程私有(无需加锁)----
bool m_remoteApplied = false;
bool m_cfgApplied = false;
@@ -155,6 +166,7 @@ private:
double m_lastDbWrite = 0;
double m_lastPrune = 0;
int m_measRotate = 0;
std::atomic<bool> m_measPending{false}; // 异步测量查询进行中(节流)
std::vector<WebCmd> m_webCmdQueue; // web 线程入队 -> 控制线程出队
std::atomic<bool> m_ctrlRunning{false};
+1
View File
@@ -81,6 +81,7 @@ void showExampleConfigAndExit() {
blk(" dbpath = pELoad.db // SQLite 数据库路径 ");
blk(" log_keep_hours= 72 // 历史保留时长(小时,0=永久) ");
blk(" meas_log_period_ms = 100 // 实测V/I/P落库周期ms(0=仅1Hz快照) ");
blk(" meas_source = fetch // 测量命令: fetch=FETCh(快) / meas=MEAS(慢) ");
blk(" web_port = 18082 // 网页端口(避开8080/8090/18080/18081) ");
blk(" web_enable = true // 是否启用网页 ");
blk(" logpath = pELoad.log // 日志路径 ");
+8 -3
View File
@@ -13,12 +13,13 @@ bool IT6000C::identify(std::string& idn) {
}
bool IT6000C::remote() {
// 连接建立后必须先进入远控,否则设置命令被面板本地态拒绝
return m_scpi.cmd("SYST:REM");
// 连接建立后必须先进入远控,否则设置命令被面板本地态拒绝。
// 用 cmdConfirmed 等待真实写入结果,明确反馈成功/失败。
return m_scpi.cmdConfirmed("SYST:REM", 300);
}
bool IT6000C::local() {
return m_scpi.cmd("SYST:LOC");
return m_scpi.cmdConfirmed("SYST:LOC", 300);
}
bool IT6000C::reset() {
@@ -118,4 +119,8 @@ bool IT6000C::measureVoltage(double& v) { return queryDouble("MEAS:VOLT?", v); }
bool IT6000C::measureCurrent(double& i) { return queryDouble("MEAS:CURR?", i); }
bool IT6000C::measurePower(double& p) { return queryDouble("MEAS:POW?", p); }
bool IT6000C::fetchVoltage(double& v) { return queryDouble("FETCh:VOLT?", v); }
bool IT6000C::fetchCurrent(double& i) { return queryDouble("FETCh:CURR?", i); }
bool IT6000C::fetchPower(double& p) { return queryDouble("FETCh:POW?", p); }
} // namespace eload
+5 -1
View File
@@ -63,9 +63,13 @@ public:
bool setPowerLimitNeg(double watt); // POW:LIM:NEG
// 测量
bool measureVoltage(double& v); // MEAS:VOLT?
bool measureVoltage(double& v); // MEAS:VOLT?(触发一次测量,较慢)
bool measureCurrent(double& i); // MEAS:CURR?
bool measurePower(double& p); // MEAS:POW?
// 读取表头缓存测量值(不触发测量,响应快,适合 100ms 轮询)
bool fetchVoltage(double& v); // FETCh:VOLT?
bool fetchCurrent(double& i); // FETCh:CURR?
bool fetchPower(double& p); // FETCh:POW?
private:
bool setDouble(const char* scpi, double v);
+63 -1
View File
@@ -279,8 +279,10 @@ void ScpiClient::runQueue() {
item->done = true;
++m_txErrorCount;
if (m_logSink) m_logSink(item->cmd, "<send failed>");
closeSocket();
m_queueCv.notify_all();
// 异步回调(IO 线程内,注意不要在锁内调用)
if (item->cb) item->cb(false, "");
closeSocket();
return;
}
if (item->expectReply) {
@@ -295,6 +297,7 @@ void ScpiClient::runQueue() {
if (!m_connected) {
item->done = true;
m_queueCv.notify_all();
if (item->cb) item->cb(false, "");
return;
}
}
@@ -304,6 +307,7 @@ void ScpiClient::runQueue() {
}
item->done = true;
m_queueCv.notify_all();
if (item->cb) item->cb(item->ok, item->resp);
}
}
@@ -326,6 +330,50 @@ bool ScpiClient::cmd(const std::string& scpi) {
return true;
}
bool ScpiClient::cmdConfirmed(const std::string& scpi, int timeoutMs) {
std::shared_ptr<Work> item(new Work());
item->cmd = scpi;
item->expectReply = false;
{
std::lock_guard<std::mutex> lock(m_queueMutex);
if (m_queue.size() >= 64) {
++m_txErrorCount;
return false;
}
m_queue.push_back(item);
}
m_queueCv.notify_all();
// 等待 IO 线程处理完成:done 表示已尝试写入,ok 为写入结果
double t0 = MOOSTime(false);
{
std::unique_lock<std::mutex> lock(m_queueMutex);
while (!item->done) {
int elapsed = static_cast<int>((MOOSTime(false) - t0) * 1000.0);
int remain = timeoutMs - elapsed;
if (remain <= 0) break;
m_queueCv.wait_for(lock, std::chrono::milliseconds(remain < 5 ? 5 : remain));
}
if (!item->done) {
// 超时未处理:从队列移除(若仍存在),防止后续错配
for (std::deque<std::shared_ptr<Work> >::iterator it = m_queue.begin();
it != m_queue.end(); ++it) {
if (*it == item) {
m_queue.erase(it);
break;
}
}
++m_errorCount;
return false;
}
}
if (!item->ok) {
++m_txErrorCount;
return false;
}
return true;
}
bool ScpiClient::query(const std::string& scpi, std::string& resp, int timeoutMs) {
// 同一时刻仅一个同步查询(避免控制线程/网页线程交叉)
std::lock_guard<std::mutex> qlock(m_queryMutex);
@@ -372,4 +420,18 @@ bool ScpiClient::query(const std::string& scpi, std::string& resp, int timeoutMs
return true;
}
bool ScpiClient::queryAsync(const std::string& scpi, QueryCallback cb) {
std::shared_ptr<Work> item(new Work());
item->cmd = scpi;
item->expectReply = true;
item->cb = std::move(cb);
{
std::lock_guard<std::mutex> lock(m_queueMutex);
if (m_queue.size() >= 64) return false;
m_queue.push_back(item);
}
m_queueCv.notify_all();
return true;
}
} // namespace eload
+12
View File
@@ -50,12 +50,23 @@ public:
bool isConnected() const { return m_connected; }
// 写命令(无响应期待)。未连接/发送失败按错误计数并丢弃。
// 返回 true 仅表示"已入队",不保证写入成功。
bool cmd(const std::string& scpi);
// 写命令并等待确认:阻塞到 IO 线程真正把命令写入 socket(或超时)。
// 用于需要明确成功/失败反馈的关键指令(如 SYST:REM / SYST:LOC)。
bool cmdConfirmed(const std::string& scpi, int timeoutMs = 300);
// 同步查询:发送 scpi 并等待一行响应。
// timeoutMs 建议不大于控制周期(100ms 循环里建议 <= 80)。
bool query(const std::string& scpi, std::string& resp, int timeoutMs = 200);
// 异步查询:发送 scpi,IO 线程收到响应后调用回调(IO 线程内)。
// 不阻塞调用线程——用于测量轮询,避免拖慢 100ms 功率控制循环。
// 回调注意:运行在 IO 线程,勿直接调用会阻塞的操作。
using QueryCallback = std::function<void(bool ok, const std::string& resp)>;
bool queryAsync(const std::string& scpi, QueryCallback cb);
// 统计
unsigned long txCount() const { return m_txCount; }
unsigned long txErrorCount() const { return m_txErrorCount; }
@@ -74,6 +85,7 @@ private:
std::string resp;
bool done;
bool ok;
QueryCallback cb; // 异步查询回调(IO 线程调用)
Work() : expectReply(false), done(false), ok(false) {}
};
+4
View File
@@ -73,6 +73,10 @@ ProcessConfig = pELoad
// 0=关闭高频记录,仅保留 1Hz 状态快照)
meas_log_period_ms = 100
// 测量命令来源:fetch=FETCh(读设备表头缓存,不触发测量,响应快,推荐);
// meas=MEAS(每次触发设备重新测量,响应慢)
meas_source = fetch
//======== 网页 ========
// (避开 pCCU 8080 / pPowerManger 8090 / pPowerMangerHost 18080 / pMotor 18081)
web_port = 18082
+58 -24
View File
@@ -52,7 +52,9 @@ bool DbStore::open() {
" outp INTEGER NOT NULL,"
" mode TEXT NOT NULL,"
" target_power REAL,"
" volt_set REAL,"
" cmd_current REAL,"
" cmd_volt REAL,"
" meas_voltage REAL,"
" meas_current REAL,"
" meas_power REAL,"
@@ -82,19 +84,42 @@ bool DbStore::open() {
" meas_current REAL,"
" meas_power REAL,"
" cmd_current REAL,"
" cmd_volt REAL,"
" target_power REAL,"
" outp INTEGER"
");")) return false;
exec("CREATE INDEX IF NOT EXISTS idx_meas_log_time ON meas_log(time);");
// 旧库缺列自动迁移(协议/字段演进后旧数据库可自动补齐)
ensureColumn("eload_log", "volt_set", "REAL");
ensureColumn("eload_log", "cmd_volt", "REAL");
ensureColumn("meas_log", "cmd_volt", "REAL");
return prepareInsert() && prepareMeasInsert() && prepareScpiInsert();
}
// 检查列是否存在,缺失则 ALTER TABLE ADD COLUMN(旧库迁移)
bool DbStore::ensureColumn(const std::string& table, const std::string& col, const std::string& type) {
if (!m_db) return false;
std::string sql = "SELECT COUNT(*) FROM pragma_table_info('" + table + "') WHERE name='" + col + "';";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(m_db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return false;
int n = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
if (n > 0) return true; // 列已存在
std::string alt = "ALTER TABLE " + table + " ADD COLUMN " + col + " " + type + ";";
return exec(alt.c_str());
}
bool DbStore::prepareInsert() {
const char* sql =
"INSERT INTO eload_log (time, connected, outp, mode, target_power, cmd_current, "
"meas_voltage, meas_current, meas_power, func_mode, tx_count, rx_count, tx_err) "
"VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13);";
"INSERT INTO eload_log (time, connected, outp, mode, target_power, volt_set, "
"cmd_current, cmd_volt, meas_voltage, meas_current, meas_power, func_mode, "
"tx_count, rx_count, tx_err) "
"VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15);";
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtInsert, nullptr) != SQLITE_OK) {
m_lastError = sqlite3_errmsg(m_db);
return false;
@@ -105,7 +130,7 @@ bool DbStore::prepareInsert() {
bool DbStore::prepareMeasInsert() {
const char* sql =
"INSERT INTO meas_log (time, meas_voltage, meas_current, meas_power, "
"cmd_current, target_power, outp) VALUES (?1,?2,?3,?4,?5,?6,?7);";
"cmd_current, cmd_volt, target_power, outp) VALUES (?1,?2,?3,?4,?5,?6,?7,?8);";
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtMeasInsert, nullptr) != SQLITE_OK) {
m_lastError = sqlite3_errmsg(m_db);
return false;
@@ -144,7 +169,8 @@ void DbStore::close() {
}
void DbStore::insertStatus(bool connected, bool outp, const std::string& mode,
double targetPower, double cmdCurrent,
double targetPower, double voltSet,
double cmdCurrent, double cmdVolt,
double measV, double measI, double measP,
const std::string& funcMode,
unsigned long tx, unsigned long rx, unsigned long txErr) {
@@ -159,7 +185,9 @@ void DbStore::insertStatus(bool connected, bool outp, const std::string& mode,
sqlite3_bind_int(m_stmtInsert, i++, outp ? 1 : 0);
sqlite3_bind_text(m_stmtInsert, i++, mode.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_double(m_stmtInsert, i++, targetPower);
sqlite3_bind_double(m_stmtInsert, i++, voltSet);
sqlite3_bind_double(m_stmtInsert, i++, cmdCurrent);
sqlite3_bind_double(m_stmtInsert, i++, cmdVolt);
sqlite3_bind_double(m_stmtInsert, i++, measV);
sqlite3_bind_double(m_stmtInsert, i++, measI);
sqlite3_bind_double(m_stmtInsert, i++, measP);
@@ -175,7 +203,7 @@ void DbStore::insertStatus(bool connected, bool outp, const std::string& mode,
}
void DbStore::insertMeas(double measV, double measI, double measP,
double cmdCurrent, double targetPower, bool outp) {
double cmdCurrent, double cmdVolt, double targetPower, bool outp) {
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_db || !m_stmtMeasInsert) return;
@@ -186,8 +214,9 @@ void DbStore::insertMeas(double measV, double measI, double measP,
sqlite3_bind_double(m_stmtMeasInsert, 3, measI);
sqlite3_bind_double(m_stmtMeasInsert, 4, measP);
sqlite3_bind_double(m_stmtMeasInsert, 5, cmdCurrent);
sqlite3_bind_double(m_stmtMeasInsert, 6, targetPower);
sqlite3_bind_int(m_stmtMeasInsert, 7, outp ? 1 : 0);
sqlite3_bind_double(m_stmtMeasInsert, 6, cmdVolt);
sqlite3_bind_double(m_stmtMeasInsert, 7, targetPower);
sqlite3_bind_int(m_stmtMeasInsert, 8, outp ? 1 : 0);
int rc = sqlite3_step(m_stmtMeasInsert);
if (rc != SQLITE_DONE) {
@@ -219,8 +248,9 @@ std::vector<ELoadLogRow> DbStore::queryRecentStatus(int limit) {
if (limit > 1000) limit = 1000;
const char* sql =
"SELECT id, time, connected, outp, mode, target_power, cmd_current, "
"meas_voltage, meas_current, meas_power, func_mode, tx_count, rx_count, tx_err "
"SELECT id, time, connected, outp, mode, target_power, volt_set, "
"cmd_current, cmd_volt, meas_voltage, meas_current, meas_power, func_mode, "
"tx_count, rx_count, tx_err "
"FROM eload_log ORDER BY id DESC LIMIT ";
std::string q = sql + std::to_string(limit) + ";";
@@ -237,15 +267,17 @@ std::vector<ELoadLogRow> DbStore::queryRecentStatus(int limit) {
r.outp = sqlite3_column_int(stmt, 3);
r.mode = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
r.targetPower = sqlite3_column_double(stmt, 5);
r.cmdCurrent = sqlite3_column_double(stmt, 6);
r.measVoltage = sqlite3_column_double(stmt, 7);
r.measCurrent = sqlite3_column_double(stmt, 8);
r.measPower = sqlite3_column_double(stmt, 9);
const char* fm = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 10));
r.voltSet = sqlite3_column_double(stmt, 6);
r.cmdCurrent = sqlite3_column_double(stmt, 7);
r.cmdVolt = sqlite3_column_double(stmt, 8);
r.measVoltage = sqlite3_column_double(stmt, 9);
r.measCurrent = sqlite3_column_double(stmt, 10);
r.measPower = sqlite3_column_double(stmt, 11);
const char* fm = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 12));
r.funcMode = fm ? fm : "";
r.txCount = sqlite3_column_int64(stmt, 11);
r.rxCount = sqlite3_column_int64(stmt, 12);
r.txErr = sqlite3_column_int64(stmt, 13);
r.txCount = sqlite3_column_int64(stmt, 13);
r.rxCount = sqlite3_column_int64(stmt, 14);
r.txErr = sqlite3_column_int64(stmt, 15);
rows.push_back(std::move(r));
}
sqlite3_finalize(stmt);
@@ -324,7 +356,7 @@ std::vector<MeasRow> DbStore::queryMeas(int limit) {
if (limit > 5000) limit = 5000;
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
"cmd_current, target_power, outp FROM meas_log "
"cmd_current, cmd_volt, target_power, outp FROM meas_log "
"ORDER BY id DESC LIMIT " + std::to_string(limit) + ";";
sqlite3_stmt* stmt = nullptr;
@@ -340,8 +372,9 @@ std::vector<MeasRow> DbStore::queryMeas(int limit) {
r.measCurrent = sqlite3_column_double(stmt, 3);
r.measPower = sqlite3_column_double(stmt, 4);
r.cmdCurrent = sqlite3_column_double(stmt, 5);
r.targetPower = sqlite3_column_double(stmt, 6);
r.outp = sqlite3_column_int(stmt, 7);
r.cmdVolt = sqlite3_column_double(stmt, 6);
r.targetPower = sqlite3_column_double(stmt, 7);
r.outp = sqlite3_column_int(stmt, 8);
rows.push_back(std::move(r));
}
sqlite3_finalize(stmt);
@@ -357,7 +390,7 @@ std::vector<MeasRow> DbStore::queryMeasRange(double startSec, double endSec, int
if (endSec <= startSec) return rows;
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
"cmd_current, target_power, outp FROM meas_log "
"cmd_current, cmd_volt, target_power, outp FROM meas_log "
"WHERE time >= " + std::to_string(startSec) +
" AND time <= " + std::to_string(endSec) +
" ORDER BY id LIMIT " + std::to_string(maxRows) + ";";
@@ -375,8 +408,9 @@ std::vector<MeasRow> DbStore::queryMeasRange(double startSec, double endSec, int
r.measCurrent = sqlite3_column_double(stmt, 3);
r.measPower = sqlite3_column_double(stmt, 4);
r.cmdCurrent = sqlite3_column_double(stmt, 5);
r.targetPower = sqlite3_column_double(stmt, 6);
r.outp = sqlite3_column_int(stmt, 7);
r.cmdVolt = sqlite3_column_double(stmt, 6);
r.targetPower = sqlite3_column_double(stmt, 7);
r.outp = sqlite3_column_int(stmt, 8);
rows.push_back(std::move(r));
}
sqlite3_finalize(stmt);
+8 -3
View File
@@ -27,8 +27,10 @@ struct ELoadLogRow {
int connected;
int outp;
std::string mode; // follow / manual / current / volt
double targetPower; // 目标功率 W
double targetPower; // 目标功率 W(volt 模式为 0)
double voltSet; // 目标电压 V(volt 模式)
double cmdCurrent; // 指令电流 A
double cmdVolt; // 指令电压 V(volt 模式)
double measVoltage; // 实测电压 V
double measCurrent; // 实测电流 A
double measPower; // 实测功率 W
@@ -46,6 +48,7 @@ struct MeasRow {
double measCurrent;
double measPower;
double cmdCurrent;
double cmdVolt;
double targetPower;
int outp;
};
@@ -62,14 +65,15 @@ public:
// 运行状态快照落库(控制线程周期调用)
void insertStatus(bool connected, bool outp, const std::string& mode,
double targetPower, double cmdCurrent,
double targetPower, double voltSet,
double cmdCurrent, double cmdVolt,
double measV, double measI, double measP,
const std::string& funcMode,
unsigned long tx, unsigned long rx, unsigned long txErr);
// 高采样测量落库(每个控制周期调用,meas_log)
void insertMeas(double measV, double measI, double measP,
double cmdCurrent, double targetPower, bool outp);
double cmdCurrent, double cmdVolt, double targetPower, bool outp);
// SCPI 非常规收发落库:text 为 "CMD" 或 "CMD => RESP"(已配对)
// direction: 1=命令 0=查询应答
@@ -97,6 +101,7 @@ public:
private:
bool exec(const char* sql);
bool ensureColumn(const std::string& table, const std::string& col, const std::string& type);
bool prepareInsert();
bool prepareMeasInsert();
bool prepareScpiInsert();
+286 -181
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:控制(步骤2/3)----
html += '<div class="card"><h2>②③ 参数与输出控制</h2>';
// 环路优先选择
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>';
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
//---- 卡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>' : ''))
+ 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
+ (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>' : '输出未开启,开启前请完成步骤②参数设置'))+'</div>'
+ '</div>';
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>'
: '输出未开启,开启前请完成步骤②参数设置');
//---- 卡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>';
//---- 卡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 调试 ----
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>';
//---- 卡5:SCPI ----
setDisabled('btnRawSend', !connected);
setDisabled('rawCmd', !connected);
//---- 卡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(){