Compare commits
5
Commits
38c489caec
...
e59f7e3d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e59f7e3d2e | ||
|
|
c610190c54 | ||
|
|
237e17ea50 | ||
|
|
91be306a92 | ||
|
|
2a34b79461 |
@@ -253,6 +253,7 @@ bin/pCCU
|
||||
bin/pccuTest
|
||||
bin/pCanBridge
|
||||
bin/pMotor
|
||||
bin/pELoad
|
||||
|
||||
# CI test reports
|
||||
reports/
|
||||
|
||||
+43
-2
@@ -1,7 +1,7 @@
|
||||
// MOOS file
|
||||
// 板卡 (RK3588 / Ubuntu 20.04) 专用 mission 配置
|
||||
// 六个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU / pCanBridge / pMotor)
|
||||
// 共用本文件。
|
||||
// 七个 systemd 服务(moosdb / pPowerManger / pPowerMangerHost / pCCU /
|
||||
// pCanBridge / pMotor / pELoad)共用本文件。
|
||||
// 由 scripts/deploy.sh 推送到板卡 /root/work/h100/missions/h100.moos
|
||||
|
||||
ServerHost = localhost
|
||||
@@ -118,3 +118,44 @@ ProcessConfig = pMotor
|
||||
|
||||
logpath = /root/work/h100/data/pMotor.log
|
||||
}
|
||||
|
||||
ProcessConfig = pELoad
|
||||
{
|
||||
AppTick = 10
|
||||
CommsTick = 4
|
||||
|
||||
//======== 电子负载(IT6000C)以太网地址 ========
|
||||
// 仪器 LAN 配置的 IP 与 Raw Socket 端口号(出厂默认 30000)
|
||||
eload_ip = 192.168.200.100
|
||||
eload_port = 30000
|
||||
|
||||
//======== 功率控制 ========
|
||||
// MOOS 功率消息变量名(实时电机功率,W,正值=负载吸收)
|
||||
power_var = MOTOR_POWER
|
||||
ctrl_period_ms = 100
|
||||
power_stale_sec = 3
|
||||
current_sign = negative
|
||||
nominal_volt = 500
|
||||
max_power = 0
|
||||
max_current = 0
|
||||
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
|
||||
|
||||
logpath = /root/work/h100/data/pELoad.log
|
||||
}
|
||||
|
||||
+28
-3
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/build-board.sh # 同步+编译+部署(不重启)
|
||||
# ./scripts/build-board.sh --start # 编译后重启全部服务(含 pCanBridge/pMotor)
|
||||
# ./scripts/build-board.sh --start # 编译后重启全部服务(含 pCanBridge/pMotor/pELoad)
|
||||
# ./scripts/build-board.sh --clean # 板卡上重新 cmake 再编译
|
||||
# ./scripts/build-board.sh --jobs 4 # 指定并行度(默认板卡 nproc)
|
||||
#
|
||||
@@ -68,7 +68,7 @@ DO_CLEAN=0
|
||||
CCU_HOST=""
|
||||
CCU_PORT=""
|
||||
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor)
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost pCCU pCanBridge pMotor pELoad)
|
||||
|
||||
info() { printf '\033[1;36m[build-board]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[build-board]\033[0m %s\n' "$*"; }
|
||||
@@ -212,7 +212,8 @@ ${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_BIN} && \
|
||||
cp -f ${BOARD_SRC}/bin/pCCU ${BOARD_BIN}/pCCU && \
|
||||
cp -f ${BOARD_SRC}/bin/pCanBridge ${BOARD_BIN}/pCanBridge && \
|
||||
cp -f ${BOARD_SRC}/bin/pMotor ${BOARD_BIN}/pMotor && \
|
||||
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU ${BOARD_BIN}/pCanBridge ${BOARD_BIN}/pMotor" \
|
||||
cp -f ${BOARD_SRC}/bin/pELoad ${BOARD_BIN}/pELoad && \
|
||||
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost ${BOARD_BIN}/pCCU ${BOARD_BIN}/pCanBridge ${BOARD_BIN}/pMotor ${BOARD_BIN}/pELoad" \
|
||||
|| { err "部署产物失败"; exit 1; }
|
||||
ok "产物已部署"
|
||||
|
||||
@@ -262,6 +263,30 @@ EOF
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed pMotor 2>/dev/null || true"
|
||||
ok "pMotor.service 已安装"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 4.7 安装/刷新 pELoad systemd unit(幂等)
|
||||
# 电子负载(IT6000C)控制程序:以太网直连设备,不依赖 pCanBridge
|
||||
#-------------------------------------------------------------------
|
||||
info "安装 pELoad.service ..."
|
||||
${SSH} "${SSH_TARGET}" "cat > /etc/systemd/system/pELoad.service" <<EOF
|
||||
[Unit]
|
||||
Description=pELoad Electronic Load (IT6000C) for H100 Power Manager
|
||||
After=moosdb.service
|
||||
Requires=moosdb.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${BOARD_BIN}
|
||||
ExecStart=${BOARD_BIN}/pELoad --alias=pELoad /root/work/h100/missions/h100.moos
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
${SSH} "${SSH_TARGET}" "systemctl daemon-reload && systemctl reset-failed pELoad 2>/dev/null || true"
|
||||
ok "pELoad.service 已安装"
|
||||
|
||||
echo ""
|
||||
ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)"
|
||||
${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60"
|
||||
|
||||
+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 "停止服务失败"
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ add_subdirectory(pPowerMangerHost)
|
||||
add_subdirectory(pCCU)
|
||||
add_subdirectory(pCanBridge)
|
||||
add_subdirectory(pMotor)
|
||||
add_subdirectory(pELoad)
|
||||
##############################################################################
|
||||
# END of CMakeLists.txt
|
||||
##############################################################################
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#--------------------------------------------------------
|
||||
# The CMakeLists.txt for: pELoad
|
||||
# 电子负载(IT6000C 双向电源)控制程序:
|
||||
# 以太网 SCPI 直连 <-> 100ms 功率控制 <- MOOSDB 功率消息
|
||||
# 网页人机交互 + sqlite3 数据存储
|
||||
#--------------------------------------------------------
|
||||
|
||||
if (${WIN32})
|
||||
SET(SYSTEM_LIBS wsock32)
|
||||
else (${WIN32})
|
||||
SET(SYSTEM_LIBS m pthread)
|
||||
endif (${WIN32})
|
||||
|
||||
# 复用仓库内 pPowerManger 的公共源码(相对路径引用,避免重复维护)
|
||||
SET(PM_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../pPowerManger)
|
||||
|
||||
SET(SHARED_SRC
|
||||
${PM_DIR}/logc/loguru.cpp
|
||||
${PM_DIR}/sqlit3/sqlite3.c
|
||||
${PM_DIR}/httpserver/mongoose.c
|
||||
)
|
||||
|
||||
SET(ELOAD_DRIVER_SRC
|
||||
driver/ScpiClient.cpp
|
||||
driver/IT6000C.cpp
|
||||
)
|
||||
|
||||
SET(ELOAD_STORE_SRC
|
||||
store/DbStore.cpp
|
||||
)
|
||||
|
||||
SET(ELOAD_WEB_SRC
|
||||
web/WebServer.cpp
|
||||
)
|
||||
|
||||
SET(SRC
|
||||
${SHARED_SRC}
|
||||
${ELOAD_DRIVER_SRC}
|
||||
${ELOAD_STORE_SRC}
|
||||
${ELOAD_WEB_SRC}
|
||||
ELoad.cpp
|
||||
ELoad_Info.cpp
|
||||
main.cpp
|
||||
)
|
||||
|
||||
ADD_EXECUTABLE(pELoad ${SRC})
|
||||
|
||||
TARGET_INCLUDE_DIRECTORIES(pELoad PRIVATE
|
||||
${PM_DIR}
|
||||
${PM_DIR}/sqlit3
|
||||
${PM_DIR}/httpserver
|
||||
${PM_DIR}/logc
|
||||
)
|
||||
|
||||
TARGET_LINK_LIBRARIES(pELoad
|
||||
${MOOS_LIBRARIES}
|
||||
apputil
|
||||
mbutil
|
||||
m
|
||||
pthread
|
||||
jsoncpp
|
||||
dl
|
||||
${SYSTEM_LIBS}
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
#ifndef PELOAD_ELOAD_H
|
||||
#define PELOAD_ELOAD_H
|
||||
|
||||
#define UNIX
|
||||
#include "MOOS/libMOOS/Thirdparty/AppCasting/AppCastingMOOSApp.h"
|
||||
#include "driver/ScpiClient.h"
|
||||
#include "driver/IT6000C.h"
|
||||
#include "store/DbStore.h"
|
||||
#include "web/WebServer.h"
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// ELoad:pELoad 程序主类(MOOS 应用外壳)——电子负载(IT6000C)控制程序。
|
||||
//
|
||||
// 数据流:
|
||||
// 控制回路(独立线程,默认 100ms 周期):
|
||||
// MOOSDB 功率消息(power_var,实时电机功率 W) -> 目标功率 -> I = P/U
|
||||
// -> SCPI CURR 指令 -> 以太网 Raw Socket(默认 30000) -> IT6000C
|
||||
// (CC 优先模式,负电流=吸收电能,模拟电机负载特性)
|
||||
// 测量回路:MEAS:VOLT?/MEAS:CURR?/MEAS:POW? 轮询 -> 状态快照
|
||||
// 上行发布:ELOAD_STATUS(JSON,1Hz)、ELOAD_MEAS_POWER(double)
|
||||
// 人机交互:网页(WebSocket 1Hz 快照 + /api/* 控制接口)
|
||||
// 数据存储:sqlite3(eload_log 运行快照 + scpi_log 非常规指令记录)
|
||||
//
|
||||
// 设备说明见 docs/电子负载说明手册/
|
||||
// (IT6000C User Manual-CN.pdf / IT6000C-Programming-Guide.pdf)。
|
||||
//============================================================================
|
||||
|
||||
class ELoad : public AppCastingMOOSApp {
|
||||
public:
|
||||
ELoad();
|
||||
~ELoad();
|
||||
|
||||
protected:
|
||||
bool OnNewMail(MOOSMSG_LIST &NewMail);
|
||||
bool Iterate();
|
||||
bool OnConnectToServer();
|
||||
bool OnStartUp();
|
||||
bool buildReport();
|
||||
void registerVariables();
|
||||
|
||||
private:
|
||||
//---- 控制回路(独立 100ms 周期线程)----
|
||||
void ctrlThreadFunc();
|
||||
void processWebCmds();
|
||||
void doControlCycle(double now);
|
||||
void applyEnable(double now); // 使能序列:远控+模式+限值+开输出
|
||||
void applyDisable(); // 关输出
|
||||
double computeTargetPower(double now); // 目标功率(含超时/斜率处理)
|
||||
double computeCmdCurrent(double targetP); // 功率折算电流(含限幅)
|
||||
void syncDeviceConfigOnce(double now); // 连接后从设备回读真实配置并接管
|
||||
void pollDevice(); // 异步测量轮询(不阻塞控制循环)
|
||||
void pollError(double now); // 异步错误/FUNC 回读(1Hz 节流)
|
||||
void requestFuncModeReadback(); // 异步回读 FUNC:MODE?(真实反馈驱动显示)
|
||||
void dbPeriodic(double now); // 状态落库 + 历史清理
|
||||
void pruneHistory(double now);
|
||||
|
||||
//---- 网页 ----
|
||||
std::string buildSnapshot();
|
||||
// 返回 (Content-Type, body);body 空串表示 404
|
||||
std::pair<std::string, std::string> handleApi(const std::string& uri, const std::string& query);
|
||||
std::string handleCmdApi(const std::string& query);
|
||||
bool getQueryParam(const std::string& query, const char* key, std::string& out);
|
||||
|
||||
//---- SCPI 收发落库过滤(例行测量不落库;cmd/resp 由 IO 线程配对)----
|
||||
void scpiLogSink(const std::string& cmd, const std::string& resp);
|
||||
|
||||
//---- 网页指令 ----
|
||||
enum WebCmdType {
|
||||
WCMD_CONNECT, WCMD_DISCONNECT,
|
||||
WCMD_OUTP_ON, WCMD_OUTP_OFF,
|
||||
WCMD_REMOTE, WCMD_LOCAL,
|
||||
WCMD_MODE_CC, WCMD_MODE_CV,
|
||||
WCMD_CTRL_FOLLOW, WCMD_CTRL_MANUAL, WCMD_CTRL_CURRENT,
|
||||
WCMD_CLEAR_PROT, WCMD_RESET_DEV, WCMD_IDENTIFY,
|
||||
WCMD_SET_POWER, WCMD_SET_CURRENT, WCMD_SET_VOLT, WCMD_SET_VOLTLIM,
|
||||
WCMD_SET_POWLIM, WCMD_SET_CURRLIM,
|
||||
WCMD_FUNC_MODE // 功能模式 FUNC:MODE(text=模式名)
|
||||
};
|
||||
struct WebCmd {
|
||||
WebCmdType type;
|
||||
double value;
|
||||
std::string text;
|
||||
WebCmd() : type(WCMD_CONNECT), value(0) {}
|
||||
};
|
||||
bool queueWebCmd(WebCmdType t, double v, std::string& err);
|
||||
bool queueWebCmd(WebCmdType t, const std::string& text, std::string& err);
|
||||
|
||||
private:
|
||||
//---- 配置 ----
|
||||
std::string m_host = "192.168.0.10";
|
||||
long m_port = 30000; // Raw Socket 端口(出厂默认 30000)
|
||||
std::string m_powerVar = "MOTOR_POWER";
|
||||
int m_ctrlPeriodMs = 100; // 功率控制周期
|
||||
double m_powerStaleSec= 3.0; // 功率消息超时(0=不判超时)
|
||||
bool m_sinkNegative = true; // true=负电流吸收(负载模拟)
|
||||
double m_nominalVolt = 500.0; // 无电压反馈时的折算电压 V
|
||||
double m_maxPower = 0; // 主机侧功率限幅 W(0=不限)
|
||||
double m_maxCurrent = 0; // 主机侧电流限幅 A(0=不限)
|
||||
double m_slewLimitWps = 0; // 功率斜率限制 W/s(0=不限)
|
||||
std::string m_dbPath = "pELoad.db";
|
||||
std::string m_logPath = "pELoad.log";
|
||||
int m_webPort = 18082;
|
||||
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;
|
||||
double m_currLimitPos = 0;
|
||||
double m_currLimitNeg = 0;
|
||||
double m_powLimitPos = 0;
|
||||
double m_powLimitNeg = 0;
|
||||
bool m_ccPriority = true; // true=CC 优先(负载模拟),false=CV 优先
|
||||
|
||||
//---- 组件 ----
|
||||
ScpiClient m_scpi;
|
||||
IT6000C m_dev;
|
||||
DbStore* m_db = nullptr;
|
||||
WebServer* m_web = nullptr;
|
||||
|
||||
//---- 共享状态(m_stateMutex 保护;控制线程写,MOOS/Web 线程读)----
|
||||
mutable std::mutex m_stateMutex;
|
||||
|
||||
// 控制状态
|
||||
std::string m_mode = "follow"; // follow / manual / current / volt
|
||||
double m_manualPower = 0; // 手动功率目标 W
|
||||
double m_manualCurrent = 0; // 手动电流目标 A
|
||||
double m_voltSet = 0; // CV 优先目标电压 V
|
||||
double m_moosPower = 0; // 最近 MOOS 功率消息
|
||||
double m_moosPowerTime = 0; // 消息到达时刻(MOOSTime)
|
||||
bool m_loadOn = false; // 期望输出状态(控制使能)
|
||||
double m_appliedPower = 0; // 斜率处理后的实际下发功率
|
||||
|
||||
// 设备/链路状态
|
||||
bool m_linkEnable = true; // 自动连接使能
|
||||
std::string m_idn;
|
||||
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 时告警
|
||||
std::string m_funcModeFeed; // 最近功能模式切换指令
|
||||
bool m_funcModeFeedOk = false;
|
||||
double m_funcModeFeedTime = 0;
|
||||
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;
|
||||
bool m_devCfgSyncDone = false; // 连接后是否已回读设备真实配置
|
||||
double m_devCfgSyncTime = 0; // 最近一次配置回读时刻
|
||||
double m_lastSentCurrent = 0;
|
||||
double m_lastSentVolt = 0;
|
||||
double m_lastSendTime = 0;
|
||||
double m_lastErrPoll = 0;
|
||||
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};
|
||||
std::thread m_ctrlThread;
|
||||
|
||||
//---- 统计 ----
|
||||
unsigned long m_pubCount = 0;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_ELOAD_H
|
||||
@@ -0,0 +1,117 @@
|
||||
/****************************************************************/
|
||||
/* NAME: ELoad_Info */
|
||||
/* FILE: ELoad_Info.cpp */
|
||||
/****************************************************************/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include "ELoad_Info.h"
|
||||
#include "ColorParse.h"
|
||||
#include "ReleaseInfo.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
void showSynopsis() {
|
||||
blk("SYNOPSIS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" The pELoad application controls the IT6000C bi-directional ");
|
||||
blk(" DC power supply / electronic load over Ethernet (raw socket ");
|
||||
blk(" SCPI, default port 30000). It provides 100ms periodic power ");
|
||||
blk(" control: subscribes the MOOS power variable (real-time motor ");
|
||||
blk(" power published by other processes) and drives the load to ");
|
||||
blk(" reproduce the motor power profile. A web page is provided ");
|
||||
blk(" for human-machine interaction and measurements are stored ");
|
||||
blk(" in SQLite. Device manual: ");
|
||||
blk(" docs/电子负载说明手册 (IT6000C User Manual-CN / ");
|
||||
blk(" IT6000C-Programming-Guide). ");
|
||||
blk(" ");
|
||||
}
|
||||
|
||||
void showHelpAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("Usage: pELoad file.moos [OPTIONS] ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("Options: ");
|
||||
mag(" --alias","=<ProcessName> ");
|
||||
blk(" Launch pELoad with the given process name ");
|
||||
blk(" rather than pELoad. ");
|
||||
mag(" --example, -e ");
|
||||
blk(" Display example MOOS configuration block. ");
|
||||
mag(" --help, -h ");
|
||||
blk(" Display this help message. ");
|
||||
mag(" --interface, -i ");
|
||||
blk(" Display MOOS publications and subscriptions. ");
|
||||
mag(" --version,-v ");
|
||||
blk(" Display the release version of pELoad. ");
|
||||
blk(" ");
|
||||
blk("Note: If argv[2] does not otherwise match a known option, ");
|
||||
blk(" then it will be interpreted as a run alias. This is ");
|
||||
blk(" to support pAntler launching conventions. ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showExampleConfigAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("pELoad Example MOOS Configuration ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
blk("ProcessConfig = pELoad ");
|
||||
blk("{ ");
|
||||
blk(" AppTick = 10 // 100ms MOOS 节拍 ");
|
||||
blk(" CommsTick = 4 ");
|
||||
blk(" ");
|
||||
blk(" eload_ip = 192.168.0.10 // 电子负载 IP(LAN 配置) ");
|
||||
blk(" eload_port = 30000 // Raw Socket 端口号 ");
|
||||
blk(" power_var = MOTOR_POWER // MOOS 功率消息变量名(W) ");
|
||||
blk(" ctrl_period_ms= 100 // 功率控制周期 ms ");
|
||||
blk(" power_stale_sec = 3 // 功率消息超时秒数(0=不判超时) ");
|
||||
blk(" current_sign = negative // negative=吸收(负载模拟) ");
|
||||
blk(" nominal_volt = 500 // 无电压反馈时的折算电压(V) ");
|
||||
blk(" max_power = 0 // 主机侧功率限幅(W),0=不限 ");
|
||||
blk(" max_current = 0 // 主机侧电流限幅(A),0=不限 ");
|
||||
blk(" slew_limit_wps= 0 // 功率斜率限制(W/s),0=不限 ");
|
||||
blk(" func_priority = cc // 环路优先:cc=电流型负载 / cv=恒压吸收 ");
|
||||
blk(" volt_set = 0 // CV 优先目标电压(V),func_priority=cv 时用 ");
|
||||
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 // 日志路径 ");
|
||||
blk("} ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showInterfaceAndExit() {
|
||||
blk(" ");
|
||||
blu("=============================================================== ");
|
||||
blu("pELoad INTERFACE ");
|
||||
blu("=============================================================== ");
|
||||
blk(" ");
|
||||
showSynopsis();
|
||||
blk(" ");
|
||||
blk("SUBSCRIPTIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" MOTOR_POWER = 实时电机功率消息(W),变量名可配置 ");
|
||||
blk(" (power_var),正值=负载吸收功率 ");
|
||||
blk(" ");
|
||||
blk("PUBLICATIONS: ");
|
||||
blk("------------------------------------ ");
|
||||
blk(" ELOAD_STATUS = 电子负载状态 JSON 串(1Hz) ");
|
||||
blk(" ELOAD_MEAS_POWER = 实测功率 (W, double) ");
|
||||
blk(" ");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void showReleaseInfoAndExit() {
|
||||
showReleaseInfo("pELoad", "gpl");
|
||||
exit(0);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/****************************************************************/
|
||||
/* NAME: ELoad_Info */
|
||||
/* FILE: ELoad_Info.h */
|
||||
/****************************************************************/
|
||||
|
||||
#ifndef ELOAD_INFO_HEADER
|
||||
#define ELOAD_INFO_HEADER
|
||||
|
||||
void showSynopsis();
|
||||
void showHelpAndExit();
|
||||
void showExampleConfigAndExit();
|
||||
void showInterfaceAndExit();
|
||||
void showReleaseInfoAndExit();
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "IT6000C.h"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// 基础
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::identify(std::string& idn) {
|
||||
return m_scpi.query("*IDN?", idn, 300);
|
||||
}
|
||||
|
||||
bool IT6000C::remote() {
|
||||
// 连接建立后必须先进入远控,否则设置命令被面板本地态拒绝。
|
||||
// 用 cmdConfirmed 等待真实写入结果,明确反馈成功/失败。
|
||||
return m_scpi.cmdConfirmed("SYST:REM", 300);
|
||||
}
|
||||
|
||||
bool IT6000C::local() {
|
||||
return m_scpi.cmdConfirmed("SYST:LOC", 300);
|
||||
}
|
||||
|
||||
bool IT6000C::reset() {
|
||||
return m_scpi.cmd("*RST");
|
||||
}
|
||||
|
||||
bool IT6000C::clearStatus() {
|
||||
return m_scpi.cmd("*CLS");
|
||||
}
|
||||
|
||||
bool IT6000C::opComplete() {
|
||||
std::string r;
|
||||
return m_scpi.query("*OPC?", r, 300);
|
||||
}
|
||||
|
||||
bool IT6000C::nextError(std::string& err) {
|
||||
return m_scpi.query("SYST:ERR?", err, 200);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 输出
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::output(bool on) {
|
||||
return m_scpi.cmd(on ? "OUTP 1" : "OUTP 0");
|
||||
}
|
||||
|
||||
bool IT6000C::queryOutput(bool& on) {
|
||||
std::string r;
|
||||
if (!m_scpi.query("OUTP?", r, 200)) return false;
|
||||
on = (r.find("1") != std::string::npos);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IT6000C::clearProtection() {
|
||||
return m_scpi.cmd("OUTP:PROT:CLE");
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 工作模式
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::setFunctionCC() {
|
||||
return m_scpi.cmd("FUNC CURR");
|
||||
}
|
||||
|
||||
bool IT6000C::setFunctionCV() {
|
||||
return m_scpi.cmd("FUNC VOLT");
|
||||
}
|
||||
|
||||
bool IT6000C::queryFunction(std::string& func) {
|
||||
return m_scpi.query("FUNC?", func, 200);
|
||||
}
|
||||
|
||||
bool IT6000C::setFunctionModeFixed() {
|
||||
return m_scpi.cmd("FUNC:MODE FIX");
|
||||
}
|
||||
|
||||
bool IT6000C::queryFunctionMode(std::string& mode) {
|
||||
return m_scpi.query("FUNC:MODE?", mode, 200);
|
||||
}
|
||||
|
||||
//============================================================================
|
||||
// 设定值
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::setDouble(const char* scpi, double v) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%s %.6f", scpi, v);
|
||||
return m_scpi.cmd(buf);
|
||||
}
|
||||
|
||||
bool IT6000C::setCurrent(double amp) { return setDouble("CURR", amp); }
|
||||
bool IT6000C::setVoltage(double volt) { return setDouble("VOLT", volt); }
|
||||
bool IT6000C::setVoltageLimit(double volt){ return setDouble("VOLT:LIM", volt); }
|
||||
bool IT6000C::setCurrentLimitPos(double amp) { return setDouble("CURR:LIM", amp); }
|
||||
bool IT6000C::setCurrentLimitNeg(double amp) { return setDouble("CURR:LIM:NEG", amp); }
|
||||
bool IT6000C::setPowerLimitPos(double watt) { return setDouble("POW:LIM", watt); }
|
||||
bool IT6000C::setPowerLimitNeg(double watt) { return setDouble("POW:LIM:NEG", watt); }
|
||||
|
||||
bool IT6000C::queryCurrent(double& amp) { return queryDouble("CURR?", amp); }
|
||||
bool IT6000C::queryVoltage(double& volt) { return queryDouble("VOLT?", volt); }
|
||||
bool IT6000C::queryVoltageLimit(double& volt) { return queryDouble("VOLT:LIM?", volt); }
|
||||
bool IT6000C::queryCurrentLimitPos(double& amp) { return queryDouble("CURR:LIM?", amp); }
|
||||
bool IT6000C::queryCurrentLimitNeg(double& amp) { return queryDouble("CURR:LIM:NEG?", amp); }
|
||||
bool IT6000C::queryPowerLimitPos(double& watt) { return queryDouble("POW:LIM?", watt); }
|
||||
bool IT6000C::queryPowerLimitNeg(double& watt) { return queryDouble("POW:LIM:NEG?", watt); }
|
||||
|
||||
//============================================================================
|
||||
// 测量
|
||||
//============================================================================
|
||||
|
||||
bool IT6000C::queryDouble(const char* scpi, double& v) {
|
||||
std::string r;
|
||||
if (!m_scpi.query(scpi, r, 150)) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,91 @@
|
||||
#ifndef PELOAD_IT6000C_H
|
||||
#define PELOAD_IT6000C_H
|
||||
|
||||
#include <string>
|
||||
#include "ScpiClient.h"
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// IT6000C:ITECH IT6000C 系列双向可编程直流电源(电子负载)SCPI 命令封装。
|
||||
//
|
||||
// 命令来源:docs/电子负载说明手册/IT6000C-Programming-Guide.pdf
|
||||
// - 双向电源:正电流=输出电能(源),负电流=吸收电能(负载模拟);
|
||||
// - 环路优先模式 FUNC CURR|VOLT:
|
||||
// CC 优先:按设定电流调节,电压限值用 VOLT:LIM(电流型负载模拟);
|
||||
// CV 优先:按设定电压调节,电流限值用 CURR:LIM(恒压吸收);
|
||||
// - 功能模式 FUNC:MODE FIX|LIST|BATT|SOLAR|CARProfile:
|
||||
// 实时功率控制必须保持 FIX,其余为瞬态/波形/电池测试模式;
|
||||
// - 输出开关 OUTP 0|1;
|
||||
// - 测量 MEAS:VOLT? / MEAS:CURR? / MEAS:POW?;
|
||||
// - 远控 SYST:REM(连接后必须先发,否则面板锁定状态不接受设置)。
|
||||
//============================================================================
|
||||
|
||||
struct IT6000CMeas {
|
||||
double voltage;
|
||||
double current;
|
||||
double power;
|
||||
IT6000CMeas() : voltage(0), current(0), power(0) {}
|
||||
};
|
||||
|
||||
class IT6000C {
|
||||
public:
|
||||
explicit IT6000C(ScpiClient& scpi) : m_scpi(scpi) {}
|
||||
|
||||
// 基础
|
||||
bool identify(std::string& idn); // *IDN?
|
||||
bool remote(); // SYST:REM 进入远控
|
||||
bool local(); // SYST:LOC 回面板控制
|
||||
bool reset(); // *RST
|
||||
bool clearStatus(); // *CLS
|
||||
bool opComplete(); // *OPC?
|
||||
bool nextError(std::string& err); // SYST:ERR?
|
||||
|
||||
// 输出
|
||||
bool output(bool on); // OUTP 1|0
|
||||
bool queryOutput(bool& on); // OUTP?
|
||||
bool clearProtection(); // OUTP:PROT:CLE
|
||||
|
||||
// 工作模式
|
||||
bool setFunctionCC(); // FUNC CURR(CC 优先,电流型负载模拟)
|
||||
bool setFunctionCV(); // FUNC VOLT(CV 优先,恒压吸收)
|
||||
bool queryFunction(std::string& func); // FUNC?
|
||||
bool setFunctionModeFixed(); // FUNC:MODE FIX(实时控制须保持 FIX)
|
||||
bool queryFunctionMode(std::string& mode); // FUNC:MODE?
|
||||
|
||||
// 设定值
|
||||
bool setCurrent(double amp); // CURR(正=输出 / 负=吸收)
|
||||
bool setVoltage(double volt); // VOLT(CV 模式设定值)
|
||||
bool setVoltageLimit(double volt); // VOLT:LIM(CC 模式电压限值)
|
||||
bool setCurrentLimitPos(double amp); // CURR:LIM
|
||||
bool setCurrentLimitNeg(double amp); // CURR:LIM:NEG
|
||||
bool setPowerLimitPos(double watt); // POW:LIM
|
||||
bool setPowerLimitNeg(double watt); // POW:LIM:NEG
|
||||
|
||||
// 配置回读(程序重启/重连后从设备同步真实状态)
|
||||
bool queryCurrent(double& amp); // CURR?
|
||||
bool queryVoltage(double& volt); // VOLT?
|
||||
bool queryVoltageLimit(double& volt); // VOLT:LIM?
|
||||
bool queryCurrentLimitPos(double& amp); // CURR:LIM?
|
||||
bool queryCurrentLimitNeg(double& amp); // CURR:LIM:NEG?
|
||||
bool queryPowerLimitPos(double& watt); // POW:LIM?
|
||||
bool queryPowerLimitNeg(double& watt); // POW:LIM:NEG?
|
||||
|
||||
// 测量
|
||||
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);
|
||||
bool queryDouble(const char* scpi, double& v);
|
||||
|
||||
ScpiClient& m_scpi;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_IT6000C_H
|
||||
@@ -0,0 +1,437 @@
|
||||
#include "ScpiClient.h"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
#include "MOOS/libMOOS/Utils/MOOSUtilityFunctions.h"
|
||||
|
||||
namespace eload {
|
||||
|
||||
namespace {
|
||||
// 非阻塞 connect 超时(ms)
|
||||
const int kConnectTOms = 2000;
|
||||
// recv 读超时(s):保证断线能及时检出、stop() 能及时退出
|
||||
const int kRecvTimeoutS = 1;
|
||||
// 连接失败后的重试间隔(ms)
|
||||
const int kRetryInterval = 2000;
|
||||
// 单条命令最大长度(防异常数据撑爆缓冲)
|
||||
const size_t kMaxLineLen = 4096;
|
||||
} // namespace
|
||||
|
||||
ScpiClient::ScpiClient() {
|
||||
m_buf.reserve(4096);
|
||||
}
|
||||
|
||||
ScpiClient::~ScpiClient() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void ScpiClient::configure(const std::string& host, long port) {
|
||||
m_host = host;
|
||||
m_port = port;
|
||||
}
|
||||
|
||||
void ScpiClient::setLogSink(LogSink cb) {
|
||||
m_logSink = std::move(cb);
|
||||
}
|
||||
|
||||
void ScpiClient::setStateCallback(StateCallback cb) {
|
||||
m_stateCb = std::move(cb);
|
||||
}
|
||||
|
||||
bool ScpiClient::start() {
|
||||
if (m_running) return true;
|
||||
if (m_host.empty() || m_port <= 0) {
|
||||
std::cerr << "[ScpiClient] invalid target: " << m_host << ":" << m_port << std::endl;
|
||||
return false;
|
||||
}
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() { threadFunc(); });
|
||||
std::cout << "[ScpiClient] started, target " << m_host << ":" << m_port << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScpiClient::stop() {
|
||||
if (!m_running) return;
|
||||
m_running = false;
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
closeSocket();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 连接一次目标(非阻塞 connect + poll 超时)
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::connectOnce() {
|
||||
m_fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (m_fd < 0) return false;
|
||||
|
||||
// 非阻塞 connect,避免目标不可达时线程长时间卡死
|
||||
int flags = ::fcntl(m_fd, F_GETFL, 0);
|
||||
::fcntl(m_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
struct sockaddr_in addr;
|
||||
std::memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<uint16_t>(m_port));
|
||||
if (::inet_pton(AF_INET, m_host.c_str(), &addr.sin_addr) != 1) {
|
||||
std::cerr << "[ScpiClient] bad host: " << m_host << std::endl;
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
|
||||
int rc = ::connect(m_fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
|
||||
if (rc < 0 && errno != EINPROGRESS) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
if (rc < 0) {
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLOUT;
|
||||
int pr = ::poll(&pfd, 1, kConnectTOms);
|
||||
if (pr <= 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
int err = 0;
|
||||
socklen_t elen = sizeof(err);
|
||||
if (::getsockopt(m_fd, SOL_SOCKET, SO_ERROR, &err, &elen) < 0 || err != 0) {
|
||||
closeSocket();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复阻塞模式 + 读超时 + 禁用 Nagle(100ms 控制环要求低延迟)
|
||||
::fcntl(m_fd, F_SETFL, flags);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = kRecvTimeoutS;
|
||||
tv.tv_usec = 0;
|
||||
::setsockopt(m_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
int one = 1;
|
||||
::setsockopt(m_fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
||||
|
||||
m_connected = true;
|
||||
++m_reconnectCount;
|
||||
std::cout << "[ScpiClient] connected to " << m_host << ":" << m_port << std::endl;
|
||||
if (m_stateCb) m_stateCb(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScpiClient::closeSocket() {
|
||||
bool was = m_connected.exchange(false);
|
||||
if (m_fd >= 0) {
|
||||
::close(m_fd);
|
||||
m_fd = -1;
|
||||
}
|
||||
if (was && m_stateCb) m_stateCb(false);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 写一行 SCPI(LF 结尾),全部写出才算成功
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::writeLine(const std::string& scpi) {
|
||||
std::string frame = scpi;
|
||||
if (frame.empty() || frame[frame.size() - 1] != '\n')
|
||||
frame += '\n';
|
||||
|
||||
size_t sent = 0;
|
||||
while (sent < frame.size()) {
|
||||
ssize_t n = ::send(m_fd, frame.data() + sent, frame.size() - sent, MSG_NOSIGNAL);
|
||||
if (n > 0) {
|
||||
sent += static_cast<size_t>(n);
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
std::cerr << "[ScpiClient] send error: " << std::strerror(errno) << std::endl;
|
||||
return false;
|
||||
}
|
||||
++m_txCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 读一行(LF 结尾),deadlineMs 内未完成返回 false
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::readLine(std::string& line, int deadlineMs) {
|
||||
double t0 = MOOSTime(false);
|
||||
while (m_running) {
|
||||
// 先检查缓冲中是否已有完整行
|
||||
for (size_t i = 0; i < m_buf.size(); ++i) {
|
||||
if (m_buf[i] == '\n') {
|
||||
line.assign(reinterpret_cast<const char*>(m_buf.data()), i);
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + (i + 1));
|
||||
if (!line.empty() && line[line.size() - 1] == '\r')
|
||||
line.erase(line.size() - 1);
|
||||
++m_rxCount;
|
||||
m_lastRxTime = MOOSTime(false);
|
||||
// 收发记录由 runQueue 按“命令-响应”配对后统一回调
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (m_buf.size() > kMaxLineLen * 4) {
|
||||
// 异常数据流,丢弃防止内存膨胀
|
||||
std::cerr << "[ScpiClient] rx buffer overflow, reset" << std::endl;
|
||||
m_buf.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
int elapsed = static_cast<int>((MOOSTime(false) - t0) * 1000.0);
|
||||
int remain = deadlineMs - elapsed;
|
||||
if (remain <= 0) return false;
|
||||
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLIN;
|
||||
int pr = ::poll(&pfd, 1, remain < 20 ? remain : 20);
|
||||
if (pr < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return false;
|
||||
}
|
||||
if (pr == 0) continue;
|
||||
|
||||
uint8_t tmp[1024];
|
||||
ssize_t n = ::recv(m_fd, tmp, sizeof(tmp), 0);
|
||||
if (n > 0) {
|
||||
m_buf.insert(m_buf.end(), tmp, tmp + n);
|
||||
} else if (n == 0) {
|
||||
std::cerr << "[ScpiClient] connection closed by peer" << std::endl;
|
||||
return false;
|
||||
} else {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
||||
std::cerr << "[ScpiClient] recv error: " << std::strerror(errno) << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// IO 线程:连接 -> 取队列 -> 写出 ->(查询时)等响应 -> 断线重连
|
||||
//----------------------------------------------------------------------
|
||||
void ScpiClient::threadFunc() {
|
||||
while (m_running) {
|
||||
if (!m_connected) {
|
||||
if (!connectOnce()) {
|
||||
++m_errorCount;
|
||||
// 分片休眠,保证 stop() 能及时退出
|
||||
for (int i = 0; i < kRetryInterval / 100 && m_running; ++i) {
|
||||
struct timespec ts = {0, 100 * 1000 * 1000}; // 100ms
|
||||
nanosleep(&ts, nullptr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
m_buf.clear();
|
||||
}
|
||||
|
||||
runQueue();
|
||||
|
||||
if (!m_connected) continue;
|
||||
|
||||
// 无命令时短暂让出 CPU(poll 空转兼作断线检测)
|
||||
struct pollfd pfd;
|
||||
pfd.fd = m_fd;
|
||||
pfd.events = POLLIN;
|
||||
::poll(&pfd, 1, 10);
|
||||
// 仪器主动上报的数据(一般没有)读出丢弃,防止缓冲堆积
|
||||
uint8_t tmp[512];
|
||||
ssize_t n = ::recv(m_fd, tmp, sizeof(tmp), MSG_DONTWAIT);
|
||||
if (n > 0) {
|
||||
m_buf.insert(m_buf.end(), tmp, tmp + n);
|
||||
// 丢弃已完成的行(无查询对应的响应),仅保留可能存在的部分行
|
||||
size_t lastLF = std::string::npos;
|
||||
for (size_t i = m_buf.size(); i > 0; --i) {
|
||||
if (m_buf[i - 1] == '\n') { lastLF = i - 1; break; }
|
||||
}
|
||||
if (lastLF != std::string::npos) {
|
||||
m_buf.erase(m_buf.begin(), m_buf.begin() + (lastLF + 1));
|
||||
}
|
||||
} else if (n == 0) {
|
||||
std::cerr << "[ScpiClient] connection closed by peer" << std::endl;
|
||||
closeSocket();
|
||||
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
|
||||
std::cerr << "[ScpiClient] recv error: " << std::strerror(errno) << std::endl;
|
||||
closeSocket();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScpiClient::runQueue() {
|
||||
while (m_running && m_connected) {
|
||||
std::shared_ptr<Work> item;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.empty()) return;
|
||||
item = m_queue.front();
|
||||
m_queue.pop_front();
|
||||
}
|
||||
|
||||
if (!writeLine(item->cmd)) {
|
||||
// 发送失败:命令未送达,标记失败并视为断线
|
||||
item->ok = false;
|
||||
item->done = true;
|
||||
++m_txErrorCount;
|
||||
if (m_logSink) m_logSink(item->cmd, "<send failed>");
|
||||
m_queueCv.notify_all();
|
||||
// 异步回调(IO 线程内,注意不要在锁内调用)
|
||||
if (item->cb) item->cb(false, "");
|
||||
closeSocket();
|
||||
return;
|
||||
}
|
||||
if (item->expectReply) {
|
||||
std::string line;
|
||||
if (readLine(line, 500)) {
|
||||
item->resp = line;
|
||||
item->ok = true;
|
||||
if (m_logSink) m_logSink(item->cmd, line);
|
||||
} else {
|
||||
item->ok = false;
|
||||
if (m_logSink) m_logSink(item->cmd, m_connected ? "<timeout>" : "<disconnected>");
|
||||
if (!m_connected) {
|
||||
item->done = true;
|
||||
m_queueCv.notify_all();
|
||||
if (item->cb) item->cb(false, "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item->ok = true;
|
||||
if (m_logSink) m_logSink(item->cmd, "");
|
||||
}
|
||||
item->done = true;
|
||||
m_queueCv.notify_all();
|
||||
if (item->cb) item->cb(item->ok, item->resp);
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// 对外接口
|
||||
//----------------------------------------------------------------------
|
||||
bool ScpiClient::cmd(const std::string& scpi) {
|
||||
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();
|
||||
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);
|
||||
|
||||
std::shared_ptr<Work> item(new Work());
|
||||
item->cmd = scpi;
|
||||
item->expectReply = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_queueMutex);
|
||||
if (m_queue.size() >= 64) return false;
|
||||
m_queue.push_back(item);
|
||||
}
|
||||
m_queueCv.notify_all();
|
||||
|
||||
// 等待完成(含查询响应或失败标记)
|
||||
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_errorCount;
|
||||
return false;
|
||||
}
|
||||
resp = item->resp;
|
||||
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
|
||||
@@ -0,0 +1,129 @@
|
||||
#ifndef PELOAD_SCPI_CLIENT_H
|
||||
#define PELOAD_SCPI_CLIENT_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <functional>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// ScpiClient:IT6000C Raw Socket SCPI 客户端(TCP,LF 结尾,自动重连)。
|
||||
//
|
||||
// 协议要点(docs/电子负载说明手册/IT6000C User Manual-CN 2.5.2.3 使用套接字):
|
||||
// - 仪器提供 SCPI 套接字服务(Socket Port 可配置,出厂默认 30000);
|
||||
// - 所有命令必须以换行符(\n)结尾;查询响应同样以换行符结束;
|
||||
// - 仪器最多同时允许 6 个套接字/telnet 连接。
|
||||
//
|
||||
// 线程模型:
|
||||
// - 内部一个 IO 线程:负责连接/重连、命令写出、响应读取;
|
||||
// - cmd() 非阻塞入队(写后无响应);
|
||||
// - query() 同步等待响应(同一时刻仅允许一个查询,互斥保护);
|
||||
// - 本线程不调用任何 MOOS 接口,跨线程数据仅通过回调投递。
|
||||
//============================================================================
|
||||
|
||||
class ScpiClient {
|
||||
public:
|
||||
// SCPI 收发记录回调(落库用,IO 线程内调用):
|
||||
// 命令型:resp 为空;查询型:resp 为响应文本(含失败标记)
|
||||
using LogSink = std::function<void(const std::string& cmd, const std::string& resp)>;
|
||||
// 连接状态变化回调(IO 线程内调用)
|
||||
using StateCallback = std::function<void(bool connected)>;
|
||||
|
||||
ScpiClient();
|
||||
~ScpiClient();
|
||||
|
||||
void configure(const std::string& host, long port); // start 前调用
|
||||
void setLogSink(LogSink cb); // start 前调用
|
||||
void setStateCallback(StateCallback cb); // start 前调用
|
||||
|
||||
bool start(); // 启动 IO 线程(自动连接/重连)
|
||||
void stop();
|
||||
|
||||
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; }
|
||||
unsigned long rxCount() const { return m_rxCount; }
|
||||
unsigned long errorCount() const { return m_errorCount; }
|
||||
unsigned long reconnectCount() const { return m_reconnectCount; }
|
||||
double lastRxTime() const { return m_lastRxTime; }
|
||||
|
||||
const std::string& host() const { return m_host; }
|
||||
long port() const { return m_port; }
|
||||
|
||||
private:
|
||||
struct Work {
|
||||
std::string cmd;
|
||||
bool expectReply;
|
||||
std::string resp;
|
||||
bool done;
|
||||
bool ok;
|
||||
QueryCallback cb; // 异步查询回调(IO 线程调用)
|
||||
Work() : expectReply(false), done(false), ok(false) {}
|
||||
};
|
||||
|
||||
void threadFunc();
|
||||
bool connectOnce();
|
||||
void closeSocket();
|
||||
bool writeLine(const std::string& scpi);
|
||||
// 从 socket 读一行(LF 结尾),deadlineMs 内未完成返回 false
|
||||
bool readLine(std::string& line, int deadlineMs);
|
||||
void runQueue();
|
||||
|
||||
std::string m_host;
|
||||
long m_port = 30000;
|
||||
int m_fd = -1;
|
||||
|
||||
std::atomic<bool> m_running{false};
|
||||
std::atomic<bool> m_connected{false};
|
||||
std::thread m_thread;
|
||||
|
||||
// 命令队列:cmd() 与 query() 共用;IO 线程逐条处理
|
||||
std::mutex m_queueMutex;
|
||||
std::condition_variable m_queueCv;
|
||||
std::deque<std::shared_ptr<Work> > m_queue;
|
||||
// 同一时刻仅允许一个同步查询
|
||||
std::mutex m_queryMutex;
|
||||
|
||||
LogSink m_logSink; // 任意线程注册,IO 线程调用(start 前 set)
|
||||
StateCallback m_stateCb;
|
||||
|
||||
std::vector<uint8_t> m_buf; // 流式接收缓冲(半包/粘包按 \n 切分)
|
||||
|
||||
std::atomic<unsigned long> m_txCount{0};
|
||||
std::atomic<unsigned long> m_txErrorCount{0};
|
||||
std::atomic<unsigned long> m_rxCount{0};
|
||||
std::atomic<unsigned long> m_errorCount{0};
|
||||
std::atomic<unsigned long> m_reconnectCount{0};
|
||||
std::atomic<double> m_lastRxTime{0.0};
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_SCPI_CLIENT_H
|
||||
@@ -0,0 +1,54 @@
|
||||
/************************************************************/
|
||||
/* NAME: pELoad */
|
||||
/* FILE: main.cpp */
|
||||
/************************************************************/
|
||||
|
||||
#include <string>
|
||||
#include "MBUtils.h"
|
||||
#include "ColorParse.h"
|
||||
#include "ELoad.h"
|
||||
#include "ELoad_Info.h"
|
||||
#include "../pPowerManger/logc/loguru.hpp"
|
||||
|
||||
using namespace std;
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
loguru::init(argc, argv);
|
||||
|
||||
string mission_file;
|
||||
string run_command = argv[0];
|
||||
// 默认以程序文件名(去掉路径)作为进程名,保证与 .moos 中 ProcessConfig 匹配
|
||||
{
|
||||
size_t slash = run_command.find_last_of('/');
|
||||
if (slash != string::npos) run_command = run_command.substr(slash + 1);
|
||||
}
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
string argi = argv[i];
|
||||
if ((argi == "-v") || (argi == "--version") || (argi == "-version"))
|
||||
showReleaseInfoAndExit();
|
||||
else if ((argi == "-e") || (argi == "--example") || (argi == "-example"))
|
||||
showExampleConfigAndExit();
|
||||
else if ((argi == "-h") || (argi == "--help") || (argi == "-help"))
|
||||
showHelpAndExit();
|
||||
else if ((argi == "-i") || (argi == "--interface"))
|
||||
showInterfaceAndExit();
|
||||
else if (strEnds(argi, ".moos") || strEnds(argi, ".moos++"))
|
||||
mission_file = argv[i];
|
||||
else if (strBegins(argi, "--alias="))
|
||||
run_command = argi.substr(8);
|
||||
else if (i == 2)
|
||||
run_command = argi;
|
||||
}
|
||||
|
||||
if (mission_file == "")
|
||||
showHelpAndExit();
|
||||
|
||||
LOG_F(INFO, "pELoad launching as %s", run_command.c_str());
|
||||
|
||||
eload::ELoad ELoad;
|
||||
ELoad.Run(run_command.c_str(), mission_file.c_str());
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// MOOS file
|
||||
// pELoad 电子负载(IT6000C 双向可编程直流电源)控制程序 配置示例
|
||||
//
|
||||
// 功能:
|
||||
// - 以太网 Raw Socket SCPI 直连 IT6000C(出厂默认 Socket Port 30000)
|
||||
// - 100ms 周期功率控制:订阅 MOOS 功率消息(实时电机功率,W),
|
||||
// 按功率/电压折算电流指令(CC 优先模式,负电流=吸收电能=负载模拟)
|
||||
// - 网页人机交互(默认 18082 端口)
|
||||
// - sqlite3 数据存储(eload_log 运行快照 + scpi_log 指令记录)
|
||||
//
|
||||
// 设备说明:docs/电子负载说明手册
|
||||
|
||||
ServerHost = localhost
|
||||
ServerPort = 9000
|
||||
Community = h100
|
||||
|
||||
ProcessConfig = pELoad
|
||||
{
|
||||
AppTick = 10
|
||||
CommsTick = 4
|
||||
|
||||
//======== 电子负载以太网地址 ========
|
||||
// 仪器 LAN 配置的 IP 与 Raw Socket 端口号
|
||||
// (前面板 [Shift]+[P-set] -> System -> I/O -> LAN 查看配置)
|
||||
eload_ip = 192.168.0.10
|
||||
eload_port = 30000
|
||||
|
||||
//======== 功率控制 ========
|
||||
// MOOS 功率消息变量名(其他程序发布的实时电机功率,单位 W,
|
||||
// 正值=电机消耗功率/负载吸收,负值=电机回馈功率/负载释放)
|
||||
power_var = MOTOR_POWER
|
||||
|
||||
// 功率控制周期 ms(需求:100ms)
|
||||
ctrl_period_ms = 100
|
||||
|
||||
// 功率消息超时秒数:超时后目标功率归零(安全),0=不判超时
|
||||
power_stale_sec = 3
|
||||
|
||||
// 电流符号约定:negative=负电流吸收(负载模拟,默认);
|
||||
// positive=正电流输出(电源模拟)
|
||||
current_sign = negative
|
||||
|
||||
// 无电压测量反馈时用于功率->电流折算的电压(V),
|
||||
// 运行后自动改用实测电压(MEAS:VOLT?)
|
||||
nominal_volt = 500
|
||||
|
||||
// 主机侧安全限幅(0=不限;设备侧 POW:LIM/CURR:LIM 为第一道防线)
|
||||
max_power = 0
|
||||
max_current = 0
|
||||
slew_limit_wps = 0
|
||||
|
||||
// 使能时下发的设备限值(0=不下发该项)
|
||||
volt_limit = 0
|
||||
curr_limit = 0
|
||||
curr_limit_neg= 0
|
||||
pow_limit = 0
|
||||
pow_limit_neg = 0
|
||||
|
||||
// 工作模式优先级:cc=CC 优先(负载/电流型),cv=CV 优先
|
||||
func_priority = cc
|
||||
|
||||
// CV 优先时的目标电压(V),func_priority=cv 时生效(恒压吸收)。
|
||||
// CC 优先下此项仅作为 VOLT 折算参考,不直接下发。
|
||||
volt_set = 0
|
||||
|
||||
//======== 数据存储 ========
|
||||
dbpath = pELoad.db
|
||||
|
||||
// 历史数据保留时长(小时,0=永久保留;对 eload_log/scpi_log/meas_log 统一生效)
|
||||
log_keep_hours = 72
|
||||
|
||||
// 高采样测量落库周期 ms(默认=控制周期 100ms,实测 V/I/P 全量记录供分析;
|
||||
// 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
|
||||
web_enable = true
|
||||
|
||||
logpath = pELoad.log
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "DbStore.h"
|
||||
#include "sqlite3.h"
|
||||
#include <ctime>
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace eload {
|
||||
|
||||
namespace {
|
||||
// 小数秒精度的 unix 时间(与 eload_log 的整秒对齐,供跨表分析)
|
||||
double nowUnix() {
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
return static_cast<double>(tv.tv_sec) + static_cast<double>(tv.tv_usec) / 1e6;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
DbStore::DbStore(const std::string& dbPath) : m_dbPath(dbPath) {}
|
||||
|
||||
DbStore::~DbStore() {
|
||||
close();
|
||||
}
|
||||
|
||||
bool DbStore::exec(const char* sql) {
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql, nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
m_lastError = err ? err : "sqlite error";
|
||||
sqlite3_free(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::open() {
|
||||
if (m_db) return true;
|
||||
if (sqlite3_open(m_dbPath.c_str(), &m_db) != SQLITE_OK) {
|
||||
m_lastError = m_db ? sqlite3_errmsg(m_db) : "cannot open db";
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
sqlite3_busy_timeout(m_db, 2000);
|
||||
exec("PRAGMA journal_mode=WAL;");
|
||||
exec("PRAGMA synchronous=NORMAL;");
|
||||
|
||||
// eload_log:运行状态周期快照
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS eload_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time INTEGER NOT NULL,"
|
||||
" connected INTEGER NOT NULL,"
|
||||
" 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,"
|
||||
" func_mode TEXT,"
|
||||
" tx_count INTEGER,"
|
||||
" rx_count INTEGER,"
|
||||
" tx_err INTEGER"
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_eload_log_time ON eload_log(time);");
|
||||
|
||||
// scpi_log:非常规 SCPI 收发记录(例行 MEAS 轮询不落库)
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS scpi_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time INTEGER NOT NULL,"
|
||||
" direction INTEGER NOT NULL," // 0=收 1=发
|
||||
" text TEXT NOT NULL"
|
||||
");")) return false;
|
||||
exec("CREATE INDEX IF NOT EXISTS idx_scpi_log_time ON scpi_log(time);");
|
||||
|
||||
// meas_log:高采样测量记录(默认每控制周期 100ms 一条,供波形分析)
|
||||
if (!exec(
|
||||
"CREATE TABLE IF NOT EXISTS meas_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" time REAL NOT NULL,"
|
||||
" meas_voltage REAL,"
|
||||
" 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, 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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::prepareMeasInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO meas_log (time, meas_voltage, meas_current, meas_power, "
|
||||
"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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DbStore::prepareScpiInsert() {
|
||||
const char* sql =
|
||||
"INSERT INTO scpi_log (time, direction, text) VALUES (?1,?2,?3);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_stmtScpiInsert, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DbStore::close() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_stmtInsert) {
|
||||
sqlite3_finalize(m_stmtInsert);
|
||||
m_stmtInsert = nullptr;
|
||||
}
|
||||
if (m_stmtMeasInsert) {
|
||||
sqlite3_finalize(m_stmtMeasInsert);
|
||||
m_stmtMeasInsert = nullptr;
|
||||
}
|
||||
if (m_stmtScpiInsert) {
|
||||
sqlite3_finalize(m_stmtScpiInsert);
|
||||
m_stmtScpiInsert = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertStatus(bool connected, bool outp, const std::string& mode,
|
||||
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) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtInsert);
|
||||
sqlite3_clear_bindings(m_stmtInsert);
|
||||
int i = 1;
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(::time(nullptr)));
|
||||
sqlite3_bind_int(m_stmtInsert, i++, connected ? 1 : 0);
|
||||
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);
|
||||
sqlite3_bind_text(m_stmtInsert, i++, funcMode.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(tx));
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(rx));
|
||||
sqlite3_bind_int64(m_stmtInsert, i++, static_cast<sqlite3_int64>(txErr));
|
||||
|
||||
int rc = sqlite3_step(m_stmtInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertMeas(double measV, double measI, double measP,
|
||||
double cmdCurrent, double cmdVolt, double targetPower, bool outp) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtMeasInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtMeasInsert);
|
||||
sqlite3_clear_bindings(m_stmtMeasInsert);
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 1, nowUnix());
|
||||
sqlite3_bind_double(m_stmtMeasInsert, 2, measV);
|
||||
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, 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) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
void DbStore::insertScpi(int direction, const std::string& text) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db || !m_stmtScpiInsert) return;
|
||||
|
||||
sqlite3_reset(m_stmtScpiInsert);
|
||||
sqlite3_clear_bindings(m_stmtScpiInsert);
|
||||
sqlite3_bind_int64(m_stmtScpiInsert, 1, static_cast<sqlite3_int64>(::time(nullptr)));
|
||||
sqlite3_bind_int(m_stmtScpiInsert, 2, direction);
|
||||
sqlite3_bind_text(m_stmtScpiInsert, 3, text.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(m_stmtScpiInsert);
|
||||
if (rc != SQLITE_DONE) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ELoadLogRow> DbStore::queryRecentStatus(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<ELoadLogRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 100;
|
||||
if (limit > 1000) limit = 1000;
|
||||
|
||||
const char* sql =
|
||||
"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) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ELoadLogRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_int64(stmt, 1);
|
||||
r.connected = sqlite3_column_int(stmt, 2);
|
||||
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.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, 13);
|
||||
r.rxCount = sqlite3_column_int64(stmt, 14);
|
||||
r.txErr = sqlite3_column_int64(stmt, 15);
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::vector<DbStore::ScpiRow> DbStore::queryRecentScpi(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<ScpiRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
|
||||
std::string q = "SELECT id, time, direction, text FROM scpi_log ORDER BY id DESC LIMIT "
|
||||
+ std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ScpiRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_int64(stmt, 1);
|
||||
r.direction = sqlite3_column_int(stmt, 2);
|
||||
const char* t = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
r.text = t ? t : "";
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
long long DbStore::countStatus() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM eload_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
long long DbStore::countScpi() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM scpi_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
long long DbStore::countMeas() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return 0;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, "SELECT COUNT(*) FROM meas_log;", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return 0;
|
||||
long long n = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) n = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return n;
|
||||
}
|
||||
|
||||
std::vector<MeasRow> DbStore::queryMeas(int limit) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<MeasRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (limit <= 0) limit = 200;
|
||||
if (limit > 5000) limit = 5000;
|
||||
|
||||
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
|
||||
"cmd_current, cmd_volt, target_power, outp FROM meas_log "
|
||||
"ORDER BY id DESC LIMIT " + std::to_string(limit) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
MeasRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_double(stmt, 1);
|
||||
r.measVoltage = sqlite3_column_double(stmt, 2);
|
||||
r.measCurrent = sqlite3_column_double(stmt, 3);
|
||||
r.measPower = sqlite3_column_double(stmt, 4);
|
||||
r.cmdCurrent = sqlite3_column_double(stmt, 5);
|
||||
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);
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::vector<MeasRow> DbStore::queryMeasRange(double startSec, double endSec, int maxRows) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
std::vector<MeasRow> rows;
|
||||
if (!m_db) return rows;
|
||||
if (maxRows <= 0) maxRows = 5000;
|
||||
if (maxRows > 20000) maxRows = 20000;
|
||||
if (endSec <= startSec) return rows;
|
||||
|
||||
std::string q = "SELECT id, time, meas_voltage, meas_current, meas_power, "
|
||||
"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) + ";";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, q.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
m_lastError = sqlite3_errmsg(m_db);
|
||||
return rows;
|
||||
}
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
MeasRow r;
|
||||
r.id = sqlite3_column_int64(stmt, 0);
|
||||
r.time = sqlite3_column_double(stmt, 1);
|
||||
r.measVoltage = sqlite3_column_double(stmt, 2);
|
||||
r.measCurrent = sqlite3_column_double(stmt, 3);
|
||||
r.measPower = sqlite3_column_double(stmt, 4);
|
||||
r.cmdCurrent = sqlite3_column_double(stmt, 5);
|
||||
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);
|
||||
return rows;
|
||||
}
|
||||
|
||||
long long DbStore::pruneOlderThan(long long cutoff) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_db) return -1;
|
||||
std::string sql = "DELETE FROM eload_log WHERE time < " + std::to_string(cutoff) +
|
||||
"; DELETE FROM scpi_log WHERE time < " + std::to_string(cutoff) +
|
||||
"; DELETE FROM meas_log WHERE time < " + std::to_string(cutoff) + ";";
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &err);
|
||||
if (rc != SQLITE_OK) {
|
||||
m_lastError = err ? err : "sqlite error";
|
||||
sqlite3_free(err);
|
||||
return -1;
|
||||
}
|
||||
return sqlite3_changes(m_db);
|
||||
}
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,120 @@
|
||||
#ifndef PELOAD_DB_STORE_H
|
||||
#define PELOAD_DB_STORE_H
|
||||
|
||||
#include <string>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
namespace eload {
|
||||
|
||||
//============================================================================
|
||||
// DbStore:pELoad SQLite 存储(复用仓库内 sqlite3.c)。
|
||||
//
|
||||
// eload_log:运行状态低频快照(1Hz),含连接/输出/模式/限值等状态;
|
||||
// meas_log :高采样测量记录(默认每控制周期 100ms 一条),
|
||||
// 实测电压/电流/功率 + 指令电流/目标功率 —— 供功率波形分析;
|
||||
// scpi_log :非常规 SCPI 收发记录(设定/控制/识别/错误查询等,
|
||||
// 例行的 MEAS 测量轮询不落库,防止表无限膨胀)。
|
||||
// log_keep_hours 对三张表统一做历史清理。
|
||||
//============================================================================
|
||||
|
||||
struct ELoadLogRow {
|
||||
long long id;
|
||||
long long time;
|
||||
int connected;
|
||||
int outp;
|
||||
std::string mode; // follow / manual / current / volt
|
||||
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
|
||||
std::string funcMode; // CURR / VOLT
|
||||
long long txCount;
|
||||
long long rxCount;
|
||||
long long txErr;
|
||||
};
|
||||
|
||||
// 高采样测量记录(meas_log)
|
||||
struct MeasRow {
|
||||
long long id;
|
||||
double time; // unix 时间(秒,带小数)
|
||||
double measVoltage;
|
||||
double measCurrent;
|
||||
double measPower;
|
||||
double cmdCurrent;
|
||||
double cmdVolt;
|
||||
double targetPower;
|
||||
int outp;
|
||||
};
|
||||
|
||||
class DbStore {
|
||||
public:
|
||||
explicit DbStore(const std::string& dbPath);
|
||||
~DbStore();
|
||||
|
||||
bool open();
|
||||
void close();
|
||||
bool isOpen() const { return m_db != nullptr; }
|
||||
std::string lastError() const { return m_lastError; }
|
||||
|
||||
// 运行状态快照落库(控制线程周期调用)
|
||||
void insertStatus(bool connected, bool outp, const std::string& mode,
|
||||
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 cmdVolt, double targetPower, bool outp);
|
||||
|
||||
// SCPI 非常规收发落库:text 为 "CMD" 或 "CMD => RESP"(已配对)
|
||||
// direction: 1=命令 0=查询应答
|
||||
void insertScpi(int direction, const std::string& text);
|
||||
|
||||
// 历史查询(网页展示)
|
||||
std::vector<ELoadLogRow> queryRecentStatus(int limit);
|
||||
// 测量查询:limit 条最近记录(倒序);或指定时间范围(unix 秒)
|
||||
std::vector<MeasRow> queryMeas(int limit);
|
||||
std::vector<MeasRow> queryMeasRange(double startSec, double endSec, int maxRows);
|
||||
struct ScpiRow {
|
||||
long long id;
|
||||
long long time;
|
||||
int direction;
|
||||
std::string text;
|
||||
};
|
||||
std::vector<ScpiRow> queryRecentScpi(int limit);
|
||||
|
||||
long long countStatus() const;
|
||||
long long countScpi() const;
|
||||
long long countMeas() const;
|
||||
|
||||
// 历史清理:删除 time < cutoff 的记录,返回删除行数(<0 表示出错)
|
||||
long long pruneOlderThan(long long cutoff);
|
||||
|
||||
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();
|
||||
|
||||
std::string m_dbPath;
|
||||
sqlite3* m_db = nullptr;
|
||||
sqlite3_stmt* m_stmtInsert = nullptr;
|
||||
sqlite3_stmt* m_stmtMeasInsert = nullptr;
|
||||
sqlite3_stmt* m_stmtScpiInsert = nullptr;
|
||||
mutable std::mutex m_mutex;
|
||||
std::string m_lastError;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
|
||||
#endif // PELOAD_DB_STORE_H
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "WebServer.h"
|
||||
#include "pages/index.h"
|
||||
#include "pages/trend.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace eload {
|
||||
|
||||
//---------------------------------------------------------
|
||||
// 事件处理:HTTP 路由 + WebSocket 收发
|
||||
|
||||
void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) {
|
||||
if (ev == MG_EV_HTTP_MSG) {
|
||||
struct mg_http_message* hm = (struct mg_http_message*)ev_data;
|
||||
|
||||
// WebSocket 升级
|
||||
if (mg_match(hm->uri, mg_str("/ws"), NULL)) {
|
||||
mg_ws_upgrade(c, hm, NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
// /api/* 接口:交给宿主程序处理,返回 (Content-Type, body)
|
||||
std::string path(hm->uri.buf, hm->uri.len);
|
||||
if (path.rfind("/api/", 0) == 0) {
|
||||
std::string body, contentType = "application/json";
|
||||
if (m_apiHandler) {
|
||||
std::string query(hm->query.buf, hm->query.len);
|
||||
std::pair<std::string, std::string> r = m_apiHandler(path, query);
|
||||
body = r.second;
|
||||
if (!r.first.empty()) contentType = r.first;
|
||||
}
|
||||
if (!body.empty()) {
|
||||
std::string headers = "Content-Type: " + contentType + "\r\n";
|
||||
mg_http_reply(c, 200, headers.c_str(),
|
||||
"%.*s", (int)body.size(), body.c_str());
|
||||
} else {
|
||||
mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 电子负载控制页面
|
||||
if (path == "/" || path == "/index.html") {
|
||||
mg_http_reply(c, 200, "Content-Type: text/html; charset=utf-8\r\n",
|
||||
"%.*s", (int)INDEX_HTML.size(), INDEX_HTML.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// 实时曲线页(独立标签页)
|
||||
if (path == "/trend" || path == "/trend.html") {
|
||||
mg_http_reply(c, 200, "Content-Type: text/html; charset=utf-8\r\n",
|
||||
"%.*s", (int)TREND_HTML.size(), TREND_HTML.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n");
|
||||
} else if (ev == MG_EV_WS_OPEN) {
|
||||
// Web 线程内:先构建欢迎快照,再登记连接
|
||||
std::string welcome;
|
||||
if (m_onOpen) welcome = m_onOpen();
|
||||
m_wsConnections.push_back(c);
|
||||
if (!welcome.empty()) {
|
||||
mg_ws_send(c, welcome.c_str(), welcome.size(), WEBSOCKET_OP_TEXT);
|
||||
}
|
||||
} else if (ev == MG_EV_CLOSE || ev == MG_EV_ERROR) {
|
||||
for (auto it = m_wsConnections.begin(); it != m_wsConnections.end(); ++it) {
|
||||
if (*it == c) {
|
||||
m_wsConnections.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (ev == MG_EV_WS_MSG) {
|
||||
// 忽略客户端消息(控制走 /api/*)
|
||||
}
|
||||
}
|
||||
|
||||
WebServer::WebServer() {
|
||||
mg_mgr_init(&m_mgr);
|
||||
}
|
||||
|
||||
WebServer::~WebServer() {
|
||||
stop();
|
||||
mg_mgr_free(&m_mgr);
|
||||
}
|
||||
|
||||
void WebServer::setOnOpen(std::function<std::string()> handler) {
|
||||
m_onOpen = std::move(handler);
|
||||
}
|
||||
|
||||
void WebServer::setApiHandler(std::function<std::pair<std::string, std::string>(const std::string&, const std::string&)> handler) {
|
||||
m_apiHandler = std::move(handler);
|
||||
}
|
||||
|
||||
bool WebServer::start(int port) {
|
||||
if (m_running) return true;
|
||||
m_port = port;
|
||||
|
||||
std::string addr = "http://0.0.0.0:" + std::to_string(port);
|
||||
mg_http_listen(&m_mgr, addr.c_str(), [](mg_connection* c, int ev, void* ev_data) {
|
||||
WebServer* server = static_cast<WebServer*>(c->fn_data);
|
||||
if (server) server->handleEvent(c, ev, ev_data);
|
||||
}, this);
|
||||
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() { serverThreadFunc(); });
|
||||
|
||||
std::cout << "pELoad web UI listening on " << addr << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebServer::stop() {
|
||||
if (!m_running) return;
|
||||
m_running = false;
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
|
||||
// Web 线程已退出,此处触碰连接列表安全
|
||||
for (auto c : m_wsConnections) {
|
||||
mg_ws_send(c, "", 0, WEBSOCKET_OP_CLOSE);
|
||||
}
|
||||
m_wsConnections.clear();
|
||||
}
|
||||
|
||||
bool WebServer::serverThreadCB(void* pParam) {
|
||||
WebServer* pThis = static_cast<WebServer*>(pParam);
|
||||
return pThis->serverThreadFunc();
|
||||
}
|
||||
|
||||
bool WebServer::serverThreadFunc() {
|
||||
while (m_running) {
|
||||
mg_mgr_poll(&m_mgr, 200);
|
||||
// 仅在本线程(poll 间隙)发送,避免跨线程操作 mongoose 连接
|
||||
flushPending();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebServer::flushPending() {
|
||||
std::string text;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_pendingMutex);
|
||||
text = std::move(m_pending);
|
||||
m_pending.clear();
|
||||
}
|
||||
if (text.empty()) return;
|
||||
for (auto c : m_wsConnections) {
|
||||
mg_ws_send(c, text.c_str(), text.size(), WEBSOCKET_OP_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
void WebServer::broadcast(const std::string& text) {
|
||||
// 只投递到队列,实际发送在 Web 线程 flushPending() 中完成
|
||||
std::lock_guard<std::mutex> lock(m_pendingMutex);
|
||||
m_pending = text;
|
||||
}
|
||||
|
||||
} // namespace eload
|
||||
@@ -0,0 +1,55 @@
|
||||
#ifndef PELOAD_WEB_SERVER_H
|
||||
#define PELOAD_WEB_SERVER_H
|
||||
|
||||
#define UNIX
|
||||
#include "mongoose.h"
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
|
||||
namespace eload {
|
||||
|
||||
class WebServer {
|
||||
public:
|
||||
WebServer();
|
||||
~WebServer();
|
||||
|
||||
bool start(int port);
|
||||
void stop();
|
||||
bool isRunning() const { return m_running; }
|
||||
|
||||
// 向所有 WebSocket 客户端广播文本
|
||||
// 线程安全:mongoose 非线程安全,本接口只把文本放入待发队列,
|
||||
// 由 Web 线程在 mg_mgr_poll 返回后统一发送(禁止跨线程直接 mg_ws_send)
|
||||
void broadcast(const std::string& text);
|
||||
|
||||
// 新客户端连接时调用,返回初始快照
|
||||
void setOnOpen(std::function<std::string()> handler);
|
||||
|
||||
// 处理 /api/* 请求:返回 (Content-Type, body)。
|
||||
// 默认内容类型 application/json;文本/CSV 接口由处理器自行指定。
|
||||
void setApiHandler(std::function<std::pair<std::string, std::string>(const std::string& uri, const std::string& query)> handler);
|
||||
|
||||
private:
|
||||
static bool serverThreadCB(void* pParam);
|
||||
bool serverThreadFunc();
|
||||
void handleEvent(struct mg_connection* c, int ev, void* ev_data);
|
||||
void flushPending();
|
||||
|
||||
struct mg_mgr m_mgr;
|
||||
int m_port = 0;
|
||||
std::atomic<bool> m_running{false};
|
||||
std::thread m_thread;
|
||||
|
||||
std::mutex m_pendingMutex;
|
||||
std::string m_pending; // latest snapshot to broadcast
|
||||
std::vector<mg_connection*> m_wsConnections; // web thread only
|
||||
std::function<std::string()> m_onOpen;
|
||||
std::function<std::pair<std::string, std::string>(const std::string&, const std::string&)> m_apiHandler;
|
||||
};
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_WEB_SERVER_H
|
||||
@@ -0,0 +1,605 @@
|
||||
#ifndef PELOAD_PAGE_INDEX_H
|
||||
#define PELOAD_PAGE_INDEX_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pELoad 电子负载控制页面(纯前端,内嵌 HTML)。
|
||||
//
|
||||
// 操作顺序(防误触 + 步骤引导):
|
||||
// ① 连接设备(链路绿 → 步骤2 可操作)
|
||||
// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值)
|
||||
// ③ 开启输出(确认后下发使能序列)
|
||||
// 所有下发的指令均带确认对话框;按钮按当前状态禁用。
|
||||
//
|
||||
// 关键设计:页面为静态骨架(输入框/按钮是稳定 DOM,永不重建),
|
||||
// render() 只按 id 更新动态数值/状态/按钮禁用 —— 输入框聚焦编辑时
|
||||
// 不受 1Hz 快照刷新影响,光标稳定、输入流畅(setInput 聚焦时不覆盖)。
|
||||
//
|
||||
// 模式说明:
|
||||
// CC 优先(电流型负载模拟):控制量 = 电流,功率折算 I=P/U,可跟随 MOOS
|
||||
// 功率 / 手动功率 / 手动电流;
|
||||
// CV 优先(恒压吸收):控制量 = 电压(VOLT),功率由外部源决定。
|
||||
// FUNC:MODE 必须保持 FIX(实时功率控制前提),非 FIX 时页面红色告警。
|
||||
//
|
||||
// 设备说明:docs/电子负载说明手册(IT6000C 双向可编程直流电源)
|
||||
//============================================================================
|
||||
|
||||
namespace eload {
|
||||
const std::string INDEX_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pELoad 电子负载控制</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--ok:#2ea043;--warn:#d29922;--bad:#f85149;--blue:#1f6feb;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:16px;padding-bottom:60px;}
|
||||
h1{font-size:18px;margin-bottom:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
h2{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;border-bottom:1px solid var(--line);padding-bottom:6px;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);white-space:nowrap;}
|
||||
.badge.ok{color:var(--ok);border-color:var(--ok);}
|
||||
.badge.warn{color:var(--warn);border-color:var(--warn);}
|
||||
.badge.err{color:var(--bad);border-color:var(--bad);}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;}
|
||||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed #222c3d;gap:12px;}
|
||||
.row:last-child{border-bottom:none;}
|
||||
.row .k{color:var(--dim);flex-shrink:0;}
|
||||
.row .v{font-family:Consolas,monospace;color:var(--fg);text-align:right;}
|
||||
.sub{font-size:11px;color:#79c0ff;margin:10px 0 2px;border-bottom:1px solid #22304a;padding-bottom:2px;}
|
||||
.btn{padding:6px 14px;margin:0 8px 8px 0;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||||
.btn:hover:not(:disabled){border-color:#3b4a63;}
|
||||
.btn:disabled{opacity:.35;cursor:not-allowed;}
|
||||
.btn.on{background:#1f6feb;border-color:#1f6feb;color:#fff;font-weight:600;}
|
||||
.btn.off{background:#3d2226;border-color:#f85149;color:#f85149;}
|
||||
.btn.warn{background:#3a2e12;border-color:var(--warn);color:var(--warn);}
|
||||
.btn.big{padding:10px 22px;font-size:14px;}
|
||||
.btn.sel{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:600;}
|
||||
input[type=number],input[type=text]{width:120px;background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);font-family:Consolas,monospace;padding:5px 8px;font-size:13px;}
|
||||
input:focus{outline:none;border-color:var(--blue);}
|
||||
input:disabled{opacity:.4;}
|
||||
.inline{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.inline label{color:var(--dim);}
|
||||
.dim{color:var(--dim);}
|
||||
.errline{color:var(--bad);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||||
.okline{color:var(--ok);font-family:Consolas,monospace;font-size:12px;word-break:break-all;}
|
||||
table{width:100%;border-collapse:collapse;font-size:11px;font-family:Consolas,monospace;}
|
||||
th,td{padding:3px 6px;border-bottom:1px solid #222c3d;text-align:right;white-space:nowrap;}
|
||||
th{color:var(--dim);font-weight:400;}
|
||||
td.l,th.l{text-align:left;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
/* 顶部状态条 */
|
||||
#topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:12px;}
|
||||
#topbar .sep{width:1px;height:20px;background:var(--line);margin:0 6px;}
|
||||
/* 操作顺序步骤条 */
|
||||
.steps{display:flex;align-items:center;gap:6px;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.step{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;border:1px solid var(--line);color:var(--dim);font-size:12px;}
|
||||
.step .n{width:18px;height:18px;border-radius:50%;background:#2a3447;color:var(--fg);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||||
.step.done{border-color:var(--ok);color:var(--ok);}
|
||||
.step.done .n{background:var(--ok);color:#fff;}
|
||||
.step.active{border-color:var(--blue);color:#fff;font-weight:600;}
|
||||
.step.active .n{background:var(--blue);color:#fff;}
|
||||
.step.err{border-color:var(--bad);color:var(--bad);}
|
||||
.step.err .n{background:var(--bad);color:#fff;}
|
||||
.sarrow{color:var(--dim);}
|
||||
.tip{font-size:11px;color:var(--dim);line-height:1.5;}
|
||||
.tip b{color:#79c0ff;}
|
||||
.bigstat{font-size:24px;font-weight:700;font-family:Consolas,monospace;text-align:center;padding:10px 0;}
|
||||
.bigstat.on{color:var(--ok);}
|
||||
.bigstat.off{color:var(--dim);}
|
||||
.bigstat.wait{color:var(--warn);}
|
||||
.meas{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center;margin-bottom:8px;}
|
||||
.meas .v{font-size:24px;font-family:Consolas,monospace;}
|
||||
.meas .k{color:var(--dim);font-size:11px;}
|
||||
.fixwarn{background:rgba(248,81,73,.15);border:1px solid var(--bad);color:var(--bad);border-radius:6px;padding:6px 10px;font-size:12px;margin-bottom:8px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="topbar">
|
||||
<h1 style="margin:0">pELoad 电子负载控制</h1>
|
||||
<span id="status" class="badge">加载中...</span>
|
||||
<span class="sep"></span>
|
||||
<span id="badgeLink" class="badge err">未连接</span>
|
||||
<span id="badgeOutp" class="badge err">输出关</span>
|
||||
<span id="badgeMode" class="badge">模式 -</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/trend" target="_blank">📈 实时曲线</a>
|
||||
</div>
|
||||
|
||||
<div class="steps" id="steps">
|
||||
<div class="step" id="step1"><span class="n">1</span>连接设备</div>
|
||||
<span class="sarrow">▸</span>
|
||||
<div class="step" id="step2"><span class="n">2</span>设置参数</div>
|
||||
<span class="sarrow">▸</span>
|
||||
<div class="step" id="step3"><span class="n">3</span>开启输出</div>
|
||||
<span style="flex:1"></span>
|
||||
<span class="tip" id="stepTip"></span>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="pages">
|
||||
|
||||
<!--======== 卡1:以太网链路 ========-->
|
||||
<div class="card">
|
||||
<h2>① 连接设备 · 以太网链路</h2>
|
||||
<div class="row"><span class="k">设备地址</span><span class="v" id="linkAddr">-</span></div>
|
||||
<div class="row"><span class="k">连接状态</span><span class="v" id="linkConn">未连接</span></div>
|
||||
<div class="row"><span class="k">自动重连</span><span class="v" id="linkAuto">-</span></div>
|
||||
<div class="row"><span class="k">重连次数</span><span class="v" id="linkReconn">-</span></div>
|
||||
<div class="row"><span class="k">收发统计</span><span class="v" id="linkStats">-</span></div>
|
||||
<div class="row"><span class="k">最近收帧</span><span class="v" id="linkLastRx">-</span></div>
|
||||
<div class="row"><span class="k">设备识别 *IDN?</span><span class="v" id="linkIdn">未识别</span></div>
|
||||
<div class="errline" id="linkError" style="display:none"></div>
|
||||
<div class="sub">操作(指令均需确认)</div>
|
||||
<button id="btnConnect" class="btn on" onclick="sendCmd('connect',null,'确认连接电子负载?')">连接</button>
|
||||
<button id="btnDisconnect" class="btn off" onclick="sendCmd('disconnect',null,'确认断开以太网链路?\n断开前会先关闭输出。')">断开</button>
|
||||
<button id="btnIdentify" class="btn" onclick="sendCmd('identify')">识别 (*IDN?)</button>
|
||||
<button id="btnClearProt" class="btn warn" onclick="sendCmd('clear_protect',null,'确认清除输出保护锁定?')">清保护</button>
|
||||
<button id="btnReset" class="btn off" onclick="sendCmd('reset_dev',null,'确认复位设备 (*RST)?\n所有设定将恢复默认!')">复位设备</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡2:当前状态 + 实时测量 ========-->
|
||||
<div class="card">
|
||||
<h2>当前状态 · 实时测量</h2>
|
||||
<div class="bigstat off" id="bigOutp">输出关</div>
|
||||
<div class="fixwarn" id="fixWarn" style="display:none"></div>
|
||||
<div class="meas">
|
||||
<div><div class="v" style="color:#79c0ff" id="measV">-</div><div class="k">电压 V</div></div>
|
||||
<div><div class="v" style="color:#3fb950" id="measI">-</div><div class="k">电流 A</div></div>
|
||||
<div><div class="v" style="color:#d29922" id="measP">-</div><div class="k">功率 W</div></div>
|
||||
</div>
|
||||
<div class="row"><span class="k">数据时效</span><span class="v" id="measAge">-</span></div>
|
||||
<div class="row"><span class="k">测量查询</span><span class="v" id="measDiag">-</span></div>
|
||||
<div class="errline" id="measDiagErr" style="display:none"></div>
|
||||
<div class="row"><span class="k">环路优先(回读)</span><span class="v" id="devFunc">-</span></div>
|
||||
<div class="row"><span class="k">功能模式(回读)</span><span class="v" id="devFuncMode">-</span></div>
|
||||
<div class="row"><span class="k">设备配置同步</span><span class="v" id="cfgSync">-</span></div>
|
||||
<div class="row"><span class="k">切换结果</span><span class="v" id="funcModeFeed">-</span></div>
|
||||
<div class="sub">功能模式切换(FUNC:MODE)</div>
|
||||
<div class="inline">
|
||||
<select id="funcModeSel">
|
||||
<option value="FIX">FIX 固定输出(实时控制)</option>
|
||||
<option value="LIST">LIST 列表波形</option>
|
||||
<option value="BATT">BATT 电池测试</option>
|
||||
<option value="SOLAR">SOLAR 光伏曲线</option>
|
||||
<option value="CAR">CAR 汽车波形</option>
|
||||
</select>
|
||||
<button id="btnFuncMode" class="btn" onclick="switchFuncMode()">切换功能模式</button>
|
||||
</div>
|
||||
<div class="tip" id="funcModeTip">实时功率控制必须保持 <b>FIX</b>。切换到 LIST/BATT/SOLAR/CAR 后,输出将按该模式自主运行,脱离本程序的实时功率控制(页面会红色告警),需切回 FIX 恢复。</div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡3:参数与输出控制 ========-->
|
||||
<div class="card">
|
||||
<h2>②③ 参数与输出控制</h2>
|
||||
<div class="sub">环路优先模式(决定控制量类型)</div>
|
||||
<button id="btnModeCC" class="btn sel" onclick="sendCmd('mode_cc',null,'确认切换为 CC 优先(电流型负载,功率折算 I=P/U)?')">CC 优先</button>
|
||||
<button id="btnModeCV" class="btn sel" onclick="sendCmd('mode_cv',null,'确认切换为 CV 优先(恒压吸收,按电压控制)?')">CV 优先</button>
|
||||
<div class="tip" id="ctrlTip"></div>
|
||||
|
||||
<div id="ccArea">
|
||||
<div class="sub">控制模式(目标来源)</div>
|
||||
<button id="btnModeFollow" class="btn" onclick="sendCmd('mode_follow',null,'确认切换为跟随 MOOS 功率消息(实时电机功率)?')">跟随MOOS</button>
|
||||
<button id="btnModeManual" class="btn" onclick="sendCmd('mode_manual',null,'确认切换为手动功率?')">手动功率</button>
|
||||
<button id="btnModeCurrent" class="btn" onclick="sendCmd('mode_current',null,'确认切换为手动电流?')">手动电流</button>
|
||||
<div class="tip" id="curModeTip"></div>
|
||||
<div class="inline"><label>功率 (W)</label>
|
||||
<input type="number" id="powerInput" step="1" value="0">
|
||||
<button id="btnSetPower" class="btn" onclick="sendFromInput('set_power','powerInput','手动目标功率','W')">设定功率</button></div>
|
||||
<div class="inline"><label>电流 (A, 负=吸收)</label>
|
||||
<input type="number" id="currInput" step="0.1" value="0">
|
||||
<button id="btnSetCurr" class="btn" onclick="sendFromInput('set_current','currInput','手动电流','A')">设定电流</button></div>
|
||||
<div class="row"><span class="k">MOOS 功率消息</span><span class="v" id="moosPower">-</span></div>
|
||||
<div class="row"><span class="k">指令电流 CURR</span><span class="v" id="cmdCurrent">-</span></div>
|
||||
<div class="row"><span class="k">符号约定</span><span class="v" id="signTip">-</span></div>
|
||||
</div>
|
||||
|
||||
<div id="cvArea" style="display:none">
|
||||
<div class="inline"><label>目标电压 (V)</label>
|
||||
<input type="number" id="voltInput" step="1" value="0">
|
||||
<button id="btnSetVolt" class="btn" onclick="sendFromInput('set_volt','voltInput','CV 目标电压','V')">设定电压</button></div>
|
||||
<div class="row"><span class="k">指令电压 VOLT</span><span class="v" id="cmdVolt">-</span></div>
|
||||
<div class="tip">恒压吸收:设备维持该电压,吸收电流由外部源决定;功率大小请以实测为准。</div>
|
||||
</div>
|
||||
|
||||
<div class="sub">输出控制</div>
|
||||
<button id="btnOutpOn" class="btn big on" onclick="sendCmd('outp_on',null,'确认开启输出?\n电子负载将按当前设定开始吸收电能!')">▶ 开启输出</button>
|
||||
<button id="btnOutpOff" class="btn big off" onclick="sendCmd('outp_off',null,'确认关闭输出?\n电子负载停止吸收电能。')">■ 关闭输出</button>
|
||||
<div class="tip" id="outpTip"></div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡4:设备参数与限值 ========-->
|
||||
<div class="card">
|
||||
<h2>设备参数(限值下发)</h2>
|
||||
<div class="tip">限值在输出开启时统一下发,修改后需“关闭输出→重新开启”生效(或在运行中直接下发,设备实时响应)。</div>
|
||||
<div class="inline"><label>电压限值 VOLT:LIM (V)</label>
|
||||
<input type="number" id="voltlimInput" step="1" value="0">
|
||||
<button id="btnSetVoltlim" class="btn" onclick="sendFromInput('set_voltlim','voltlimInput','电压限值','V')">下发</button></div>
|
||||
<div class="inline"><label>电流限值 CURR:LIM (A)</label>
|
||||
<input type="number" id="currlimInput" step="0.1" value="0">
|
||||
<button id="btnSetCurrlim" class="btn" onclick="sendFromInput('set_currlimit','currlimInput','电流限值','A')">下发</button></div>
|
||||
<div class="inline"><label>功率限值 POW:LIM (W)</label>
|
||||
<input type="number" id="powlimInput" step="10" value="0">
|
||||
<button id="btnSetPowlim" class="btn" onclick="sendFromInput('set_powlimit','powlimInput','功率限值','W')">下发</button></div>
|
||||
<div class="sub">远控 / 本控</div>
|
||||
<div class="row"><span class="k">当前控制端</span><span class="v" id="remoteState">-</span></div>
|
||||
<div class="row"><span class="k">最近指令结果</span><span class="v" id="remoteFeed">-</span></div>
|
||||
<div class="tip">IT6000C 无远控状态查询命令,此状态按最近一次远控/本控指令的发送结果推断;若面板被手动按回本控,需重新发送“远控”。</div>
|
||||
<button id="btnRemote" class="btn" onclick="sendCmd('remote',null,'确认进入远控模式 (SYST:REM)?\n面板按键将被锁定。')">远控 SYST:REM</button>
|
||||
<button id="btnLocal" class="btn warn" onclick="sendCmd('local',null,'确认切回面板本控 (SYST:LOC)?\n输出将关闭且远控失效。')">本控 SYST:LOC</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡5:SCPI 调试 ========-->
|
||||
<div class="card">
|
||||
<h2>SCPI 调试(高级)</h2>
|
||||
<div class="inline"><input type="text" id="rawCmd" style="flex:1;min-width:180px" placeholder="例如: *IDN? 或 VOLT:LIM? MAX" />
|
||||
<button id="btnRawSend" class="btn" onclick="sendRaw()">发送</button></div>
|
||||
<div class="sub">最近响应</div>
|
||||
<div class="okline" id="rawResp">-</div>
|
||||
<div class="sub">非常规 SCPI 收发记录 (sqlite scpi_log)</div>
|
||||
<div id="scpiLog" style="max-height:200px;overflow:auto"><span class="dim">点击“刷新记录”加载</span></div>
|
||||
<button class="btn" onclick="loadScpiLog()">刷新记录</button>
|
||||
</div>
|
||||
|
||||
<!--======== 卡6:数据分析 ========-->
|
||||
<div class="card">
|
||||
<h2>数据分析 (高采样 meas_log)</h2>
|
||||
<div class="row"><span class="k">测量记录条数</span><span class="v" id="measCount">-</span></div>
|
||||
<div class="tip">实测电压/电流/功率按控制周期(默认 100ms)落库,用于功率波形分析。可预览最近数据或导出 CSV 离线分析(Excel/Python)。</div>
|
||||
<div class="sub">最近数据预览</div>
|
||||
<div id="measPreview" style="max-height:220px;overflow:auto"><span class="dim">点击“加载预览”</span></div>
|
||||
<button class="btn" onclick="loadMeas()">加载预览 (最近50条)</button>
|
||||
<button class="btn" onclick="downloadMeasCsv()">导出 CSV (最近2000条)</button>
|
||||
<div class="sub">按时间范围导出(unix 秒)</div>
|
||||
<div class="inline"><label>起始</label><input type="number" id="csvStart" step="1" placeholder="起始 unix 秒">
|
||||
<label>结束</label><input type="number" id="csvEnd" step="1" placeholder="结束 unix 秒">
|
||||
<button class="btn" onclick="downloadMeasRange()">按范围导出</button></div>
|
||||
</div>
|
||||
|
||||
<!--======== 卡7:历史记录 ========-->
|
||||
<div class="card">
|
||||
<h2>历史记录 (sqlite eload_log, 1Hz)</h2>
|
||||
<div class="row"><span class="k">记录总数</span><span class="v" id="statusCount">-</span></div>
|
||||
<div id="history" style="max-height:240px;overflow:auto"><span class="dim">点击“查询”加载</span></div>
|
||||
<button class="btn" onclick="loadHistory()">查询最近 60 条</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status');
|
||||
|
||||
function ageText(a){
|
||||
if(typeof a !== 'number' || a < 0) return '从未';
|
||||
if(a < 90) return a.toFixed(1)+' 秒前';
|
||||
if(a < 3600) return Math.floor(a/60)+' 分 '+Math.floor(a%60)+' 秒前';
|
||||
return Math.floor(a/3600)+' 时 '+Math.floor((a%3600)/60)+' 分前';
|
||||
}
|
||||
function ageCell(d, key){
|
||||
if(!d || !d[key+'Valid']) return '<span class="dim">从未</span>';
|
||||
const online = d[key+'Age'] < 3;
|
||||
return (online ? '<span style="color:var(--ok)">' : '<span style="color:var(--bad)">')
|
||||
+ ageText(d[key+'Age']) + (online ? ' (在线)' : ' (超时)') + '</span>';
|
||||
}
|
||||
function fmt(v, n){ return (typeof v === 'number') ? v.toFixed(n===undefined?2:n) : '-'; }
|
||||
|
||||
//------------------ 指令下发(全部带确认,防误触) ------------------
|
||||
// 链路: 网页 fetch -> pELoad 入队 -> 控制线程(100ms) -> SCPI -> IT6000C
|
||||
function sendCmd(action, value, confirmMsg){
|
||||
if(confirmMsg && !confirm(confirmMsg)) return;
|
||||
let url = '/api/eload_cmd?action='+action;
|
||||
if(value !== undefined && value !== null) url += '&value='+value;
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if(j.ok){ if(action!=='identify') statusEl.textContent = '已下发: '+action; }
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(() => alert('网络错误,指令未下发'));
|
||||
}
|
||||
function sendRaw(){
|
||||
const cmd = $('rawCmd').value.trim();
|
||||
if(!cmd){ alert('请输入 SCPI 命令'); return; }
|
||||
if(!confirm('确认发送原始 SCPI 指令:\n'+cmd+'\n\n原始指令不做任何校验,请谨慎操作!')) return;
|
||||
fetch('/api/eload_cmd?action=raw&cmd='+encodeURIComponent(cmd))
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
$('rawResp').textContent = j.ok ? (j.response || '(无响应)') : ('错误: '+(j.error||''));
|
||||
if(j.ok && j.response) loadScpiLog();
|
||||
})
|
||||
.catch(() => { $('rawResp').textContent = '网络错误'; });
|
||||
}
|
||||
// 从输入框取数并下发(带确认)
|
||||
function sendFromInput(action, inputId, label, unit){
|
||||
const v = parseFloat($(inputId).value);
|
||||
if(isNaN(v)){ alert('请输入有效数值'); return; }
|
||||
sendCmd(action, v, '确认设定'+label+' = '+v+' '+unit+' ?');
|
||||
}
|
||||
// 功能模式切换(带确认 + 脱离实时控制警告)
|
||||
function switchFuncMode(){
|
||||
const sel = $('funcModeSel');
|
||||
const m = sel.value;
|
||||
if(m === 'FIX'){
|
||||
if(!confirm('确认切换为 FIX(固定输出)?\n将恢复本程序的实时功率控制。')) return;
|
||||
} else {
|
||||
if(!confirm('确认切换到 '+m+' 模式?\n\n⚠ 该模式会脱离本程序的实时功率控制,\n输出将按 '+m+' 的模式自主运行!\n请确保已理解风险后再确认。')) return;
|
||||
}
|
||||
fetch('/api/eload_cmd?action=func_mode&mode='+m)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if(j.ok) statusEl.textContent = '已下发: func_mode '+m;
|
||||
else alert('下发失败: '+(j.error||'未知错误'));
|
||||
})
|
||||
.catch(() => alert('网络错误,指令未下发'));
|
||||
}
|
||||
|
||||
//------------------ 渲染(WebSocket 1Hz 快照,仅按 id 更新,不重建 DOM)------------------
|
||||
let lastSnap = null;
|
||||
// 设置输入框值:用户聚焦编辑中不覆盖(保证输入流畅、光标稳定)
|
||||
function setInput(id, v){
|
||||
const el = $(id);
|
||||
if(!el) return;
|
||||
if(document.activeElement === el) return;
|
||||
const str = (v === undefined || v === null) ? '' : String(v);
|
||||
if(el.value !== str) el.value = str;
|
||||
}
|
||||
function setDisabled(id, cond){ const el = $(id); if(el) el.disabled = !!cond; }
|
||||
|
||||
function render(s){
|
||||
lastSnap = s;
|
||||
const link = s.link || {}, ctrl = s.ctrl || {}, dev = s.dev || {}, db = s.db || {};
|
||||
const connected = !!link.connected, cc = !!ctrl.ccPriority;
|
||||
const loadOn = !!ctrl.loadOn, outpApplied = !!ctrl.outpApplied;
|
||||
const modeName = {follow:'跟随 MOOS 功率', manual:'手动功率', current:'手动电流', volt:'恒压吸收'};
|
||||
const powerStale = ctrl.moosPowerValid && ctrl.moosPowerAge > (ctrl.staleSec||0);
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
//---- 顶部状态条 ----
|
||||
statusEl.textContent = connected ? '已连接 '+link.host+':'+link.port : '未连接';
|
||||
statusEl.className = 'badge ' + (connected ? 'ok' : 'err');
|
||||
$('badgeLink').textContent = '链路: ' + (connected ? '已连接' : '未连接');
|
||||
$('badgeLink').className = 'badge ' + (connected ? 'ok' : 'err');
|
||||
$('badgeOutp').textContent = '输出: ' + (outpApplied ? '开' : (loadOn ? '待开' : '关'));
|
||||
$('badgeOutp').className = 'badge ' + (outpApplied ? 'ok' : (loadOn ? 'warn' : 'err'));
|
||||
$('badgeMode').textContent = '模式: ' + (cc ? 'CC优先' : 'CV优先') + ' · ' + (modeName[ctrl.mode]||ctrl.mode);
|
||||
$('badgeMode').className = 'badge' + (dev.funcModeFixWarn ? ' err' : '');
|
||||
|
||||
//---- 步骤条 ----
|
||||
let curStep = 1, tipText = '';
|
||||
if(!connected){ curStep = 1; tipText = '请先连接设备(步骤1)'; }
|
||||
else if(!outpApplied){ curStep = 2; tipText = '设置环路优先/控制模式与限值(步骤2),然后开启输出(步骤3)'; }
|
||||
else { curStep = 3; tipText = '输出已开启,正在进行实时功率控制'; }
|
||||
if(dev.funcModeFixWarn) tipText = '!! FUNC:MODE 非 FIX,实时控制失效 !!';
|
||||
$('step1').className = 'step ' + (connected ? 'done' : 'active');
|
||||
$('step2').className = 'step ' + (outpApplied ? 'done' : (curStep===2 ? 'active' : ''));
|
||||
$('step3').className = 'step ' + (outpApplied ? 'done active' : '');
|
||||
$('stepTip').innerHTML = tipText;
|
||||
|
||||
//---- 卡1:链路 ----
|
||||
$('linkAddr').textContent = link.host + ':' + link.port;
|
||||
$('linkConn').innerHTML = connected
|
||||
? '<span style="color:var(--ok)">已连接</span>'
|
||||
: '<span style="color:var(--bad)">未连接</span>';
|
||||
$('linkAuto').textContent = link.enabled ? '开启' : '关闭';
|
||||
$('linkReconn').textContent = link.reconnects;
|
||||
$('linkStats').textContent = 'tx='+link.tx+' rx='+link.rx+' txErr='+link.txErr;
|
||||
$('linkLastRx').innerHTML = ageCell(link, 'lastRx');
|
||||
$('linkIdn').innerHTML = link.idn || '<span class="dim">未识别</span>';
|
||||
$('linkError').style.display = link.lastError ? '' : 'none';
|
||||
$('linkError').textContent = link.lastError || '';
|
||||
setDisabled('btnConnect', connected);
|
||||
setDisabled('btnDisconnect', !connected);
|
||||
setDisabled('btnIdentify', !connected);
|
||||
setDisabled('btnClearProt', !connected);
|
||||
setDisabled('btnReset', !connected);
|
||||
|
||||
//---- 卡2:状态 + 测量 ----
|
||||
const bo = $('bigOutp');
|
||||
bo.textContent = outpApplied ? '输出开' : (loadOn ? '待应用' : '输出关');
|
||||
bo.className = 'bigstat ' + (outpApplied ? 'on' : (loadOn ? 'wait' : 'off'));
|
||||
const fw = $('fixWarn');
|
||||
if(dev.funcModeFixWarn){
|
||||
fw.style.display = '';
|
||||
fw.innerHTML = 'FUNC:MODE = '+(dev.funcMode||'?')+'(非 FIX)——实时功率控制已失效!请复位或手动恢复 FIX。';
|
||||
} else fw.style.display = 'none';
|
||||
$('measV').textContent = fmt(dev.measV, 2);
|
||||
$('measI').textContent = fmt(dev.measI, 3);
|
||||
$('measP').textContent = fmt(dev.measP, 1);
|
||||
$('measAge').innerHTML = 'V '+ageCell(dev,'measV')+' · I '+ageCell(dev,'measI')+' · P '+ageCell(dev,'measP');
|
||||
$('measDiag').innerHTML = '成功 <span style="color:var(--ok)">'+dev.measOk+'</span> · 失败 <span style="color:'+(dev.measFail>0?'var(--bad)':'var(--dim)')+'">'+dev.measFail+'</span>';
|
||||
const mde = $('measDiagErr');
|
||||
if(dev.lastMeasErr){
|
||||
mde.style.display = '';
|
||||
mde.textContent = dev.lastMeasErr;
|
||||
} else mde.style.display = 'none';
|
||||
$('devFunc').textContent = dev.func || '-';
|
||||
const fmName = (dev.funcMode || '').trim();
|
||||
$('devFuncMode').innerHTML = fmName
|
||||
? '<b style="font-size:15px">' + fmName + '</b>'
|
||||
+ (dev.funcModeFixWarn ? ' <span class="badge err">非FIX</span>' : ' <span class="badge ok">FIX</span>')
|
||||
: '<span class="dim">回读中...</span>';
|
||||
const cfs = $('cfgSync');
|
||||
if(dev.cfgSynced){
|
||||
cfs.innerHTML = '<span style="color:var(--ok)">已同步</span>('+ageText(now - dev.cfgSyncTime)+')';
|
||||
} else {
|
||||
cfs.innerHTML = '<span style="color:var(--warn)">同步中…</span>';
|
||||
}
|
||||
const fmf = $('funcModeFeed');
|
||||
if(dev.funcModeFeedValid){
|
||||
const ok = dev.funcModeFeedOk ? '已发送成功' : '发送失败(未连接或超时)';
|
||||
fmf.textContent = dev.funcModeFeed + ':' + ok + '('+ageText(now - dev.funcModeFeedTime)+')';
|
||||
fmf.style.color = dev.funcModeFeedOk ? 'var(--ok)' : 'var(--bad)';
|
||||
} else { fmf.textContent = '-'; fmf.style.color = ''; }
|
||||
setDisabled('btnFuncMode', !connected);
|
||||
setDisabled('funcModeSel', !connected);
|
||||
// 下拉框同步当前设备功能模式(仅当未聚焦时)
|
||||
const fmsel = $('funcModeSel');
|
||||
if(document.activeElement !== fmsel && dev.funcMode){
|
||||
const opts = ['FIX','LIST','BATT','SOLAR','CAR'];
|
||||
const idx = opts.indexOf(dev.funcMode);
|
||||
if(idx >= 0 && fmsel.selectedIndex !== idx) fmsel.selectedIndex = idx;
|
||||
}
|
||||
|
||||
//---- 卡3:控制 ----
|
||||
setDisabled('btnModeCC', !connected || cc);
|
||||
setDisabled('btnModeCV', !connected || !cc);
|
||||
$('ctrlTip').innerHTML = cc
|
||||
? '<b>CC 优先</b>:控制量 = 电流。目标功率/电流 → CURR 指令(负值=吸收电能)。'
|
||||
: '<b>CV 优先</b>:控制量 = 电压。恒压吸收,功率由外部源决定。';
|
||||
$('ccArea').style.display = cc ? '' : 'none';
|
||||
$('cvArea').style.display = cc ? 'none' : '';
|
||||
setDisabled('btnModeFollow', !connected || ctrl.mode==='follow');
|
||||
setDisabled('btnModeManual', !connected || ctrl.mode==='manual');
|
||||
setDisabled('btnModeCurrent', !connected || ctrl.mode==='current');
|
||||
$('curModeTip').innerHTML = '当前:<b>'+(modeName[ctrl.mode]||ctrl.mode)+'</b>';
|
||||
setInput('powerInput', ctrl.manualPower || 0);
|
||||
setInput('currInput', ctrl.manualCurrent || 0);
|
||||
setInput('voltInput', ctrl.voltSet || 0);
|
||||
setDisabled('btnSetPower', !connected);
|
||||
setDisabled('btnSetCurr', !connected);
|
||||
setDisabled('btnSetVolt', !connected);
|
||||
setDisabled('powerInput', !connected);
|
||||
setDisabled('currInput', !connected);
|
||||
setDisabled('voltInput', !connected);
|
||||
$('moosPower').innerHTML = (ctrl.moosPowerValid
|
||||
? fmt(ctrl.moosPower,1)+' W ('+ageText(ctrl.moosPowerAge)+')'
|
||||
: '<span class="dim">未收到</span>')
|
||||
+ (powerStale ? ' <span class="badge err">超时!</span>' : '');
|
||||
$('cmdCurrent').textContent = fmt(ctrl.cmdCurrent,4) + ' A';
|
||||
$('signTip').textContent = ctrl.sign==='negative' ? '负电流=吸收(负载模拟)' : '正电流=输出(电源模拟)';
|
||||
$('cmdVolt').textContent = fmt(ctrl.cmdVolt,2) + ' V';
|
||||
setDisabled('btnOutpOn', !connected || loadOn);
|
||||
setDisabled('btnOutpOff', !connected || !loadOn);
|
||||
$('outpTip').innerHTML = outpApplied
|
||||
? '<b style="color:var(--ok)">输出已开启</b>,正在实时控制'
|
||||
: (loadOn ? '<b style="color:var(--warn)">已请求开启,等待使能序列应用…</b>'
|
||||
: '输出未开启,开启前请完成步骤②参数设置');
|
||||
|
||||
//---- 卡4:参数 ----
|
||||
setInput('voltlimInput', dev.voltLimit || 0);
|
||||
setInput('currlimInput', dev.currLimit || 0);
|
||||
setInput('powlimInput', dev.powLimit || 0);
|
||||
setDisabled('voltlimInput', !connected);
|
||||
setDisabled('currlimInput', !connected);
|
||||
setDisabled('powlimInput', !connected);
|
||||
setDisabled('btnSetVoltlim', !connected);
|
||||
setDisabled('btnSetCurrlim', !connected);
|
||||
setDisabled('btnSetPowlim', !connected);
|
||||
setDisabled('btnRemote', !connected);
|
||||
setDisabled('btnLocal', !connected);
|
||||
const rs = $('remoteState');
|
||||
rs.textContent = dev.remoteState==='remote' ? '远控(SCPI 控制中)'
|
||||
: dev.remoteState==='local' ? '本控(面板控制)' : '未知';
|
||||
rs.style.color = dev.remoteState==='remote' ? 'var(--ok)'
|
||||
: dev.remoteState==='local' ? 'var(--warn)' : 'var(--dim)';
|
||||
const rf = $('remoteFeed');
|
||||
rf.textContent = dev.remoteFeedValid
|
||||
? dev.remoteFeed + '('+ageText(now - dev.remoteFeedTime)+')'
|
||||
: '-';
|
||||
const feedFail = (dev.remoteFeed||'').indexOf('失败') >= 0;
|
||||
rf.style.color = feedFail ? 'var(--bad)' : '';
|
||||
rf.style.fontFamily = feedFail ? 'Consolas,monospace' : '';
|
||||
|
||||
//---- 卡5:SCPI ----
|
||||
setDisabled('btnRawSend', !connected);
|
||||
setDisabled('rawCmd', !connected);
|
||||
|
||||
//---- 卡6/7:数据 ----
|
||||
$('measCount').textContent = db.measCount;
|
||||
$('statusCount').textContent = db.statusCount + ' / SCPI ' + db.scpiCount;
|
||||
}
|
||||
|
||||
//------------------ 数据分析(高采样测量 meas_log) ------------------
|
||||
function loadMeas(){
|
||||
fetch('/api/meas?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th class="l">V</th><th class="l">I(A)</th>'
|
||||
+ '<th class="l">P(W)</th><th class="l">指令I</th><th class="l">指令V</th><th class="l">目标P</th><th>输出</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString('zh-CN', {hour12:false}) + '.' + Math.floor((r.time%1)*1000);
|
||||
html += '<tr><td class="l">'+t+'</td><td class="l">'+fmt(r.v,2)+'</td><td class="l">'+fmt(r.i,3)+'</td>'
|
||||
+ '<td class="l">'+fmt(r.p,1)+'</td><td class="l">'+fmt(r.cmdI,3)+'</td><td class="l">'+fmt(r.cmdV,1)+'</td>'
|
||||
+ '<td class="l">'+fmt(r.targetP,1)+'</td>'
|
||||
+ '<td>'+(r.outp?1:0)+'</td></tr>';
|
||||
}
|
||||
$('measPreview').innerHTML = rows.length ? html : '<span class="dim">暂无数据</span>';
|
||||
})
|
||||
.catch(() => { $('measPreview').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
function downloadMeasCsv(){
|
||||
if(!confirm('导出最近 2000 条测量记录 (CSV) 用于离线分析?')) return;
|
||||
window.location.href = '/api/meas_csv?limit=2000';
|
||||
}
|
||||
function downloadMeasRange(){
|
||||
const st = parseFloat($('csvStart').value), et = parseFloat($('csvEnd').value);
|
||||
if(isNaN(st) || isNaN(et)){ alert('请输入有效的起始/结束 unix 秒'); return; }
|
||||
if(et <= st){ alert('结束时间必须大于起始时间'); return; }
|
||||
if(!confirm('导出时间范围 ['+st+' ~ '+et+'] 的测量记录 (CSV)?')) return;
|
||||
window.location.href = '/api/meas_csv?start='+st+'&end='+et;
|
||||
}
|
||||
function loadHistory(){
|
||||
fetch('/api/history?limit=60')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th>连</th><th>输出</th><th class="l">模式</th>'
|
||||
+ '<th>目标W</th><th>目标V</th><th>指令A</th><th>指令V</th><th>V</th><th>A</th><th>W</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||||
html += '<tr><td class="l">'+t+'</td><td>'+(r.connected?1:0)+'</td><td>'+(r.outp?1:0)+'</td>'
|
||||
+ '<td class="l">'+r.mode+'</td><td>'+fmt(r.targetPower,1)+'</td><td>'+fmt(r.voltSet,0)+'</td>'
|
||||
+ '<td>'+fmt(r.cmdCurrent,3)+'</td><td>'+fmt(r.cmdVolt,1)+'</td>'
|
||||
+ '<td>'+fmt(r.measVoltage,1)+'</td><td>'+fmt(r.measCurrent,3)+'</td><td>'+fmt(r.measPower,1)+'</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
$('history').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||||
})
|
||||
.catch(() => { $('history').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
function loadScpiLog(){
|
||||
fetch('/api/scpi_log?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const rows = j.rows || [];
|
||||
let html = '<table><tr><th>时间</th><th class="l">收/发</th><th class="l">内容</th></tr>';
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const r = rows[i];
|
||||
const t = new Date(r.time*1000).toLocaleTimeString();
|
||||
const dir = r.dir ? '<span style="color:#79c0ff">发</span>' : '<span style="color:#3fb950">收</span>';
|
||||
html += '<tr><td class="l">'+t+'</td><td class="l">'+dir+'</td><td class="l">'+r.text+'</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
$('scpiLog').innerHTML = rows.length ? html : '<span class="dim">暂无记录</span>';
|
||||
})
|
||||
.catch(() => { $('scpiLog').innerHTML = '<span class="errline">查询失败</span>'; });
|
||||
}
|
||||
|
||||
//------------------ WebSocket 1Hz 快照 ------------------
|
||||
let ws = null, wsTimer = null;
|
||||
function connectWs(){
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(proto + location.host + '/ws');
|
||||
ws.onopen = () => { statusEl.textContent = '已连接'; statusEl.className = 'badge ok'; };
|
||||
ws.onmessage = e => {
|
||||
try { render(JSON.parse(e.data)); } catch(err) {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
statusEl.textContent = '连接断开, 重连中...';
|
||||
statusEl.className = 'badge err';
|
||||
wsTimer = setTimeout(connectWs, 2000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWs();
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_PAGE_INDEX_H
|
||||
@@ -0,0 +1,199 @@
|
||||
#ifndef PELOAD_PAGE_TREND_H
|
||||
#define PELOAD_PAGE_TREND_H
|
||||
|
||||
#include <string>
|
||||
|
||||
//============================================================================
|
||||
// pELoad 实时曲线页(独立标签页)。
|
||||
//
|
||||
// - 通过 WebSocket /ws 接收 5Hz 快照,绘制电压/电流/功率三条实时曲线;
|
||||
// - 可从 /api/meas(100ms 高采样 meas_log)加载历史数据回填;
|
||||
// - 纯前端 canvas 绘制,无外部依赖。
|
||||
//============================================================================
|
||||
|
||||
namespace eload {
|
||||
const std::string TREND_HTML = R"HTML(<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pELoad 实时曲线</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#1a2130;--line:#2a3447;--fg:#e6edf3;--dim:#8b98ab;--blue:#58a6ff;--green:#3fb950;--orange:#d29922;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:var(--bg);color:var(--fg);font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;padding:14px;}
|
||||
h1{font-size:17px;margin-bottom:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--card);border:1px solid var(--line);}
|
||||
.badge.ok{color:var(--green);border-color:var(--green);}
|
||||
.badge.err{color:#f85149;border-color:#f85149;}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;}
|
||||
.panel h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;}
|
||||
.panel h2 .now{float:right;font-size:15px;font-family:Consolas,monospace;color:var(--fg);text-transform:none;}
|
||||
canvas{width:100%;height:180px;display:block;background:#0b1018;border:1px solid #22304a;border-radius:4px;}
|
||||
.controls{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.controls select{background:#0f1420;border:1px solid var(--line);border-radius:6px;color:var(--fg);padding:4px 8px;}
|
||||
.btn{padding:5px 14px;border:1px solid var(--line);border-radius:6px;background:#222b3d;color:var(--fg);font-size:12px;cursor:pointer;}
|
||||
.btn:hover{border-color:#3b4a63;}
|
||||
#status{color:var(--dim);font-size:12px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pELoad 实时曲线 <span id="status" class="badge">连接中...</span>
|
||||
<span style="flex:1"></span>
|
||||
<a class="btn" href="/" target="_blank">控制页面</a>
|
||||
</h1>
|
||||
|
||||
<div class="controls">
|
||||
<span style="color:var(--dim)">时间窗口</span>
|
||||
<select id="winSel">
|
||||
<option value="30">30 秒</option>
|
||||
<option value="60" selected>60 秒</option>
|
||||
<option value="120">120 秒</option>
|
||||
<option value="300">300 秒 (5分钟)</option>
|
||||
<option value="600">600 秒 (10分钟)</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadHistory()">加载历史数据 (最近窗口)</button>
|
||||
<span style="color:var(--dim)" id="info">采样: 0 点</span>
|
||||
</div>
|
||||
|
||||
<div class="panel"><h2>电压 V <span class="now" id="nowV" style="color:var(--blue)">-</span></h2><canvas id="cvV"></canvas></div>
|
||||
<div class="panel"><h2>电流 A <span class="now" id="nowI" style="color:var(--green)">-</span></h2><canvas id="cvI"></canvas></div>
|
||||
<div class="panel"><h2>功率 W <span class="now" id="nowP" style="color:var(--orange)">-</span></h2><canvas id="cvP"></canvas></div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const statusEl = $('status');
|
||||
const MAXPTS = 8000;
|
||||
// 数据缓冲:{t: ms时间戳, v, i, p}
|
||||
const data = [];
|
||||
let winSec = 60;
|
||||
|
||||
// 裁剪到时间窗口
|
||||
function trim(){
|
||||
const cutoff = Date.now() - winSec*1000;
|
||||
while(data.length && data[0].t < cutoff) data.shift();
|
||||
if(data.length > MAXPTS) data.splice(0, data.length - MAXPTS);
|
||||
$('info').textContent = '采样: ' + data.length + ' 点';
|
||||
}
|
||||
|
||||
function draw(canvas, key, color){
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width = canvas.clientWidth * (window.devicePixelRatio||1);
|
||||
const H = canvas.height = 180 * (window.devicePixelRatio||1);
|
||||
ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
|
||||
const w = canvas.clientWidth, h = 180;
|
||||
ctx.fillStyle = '#0b1018'; ctx.fillRect(0,0,w,h);
|
||||
|
||||
// 纵向范围(动态,含 0)
|
||||
let min = 0, max = 0, has = false;
|
||||
for(const p of data){
|
||||
const v = p[key];
|
||||
if(!isFinite(v)) continue;
|
||||
if(!has){ min = max = v; has = true; }
|
||||
else { if(v<min)min=v; if(v>max)max=v; }
|
||||
}
|
||||
if(!has){ min = 0; max = 1; }
|
||||
const pad = (max-min)*0.15 || 1;
|
||||
min -= pad; max += pad;
|
||||
if(max <= min){ max = min+1; }
|
||||
|
||||
// 网格(横向 5 条 + 纵向约 10 格)
|
||||
ctx.strokeStyle = '#1c2740'; ctx.lineWidth = 1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const y = (h-8) - (h-16) * (i/5) + 4;
|
||||
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
|
||||
}
|
||||
const tstep = winSec/10;
|
||||
for(let i=0;i<=10;i++){
|
||||
const x = (w-4) * (i/10) + 2;
|
||||
ctx.beginPath(); ctx.moveTo(x,4); ctx.lineTo(x,h-4); ctx.stroke();
|
||||
}
|
||||
|
||||
// 曲线
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
const nowT = Date.now();
|
||||
for(const p of data){
|
||||
const x = 2 + (w-4) * (1 - (nowT - p.t)/ (winSec*1000));
|
||||
const y = 4 + (h-16) * (1 - (p[key]-min)/(max-min));
|
||||
if(x < -10) continue;
|
||||
if(!started){ ctx.moveTo(x,y); started = true; }
|
||||
else ctx.lineTo(x,y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Y 轴刻度(最大值/最小值标注)
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '11px Consolas,monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(max.toFixed(max>100?0:1), 4, 14);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(min.toFixed(min>100?0:1), w-4, h-6);
|
||||
}
|
||||
|
||||
function redraw(){
|
||||
trim();
|
||||
draw($('cvV'), 'v', '#58a6ff');
|
||||
draw($('cvI'), 'i', '#3fb950');
|
||||
draw($('cvP'), 'p', '#d29922');
|
||||
}
|
||||
|
||||
// 实时快照(WebSocket 5Hz)
|
||||
function onSnapshot(s){
|
||||
const d = s.dev || {};
|
||||
if(!d.measVValid && !d.measIValid && !d.measPValid) return;
|
||||
const t = Date.now();
|
||||
data.push({t, v:d.measV, i:d.measI, p:d.measP});
|
||||
$('nowV').textContent = d.measV.toFixed(2);
|
||||
$('nowI').textContent = d.measI.toFixed(3);
|
||||
$('nowP').textContent = d.measP.toFixed(1);
|
||||
redraw();
|
||||
}
|
||||
|
||||
// 历史数据加载(/api/meas 时间范围查询,覆盖整个窗口)
|
||||
function loadHistory(){
|
||||
const nowSec = Date.now()/1000;
|
||||
const start = nowSec - winSec;
|
||||
$('info').textContent = '加载历史...';
|
||||
const url = '/api/meas?start=' + start.toFixed(3) + '&end=' + nowSec.toFixed(3) + '&limit=20000';
|
||||
fetch(url).then(r=>r.json()).then(j => {
|
||||
const arr = j.rows || []; // 时间升序
|
||||
if(!arr.length){ $('info').textContent = '暂无历史数据'; return; }
|
||||
const nowMs = Date.now();
|
||||
data.length = 0;
|
||||
for(let i=0;i<arr.length;i++){
|
||||
const r = arr[i];
|
||||
data.push({t: nowMs - (nowSec - r.time)*1000, v:r.v, i:r.i, p:r.p});
|
||||
}
|
||||
const last = arr[arr.length-1];
|
||||
$('nowV').textContent = last.v.toFixed(2);
|
||||
$('nowI').textContent = last.i.toFixed(3);
|
||||
$('nowP').textContent = last.p.toFixed(1);
|
||||
redraw();
|
||||
$('info').textContent = '已加载 ' + arr.length + ' 条历史';
|
||||
}).catch(()=>{ $('info').textContent = '历史加载失败'; });
|
||||
}
|
||||
|
||||
$('winSel').addEventListener('change', function(){ winSec = parseInt(this.value,10); redraw(); });
|
||||
|
||||
// WebSocket 5Hz 快照
|
||||
let ws=null, wsTimer=null;
|
||||
function connectWs(){
|
||||
const proto = location.protocol==='https:'?'wss://':'ws://';
|
||||
ws = new WebSocket(proto + location.host + '/ws');
|
||||
ws.onopen = () => { statusEl.textContent='已连接'; statusEl.className='badge ok'; };
|
||||
ws.onmessage = e => { try{ onSnapshot(JSON.parse(e.data)); }catch(err){} };
|
||||
ws.onclose = () => { statusEl.textContent='连接断开, 重连中...'; statusEl.className='badge err';
|
||||
wsTimer = setTimeout(connectWs, 2000); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connectWs();
|
||||
// 初始加载历史(有数据时立即有曲线)
|
||||
loadHistory();
|
||||
</script>
|
||||
</body>
|
||||
</html>)HTML";
|
||||
|
||||
} // namespace eload
|
||||
#endif // PELOAD_PAGE_TREND_H
|
||||
@@ -0,0 +1,8 @@
|
||||
本程序用于整个能源-动力系统中的电子负载控制,具备以下功能/需求
|
||||
1.通过以太网与docs/电子负载说明手册中的电子负载连接,并可以进行基本的控制、操作、参数设定等
|
||||
2.具备100ms周期的功率控制能力,后续有其他程序会通过MOOSDB发布功率消息,改功率消息为实施计算的电机功率,通过电子负载模拟电机功率的变化
|
||||
3.使用web页面进行人机交互
|
||||
4.具备sqlit3的数据存储功能
|
||||
5.使用moos文件进行配置电子负载地址、端口等信息
|
||||
|
||||
电子负载的详细操作及指令见docs/电子负载说明手册中的文件
|
||||
+148
-110
@@ -12,6 +12,9 @@
|
||||
// (fetch /api/motor_cmd -> pMotor 入队 -> MOOS CAN_TX -> pCanBridge -> CAN1)
|
||||
// - 展示:反馈转速/母线电压、温度、支路一/支路二故障与报警(按位解析中文)
|
||||
// - 协议定义:docs/推进电机通信协议20260321.docx
|
||||
//
|
||||
// 重要:页面 DOM 只在加载时构建一次,收到快照后仅就地更新各数值叶节点,
|
||||
// 绝不再整块重建 pages(重建会销毁正被聚焦/输入的控件导致光标跳动)。
|
||||
//============================================================================
|
||||
|
||||
namespace motor {
|
||||
@@ -56,7 +59,7 @@ input[type=number]:focus{outline:none;border-color:#1f6feb;}
|
||||
</head>
|
||||
<body>
|
||||
<h1>pMotor 推进电机控制 <span id="status" class="badge">连接中...</span></h1>
|
||||
<div class="grid" id="pages">加载中...</div>
|
||||
<div class="grid" id="pages"></div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
@@ -65,7 +68,6 @@ const statusEl = $('status');
|
||||
|
||||
function row(k, v){ return '<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>'; }
|
||||
function sub(t){ return '<div class="sub">'+t+'</div>'; }
|
||||
function hex(v){ return '0x' + (v & 0xFF).toString(16).toUpperCase().padStart(2,'0'); }
|
||||
function hex16(v){ return '0x' + (v & 0xFFFF).toString(16).toUpperCase().padStart(4,'0'); }
|
||||
function card(title, html){ return '<div class="card"><h2>'+title+'</h2>'+html+'</div>'; }
|
||||
function ageText(a){
|
||||
@@ -80,6 +82,8 @@ function ageCell(d){
|
||||
return (online ? '<span class="fc-ok">' : '<span class="fc-bad">')
|
||||
+ ageText(d.age) + (online ? ' (在线)' : ' (超时)') + '</span>';
|
||||
}
|
||||
// 就地更新:只替换叶节点内容,绝不重建含输入控件的父级 DOM
|
||||
function setH(id, html){ const el = $(id); if(el) el.innerHTML = html; }
|
||||
|
||||
//------------------ 控制指令(/api/motor_cmd) ------------------
|
||||
// 链路: 网页 fetch -> pMotor 入队 -> Iterate 周期编码 0x18EF2010
|
||||
@@ -117,135 +121,172 @@ function motorSetSpeed(){
|
||||
sendCmd('speed', v, '确认设定期望转速 '+v+' rpm?\n(对应指令帧 Byte2/3,使能状态保持不变)');
|
||||
}
|
||||
|
||||
//------------------ 控制卡片 ------------------
|
||||
function cmdCard(s){
|
||||
const c = s || {};
|
||||
const cmd = c.cmd || {};
|
||||
const en = cmd.enable;
|
||||
let h = '';
|
||||
h += row('使能状态', en
|
||||
? '<span class="fc-ok">已使能(运行)</span>'
|
||||
: '<span style="color:var(--dim)">未使能(停机)</span>');
|
||||
h += row('期望转速', (cmd.speed||0) + ' rpm');
|
||||
h += row('复位命令', cmd.resetActive ? '<span class="fc-warn">生效中(保持数秒)</span>' : '无效');
|
||||
if(cmd.lastCmdValid){
|
||||
const names = {enable:'使能命令=1', disable:'使能命令=0(停机)', reset:'复位命令=1', speed:'期望转速'};
|
||||
h += row('最近指令', (names[cmd.lastCmdAction]||cmd.lastCmdAction)
|
||||
+ (cmd.lastCmdAction==='speed' ? ' ('+(cmd.lastCmdSpeed||0)+' rpm)' : '')
|
||||
+ ' · ' + ageText(cmd.lastCmdAge));
|
||||
} else {
|
||||
h += row('最近指令', '<span style="color:var(--dim)">无</span>');
|
||||
}
|
||||
h += '<div class="sub">使能命令 (Byte0 bit0)</div>' +
|
||||
//------------------ 页面骨架(只在加载时构建一次) ------------------
|
||||
// 所有可被聚焦的控件(输入框/按钮)都在这份骨架里,之后绝不重建。
|
||||
function buildUI(){
|
||||
let html = '';
|
||||
|
||||
// 控制卡片(含输入框,永不重建)
|
||||
let c = '';
|
||||
c += row('使能状态', '<span id="cEnable">…</span>');
|
||||
c += row('期望转速', '<span id="cSpeed">…</span> rpm');
|
||||
c += row('复位命令', '<span id="cReset">…</span>');
|
||||
c += row('最近指令', '<span id="cLast">…</span>');
|
||||
c += sub('使能命令 (Byte0 bit0)') +
|
||||
'<div>' +
|
||||
'<button class="btn on" onclick="motorEnable()">使能 (1)</button>' +
|
||||
'<button class="btn off" onclick="motorStop()">去使能 (0)</button></div>' +
|
||||
'<div class="sub">复位命令 (Byte0 bit1,仅故障状态下有效)</div>' +
|
||||
'<div>' +
|
||||
'<button class="btn warn" onclick="motorReset()">复位</button></div>' +
|
||||
'<div class="sub">期望转速 (Byte2/3,s16,单位 rpm)</div>' +
|
||||
'<button class="btn off" onclick="motorStop()">去使能 (0)</button></div>';
|
||||
c += sub('复位命令 (Byte0 bit1,仅故障状态下有效)') +
|
||||
'<div><button class="btn warn" onclick="motorReset()">复位</button></div>';
|
||||
c += sub('期望转速 (Byte2/3,s16,单位 rpm)') +
|
||||
'<div>' +
|
||||
'<input type="number" id="speedInput" value="1000" min="-32767" max="32767" step="100" title="期望转速 (rpm)">' +
|
||||
'<span style="color:var(--dim);margin-left:6px;">rpm</span> ' +
|
||||
'<button class="btn" onclick="motorSetSpeed()">设定</button></div>' +
|
||||
'<div class="sub" style="margin-top:8px;">指令帧 0x18EF2010 周期 ' + ((c.link&&c.link.periodMs)||500) +
|
||||
'ms 持续发送(协议要求);停机=使能0且转速清0(协议示例 00 00 00 00 00 00 00 00)</div>';
|
||||
return card('电机指令 (下行 0x18EF2010)', h);
|
||||
'<div class="sub" style="margin-top:8px;">指令帧 0x18EF2010 周期 <span id="cPeriod">…</span>' +
|
||||
' ms 持续发送(协议要求);停机=使能0且转速清0(协议示例 00 00 00 00 00 00 00 00)</div>';
|
||||
html += card('电机指令 (下行 0x18EF2010)', c);
|
||||
|
||||
// 运行状态(状态帧3:转速 / 母线电压)
|
||||
let s3 = '';
|
||||
s3 += row('反馈转速', '<span id="s3speed" class="state-big">…</span> rpm');
|
||||
s3 += row('母线电压', '<span id="s3volt">…</span>');
|
||||
s3 += row('最后更新', '<span id="s3age">…</span>');
|
||||
s3 += sub('来源 0x18EF1220(100ms,冗余 0x18EF1221)');
|
||||
html += card('运行状态', s3);
|
||||
|
||||
// 温度(状态帧4)
|
||||
let s4 = '';
|
||||
s4 += row('A相绕组温度', '<span id="tA">…</span>');
|
||||
s4 += row('B相绕组温度', '<span id="tB">…</span>');
|
||||
s4 += row('C相绕组温度', '<span id="tC">…</span>');
|
||||
s4 += row('逆变组件底板温度', '<span id="tInv">…</span>');
|
||||
s4 += row('输出滤波器温度', '<span id="tFilter">…</span>');
|
||||
s4 += row('筒内空气温度', '<span id="tAir">…</span>');
|
||||
s4 += row('最后更新', '<span id="tAge">…</span>');
|
||||
s4 += sub('来源 0x18EF1320(100ms,冗余 0x18EF1321,Byte0~5 各 1 字节 s8)');
|
||||
html += card('温度', s4);
|
||||
|
||||
// 支路一 / 支路二 故障报警卡片
|
||||
function branchCard(title, pfx){
|
||||
let b = '';
|
||||
b += '<div class="row"><span class="k" id="'+pfx+'fK">故障</span>' +
|
||||
'<span class="v"><span id="'+pfx+'faults">…</span></span></div>';
|
||||
b += '<div class="row"><span class="k" id="'+pfx+'aK">报警</span>' +
|
||||
'<span class="v"><span id="'+pfx+'alarms">…</span></span></div>';
|
||||
b += row('故障字原始值', '<span id="'+pfx+'words">…</span>');
|
||||
b += row('最后更新', '<span id="'+pfx+'age">…</span>');
|
||||
return card(title, b);
|
||||
}
|
||||
html += branchCard('支路一故障/报警 (0x18EF1020)', 'f1');
|
||||
html += branchCard('支路二故障/报警 (0x18EF1120)', 'f2');
|
||||
|
||||
// 通信链路卡片
|
||||
let l = '';
|
||||
l += row('下行通道 (CAN1)', '<span id="lch">…</span>');
|
||||
l += row('冗余通道', '<span id="lred">…</span>');
|
||||
l += row('指令帧周期', '<span id="lperiod">…</span> ms');
|
||||
l += row('指令帧累计', '<span id="ltx">…</span>');
|
||||
l += row('最近下发', '<span id="llast">…</span>');
|
||||
l += row('电机报文接收', '<span id="lrx">…</span> 帧');
|
||||
l += sub('链路: 电机 <-> CAN1 <-> CANET <-> pCanBridge <-> MOOS <-> pMotor');
|
||||
html += card('通信链路 (pCanBridge)', l);
|
||||
|
||||
// 协议参考卡片(纯静态)
|
||||
let p = '';
|
||||
p += row('0x18EF2010', '主控指令帧(下行 500ms,冗余 0x18EF2011)');
|
||||
p += row('0x18EF2110', '主控限制帧(下行,全预留,暂不使用)');
|
||||
p += row('0x18EF1020', '支路一故障报警状态帧1(上行 200ms,冗余 0x18EF1021)');
|
||||
p += row('0x18EF1120', '支路二故障报警状态帧2(上行 200ms,冗余 0x18EF1121)');
|
||||
p += row('0x18EF1220', '状态帧3:反馈转速/母线电压(上行 100ms,冗余 0x18EF1221)');
|
||||
p += row('0x18EF1320', '状态帧4:温度(上行 100ms,冗余 0x18EF1321)');
|
||||
p += row('帧格式', 'CAN2.0B 扩展帧 29 位 ID,数据域 8 字节,Intel 字节序');
|
||||
p += row('波特率', '250 kbps(默认,按客户可定制 125~1000)');
|
||||
html += card('报文定义 (推进电机通信协议 20260321)', p);
|
||||
|
||||
pagesEl.innerHTML = html;
|
||||
}
|
||||
|
||||
//------------------ 状态帧3:转速 / 母线电压 ------------------
|
||||
function state3Card(d){
|
||||
if(!d) return '';
|
||||
let h = '';
|
||||
h += row('反馈转速', '<span class="state-big">'+(d.valid?(d.speed||0):'—')+'</span> rpm');
|
||||
h += row('母线电压', (d.valid?(d.voltage||0):'—') + (d.valid?' V':''));
|
||||
h += row('最后更新', ageCell(d));
|
||||
h += sub('来源 0x18EF1220(100ms,冗余 0x18EF1221)');
|
||||
return card('运行状态', h);
|
||||
//------------------ 快照就地更新(不触碰输入控件) ------------------
|
||||
function updateCmd(c){
|
||||
c = c || {};
|
||||
setH('cEnable', c.enable
|
||||
? '<span class="fc-ok">已使能(运行)</span>'
|
||||
: '<span style="color:var(--dim)">未使能(停机)</span>');
|
||||
setH('cSpeed', String(c.speed || 0));
|
||||
setH('cReset', c.resetActive ? '<span class="fc-warn">生效中(保持数秒)</span>' : '无效');
|
||||
if(c.lastCmdValid){
|
||||
const names = {enable:'使能命令=1', disable:'使能命令=0(停机)', reset:'复位命令=1', speed:'期望转速'};
|
||||
setH('cLast', (names[c.lastCmdAction] || c.lastCmdAction)
|
||||
+ (c.lastCmdAction==='speed' ? ' ('+(c.lastCmdSpeed||0)+' rpm)' : '')
|
||||
+ ' · ' + ageText(c.lastCmdAge));
|
||||
} else {
|
||||
setH('cLast', '<span style="color:var(--dim)">无</span>');
|
||||
}
|
||||
}
|
||||
|
||||
//------------------ 状态帧4:温度 ------------------
|
||||
function state4Card(d){
|
||||
if(!d) return '';
|
||||
const v = d.valid;
|
||||
let h = '';
|
||||
function updateState3(d){
|
||||
if(!d) return;
|
||||
setH('s3speed', d.valid ? String(d.speed || 0) : '—');
|
||||
setH('s3volt', d.valid ? ((d.voltage||0) + ' V') : '—');
|
||||
setH('s3age', ageCell(d));
|
||||
}
|
||||
|
||||
function updateState4(d){
|
||||
if(!d) return;
|
||||
const temps = [
|
||||
['A相绕组温度', d.tA], ['B相绕组温度', d.tB], ['C相绕组温度', d.tC],
|
||||
['逆变组件底板温度', d.tInv], ['输出滤波器温度', d.tFilter], ['筒内空气温度', d.tAir],
|
||||
['tA', d.tA], ['tB', d.tB], ['tC', d.tC],
|
||||
['tInv', d.tInv], ['tFilter', d.tFilter], ['tAir', d.tAir],
|
||||
];
|
||||
for(const t of temps){
|
||||
h += row(t[0], v ? (t[1]|0) + ' ℃' : '—');
|
||||
setH(t[0], d.valid ? ((t[1]|0) + ' ℃') : '—');
|
||||
}
|
||||
h += row('最后更新', ageCell(d));
|
||||
h += sub('来源 0x18EF1320(100ms,冗余 0x18EF1321,Byte0~5 各 1 字节 s8)');
|
||||
return card('温度', h);
|
||||
setH('tAge', ageCell(d));
|
||||
}
|
||||
|
||||
//------------------ 支路故障/报警卡片 ------------------
|
||||
function branchCard(title, d){
|
||||
if(!d) return '';
|
||||
const words = d.words || [];
|
||||
let h = '';
|
||||
function updateBranch(pfx, d){
|
||||
if(!d) return;
|
||||
const faults = d.faults || [];
|
||||
const alarms = d.alarms || [];
|
||||
if(faults.length){
|
||||
h += row('故障(' + faults.length + '条)', faults.map(function(f){ return '<span class="fc-bad">'+f+'</span>'; }).join(''));
|
||||
} else {
|
||||
h += row('故障', d.valid ? '<span class="fc-ok">无</span>' : '—');
|
||||
}
|
||||
if(alarms.length){
|
||||
h += row('报警(' + alarms.length + '条)', alarms.map(function(a){ return '<span class="fc-warn">'+a+'</span>'; }).join(''));
|
||||
} else {
|
||||
h += row('报警', d.valid ? '<span class="fc-ok">无</span>' : '—');
|
||||
}
|
||||
h += row('故障字原始值', words.map(hex16).join(' '));
|
||||
h += row('最后更新', ageCell(d));
|
||||
return card(title, h);
|
||||
setH(pfx+'fK', '故障' + (faults.length ? '(' + faults.length + '条)' : ''));
|
||||
setH(pfx+'aK', '报警' + (alarms.length ? '(' + alarms.length + '条)' : ''));
|
||||
setH(pfx+'faults', faults.length
|
||||
? faults.map(function(f){ return '<span class="fc-bad">'+f+'</span>'; }).join('')
|
||||
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
|
||||
setH(pfx+'alarms', alarms.length
|
||||
? alarms.map(function(a){ return '<span class="fc-warn">'+a+'</span>'; }).join('')
|
||||
: (d.valid ? '<span class="fc-ok">无</span>' : '—'));
|
||||
setH(pfx+'words', (d.words || []).map(hex16).join(' '));
|
||||
setH(pfx+'age', ageCell(d));
|
||||
}
|
||||
|
||||
//------------------ 通信链路卡片 ------------------
|
||||
function linkCard(d){
|
||||
const l = (d && d.link) || {};
|
||||
let h = '';
|
||||
h += row('下行通道 (CAN1)', l.channel || 'CAN1');
|
||||
h += row('冗余通道', l.redChannel ? l.redChannel : '<span style="color:var(--dim)">未配置</span>');
|
||||
h += row('指令帧周期', (l.periodMs||0) + ' ms');
|
||||
h += row('指令帧累计', (l.txCount||0) + ' 成功 / ' + (l.txErr||0) + ' 失败');
|
||||
h += row('最近下发', ageCell({valid:!!l.lastTxValid, age:l.lastTxAge}));
|
||||
h += row('电机报文接收', (l.rxCount||0) + ' 帧');
|
||||
h += sub('链路: 电机 <-> CAN1 <-> CANET <-> pCanBridge <-> MOOS <-> pMotor');
|
||||
return card('通信链路 (pCanBridge)', h);
|
||||
}
|
||||
|
||||
//------------------ 协议参考卡片 ------------------
|
||||
function protoCard(){
|
||||
let h = '';
|
||||
h += row('0x18EF2010', '主控指令帧(下行 500ms,冗余 0x18EF2011)');
|
||||
h += row('0x18EF2110', '主控限制帧(下行,全预留,暂不使用)');
|
||||
h += row('0x18EF1020', '支路一故障报警状态帧1(上行 200ms,冗余 0x18EF1021)');
|
||||
h += row('0x18EF1120', '支路二故障报警状态帧2(上行 200ms,冗余 0x18EF1121)');
|
||||
h += row('0x18EF1220', '状态帧3:反馈转速/母线电压(上行 100ms,冗余 0x18EF1221)');
|
||||
h += row('0x18EF1320', '状态帧4:温度(上行 100ms,冗余 0x18EF1321)');
|
||||
h += row('帧格式', 'CAN2.0B 扩展帧 29 位 ID,数据域 8 字节,Intel 字节序');
|
||||
h += row('波特率', '250 kbps(默认,按客户可定制 125~1000)');
|
||||
return card('报文定义 (推进电机通信协议 20260321)', h);
|
||||
function updateLink(l){
|
||||
l = l || {};
|
||||
setH('lch', l.channel || 'CAN1');
|
||||
setH('lred', l.redChannel ? l.redChannel : '<span style="color:var(--dim)">未配置</span>');
|
||||
setH('lperiod', String(l.periodMs || 0));
|
||||
setH('ltx', (l.txCount||0) + ' 成功 / ' + (l.txErr||0) + ' 失败');
|
||||
setH('llast', ageCell({valid:!!l.lastTxValid, age:l.lastTxAge}));
|
||||
setH('lrx', String(l.rxCount || 0));
|
||||
}
|
||||
|
||||
function render(snap){
|
||||
let html = '';
|
||||
html += cmdCard(snap);
|
||||
html += state3Card(snap.state3);
|
||||
html += state4Card(snap.state4);
|
||||
html += branchCard('支路一故障/报警 (0x18EF1020)', snap.fault1);
|
||||
html += branchCard('支路二故障/报警 (0x18EF1120)', snap.fault2);
|
||||
html += linkCard(snap);
|
||||
html += protoCard();
|
||||
pagesEl.innerHTML = html;
|
||||
// 保留转速输入值(render 会重建 DOM)
|
||||
const prev = window.__lastSpeed;
|
||||
if(prev !== undefined){ const el = $('speedInput'); if(el) el.value = prev; }
|
||||
snap = snap || {};
|
||||
const link = snap.link || {};
|
||||
updateCmd(snap.cmd);
|
||||
updateState3(snap.state3);
|
||||
updateState4(snap.state4);
|
||||
updateBranch('f1', snap.fault1);
|
||||
updateBranch('f2', snap.fault2);
|
||||
updateLink(link);
|
||||
setH('cPeriod', String(link.periodMs || 500));
|
||||
}
|
||||
|
||||
// 骨架构建一次后即保持稳定;render 只做叶节点就地更新,
|
||||
// 输入框不再被销毁重建,光标与焦点因此不受快照刷新影响。
|
||||
buildUI();
|
||||
render({});
|
||||
|
||||
function connect(){
|
||||
const proto = location.protocol==='https:'?'wss':'ws';
|
||||
const ws = new WebSocket(proto+'://'+location.host+'/ws');
|
||||
@@ -254,10 +295,7 @@ function connect(){
|
||||
ws.onerror = ()=>{ ws.close(); };
|
||||
ws.onmessage = (ev)=>{
|
||||
try{
|
||||
const snap = JSON.parse(ev.data);
|
||||
const el = $('speedInput');
|
||||
if(el && el === document.activeElement) window.__lastSpeed = el.value;
|
||||
render(snap);
|
||||
render(JSON.parse(ev.data));
|
||||
}catch(e){}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user