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:
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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 // 日志路径 ");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(){
|
||||
|
||||
Reference in New Issue
Block a user