diff --git a/.gitignore b/.gitignore index d57c068..cecd47a 100644 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,7 @@ bin/pCCU bin/pccuTest bin/pCanBridge bin/pMotor +bin/pELoad # CI test reports reports/ diff --git a/missions/h100.moos b/missions/h100.moos index f7bad4b..11ed1ad 100644 --- a/missions/h100.moos +++ b/missions/h100.moos @@ -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,37 @@ 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.0.10 + 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 + + //======== 数据存储 ======== + dbpath = /root/work/h100/data/pELoad.db + log_keep_hours = 72 + meas_log_period_ms = 100 + + //======== 网页(避开 8080/8090/18080/18081)======== + web_port = 18082 + web_enable = true + + logpath = /root/work/h100/data/pELoad.log +} diff --git a/scripts/build-board.sh b/scripts/build-board.sh index ea9483d..f36df00 100755 --- a/scripts/build-board.sh +++ b/scripts/build-board.sh @@ -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" </dev/null || true" +ok "pELoad.service 已安装" + echo "" ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)" ${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 726de24..b472e64 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -23,6 +23,7 @@ add_subdirectory(pPowerMangerHost) add_subdirectory(pCCU) add_subdirectory(pCanBridge) add_subdirectory(pMotor) +add_subdirectory(pELoad) ############################################################################## # END of CMakeLists.txt ############################################################################## diff --git a/src/pELoad/CMakeLists.txt b/src/pELoad/CMakeLists.txt new file mode 100644 index 0000000..1d2c37f --- /dev/null +++ b/src/pELoad/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} +) diff --git a/src/pELoad/ELoad.cpp b/src/pELoad/ELoad.cpp new file mode 100644 index 0000000..f215459 --- /dev/null +++ b/src/pELoad/ELoad.cpp @@ -0,0 +1,1175 @@ +#include "ELoad.h" +#include "MBUtils.h" +#include "../pPowerManger/logc/loguru.hpp" +#include "json/json.h" +#include +#include +#include +#include +#include + +using namespace eload; +using namespace std; + +//--------------------------------------------------------- +// 信号处理:SIGTERM/SIGINT 时置标志,由 MOOS 线程在 Iterate 中 +// 安全关闭设备输出后退出(信号处理函数内不做任何 I/O) + +namespace { +std::atomic g_sigShutdown(false); + +void signalHandler(int sig) { + (void)sig; + g_sigShutdown = true; +} +} // namespace + +//--------------------------------------------------------- +// Constructor / Destructor + +ELoad::ELoad() : m_dev(m_scpi) {} + +ELoad::~ELoad() { + // 顺序:网页 -> 控制线程 -> 安全关输出 -> SCPI 链路 -> 数据库 + if (m_web) { m_web->stop(); delete m_web; m_web = nullptr; } + if (m_ctrlRunning) { + m_ctrlRunning = false; + if (m_ctrlThread.joinable()) m_ctrlThread.join(); + } + // 安全:退出前关闭设备输出(若仍在吸收状态) + if (m_scpi.isConnected() && m_cfgApplied) { + m_dev.output(false); + } + m_scpi.stop(); + if (m_db) { m_db->close(); delete m_db; m_db = nullptr; } +} + +//--------------------------------------------------------- +// OnStartUp:读取配置并初始化各组件 + +bool ELoad::OnStartUp() { + AppCastingMOOSApp::OnStartUp(); + + // 优雅退出:SIGTERM/SIGINT -> Iterate 中关闭输出后再退出 + std::signal(SIGTERM, signalHandler); + std::signal(SIGINT, signalHandler); + + STRING_LIST sParams; + m_MissionReader.EnableVerbatimQuoting(false); + if (!m_MissionReader.GetConfiguration(GetAppName(), sParams)) + reportConfigWarning("No config block found for " + GetAppName()); + + STRING_LIST::iterator p; + for (p = sParams.begin(); p != sParams.end(); p++) { + string orig = *p; + string line = *p; + string param = stripBlankEnds(tolower(biteStringX(line, '='))); + string value = stripBlankEnds(line); + + bool handled = true; + if (param == "eload_ip") m_host = value; + else if (param == "eload_port") m_port = atol(value.c_str()); + else if (param == "power_var") m_powerVar = toupper(value); + else if (param == "ctrl_period_ms") m_ctrlPeriodMs = atoi(value.c_str()); + else if (param == "power_stale_sec")m_powerStaleSec= atof(value.c_str()); + else if (param == "current_sign") m_sinkNegative = (tolower(value) != "positive"); + else if (param == "nominal_volt") m_nominalVolt = atof(value.c_str()); + else if (param == "max_power") m_maxPower = atof(value.c_str()); + else if (param == "max_current") m_maxCurrent = atof(value.c_str()); + else if (param == "slew_limit_wps") m_slewLimitWps = atof(value.c_str()); + else if (param == "volt_limit") m_voltLimit = atof(value.c_str()); + else if (param == "curr_limit") m_currLimitPos = atof(value.c_str()); + else if (param == "curr_limit_neg") m_currLimitNeg = atof(value.c_str()); + else if (param == "pow_limit") m_powLimitPos = atof(value.c_str()); + else if (param == "pow_limit_neg") m_powLimitNeg = atof(value.c_str()); + else if (param == "func_priority") m_ccPriority = (tolower(value) != "cv"); + else if (param == "volt_set") m_voltSet = atof(value.c_str()); + else if (param == "dbpath") m_dbPath = value; + else if (param == "log_keep_hours") m_logKeepHours = atof(value.c_str()); + else if (param == "meas_log_period_ms") m_measLogPeriodMs = atoi(value.c_str()); + else if (param == "web_port") m_webPort = atoi(value.c_str()); + else if (param == "web_enable") m_webEnable = (tolower(value) == "true" || value == "1"); + else if (param == "logpath") m_logPath = value; + else handled = false; + + if (!handled) + reportUnhandledConfigWarning(orig); + } + if (m_ctrlPeriodMs < 20) m_ctrlPeriodMs = 20; + + registerVariables(); + + // 日志文件 + if (m_logPath.empty()) m_logPath = "pELoad.log"; + loguru::add_file(m_logPath.c_str(), loguru::Append, loguru::Verbosity_MAX); + LOG_F(INFO, "pELoad log path: %s", m_logPath.c_str()); + + // 数据库 + m_db = new DbStore(m_dbPath); + if (!m_db->open()) + LOG_F(ERROR, "[ELoad] sqlite open failed (%s): %s", + m_dbPath.c_str(), m_db->lastError().c_str()); + else + LOG_F(INFO, "[ELoad] sqlite opened: %s", m_dbPath.c_str()); + + // SCPI 客户端 + m_scpi.configure(m_host, m_port); + m_scpi.setLogSink([this](const std::string& cmd, const std::string& resp) { + scpiLogSink(cmd, resp); + }); + m_scpi.setStateCallback([this](bool connected) { + if (!connected) { + // 断线后需重新执行远控/配置/输出序列 + m_remoteApplied = false; + m_cfgApplied = false; + } + LOG_F(INFO, "[ELoad] link %s", connected ? "connected" : "disconnected"); + }); + if (!m_scpi.start()) + LOG_F(ERROR, "[ELoad] scpi client start failed (%s:%ld)", m_host.c_str(), m_port); + + // 控制线程(100ms 周期) + m_ctrlRunning = true; + m_ctrlThread = std::thread([this]() { ctrlThreadFunc(); }); + + // 网页 + if (m_webEnable) { + m_web = new WebServer(); + m_web->setOnOpen([this]() { return buildSnapshot(); }); + m_web->setApiHandler([this](const std::string& uri, const std::string& query) { + return handleApi(uri, query); + }); + if (!m_web->start(m_webPort)) { + LOG_F(ERROR, "web server start failed on port %d", m_webPort); + delete m_web; + m_web = nullptr; + } + } + + LOG_F(INFO, "pELoad started: device %s:%ld, power_var=%s, ctrl=%dms, " + "sign=%s, nominal_volt=%.1f, web=%s", + m_host.c_str(), m_port, m_powerVar.c_str(), m_ctrlPeriodMs, + m_sinkNegative ? "negative(sink)" : "positive(source)", m_nominalVolt, + m_web ? ("port " + to_string(m_webPort)).c_str() : "disabled"); + return true; +} + +//--------------------------------------------------------- +// OnConnectToServer + +bool ELoad::OnConnectToServer() { + registerVariables(); + return true; +} + +//--------------------------------------------------------- +// registerVariables:订阅功率消息 + +void ELoad::registerVariables() { + AppCastingMOOSApp::RegisterVariables(); + if (!m_powerVar.empty()) + Register(m_powerVar, 0); +} + +//--------------------------------------------------------- +// OnNewMail:接收功率消息(实时电机功率,W) + +bool ELoad::OnNewMail(MOOSMSG_LIST &NewMail) { + AppCastingMOOSApp::OnNewMail(NewMail); + MOOSMSG_LIST::iterator p; + for (p = NewMail.begin(); p != NewMail.end(); p++) { + CMOOSMsg &msg = *p; + if (msg.m_sKey != m_powerVar) + continue; + double pw = 0; + if (msg.IsDouble()) + pw = msg.m_dfVal; + else if (msg.IsString()) + pw = atof(msg.m_sVal.c_str()); + else + continue; + + std::lock_guard lock(m_stateMutex); + m_moosPower = pw; + m_moosPowerTime = MOOSTime(false); + } + return true; +} + +//--------------------------------------------------------- +// Iterate:周期任务(状态发布;控制回路在独立线程) + +bool ELoad::Iterate() { + AppCastingMOOSApp::Iterate(); + + // 信号触发的安全退出:先关设备输出,再结束进程 + if (g_sigShutdown) { + LOG_F(INFO, "[ELoad] shutdown signal, closing device output..."); + if (m_scpi.isConnected()) { + m_dev.output(false); + } + m_Comms.Close(true); + std::exit(0); + } + + double now = MOOSTime(); + + // 状态发布 1Hz:ELOAD_STATUS(JSON) + ELOAD_MEAS_POWER(double) + static double lastPub = 0; + if (now - lastPub >= 1.0) { + lastPub = now; + std::string snap = buildSnapshot(); + m_Comms.Notify("ELOAD_STATUS", snap); + { + std::lock_guard lock(m_stateMutex); + m_Comms.Notify("ELOAD_MEAS_POWER", m_measP); + ++m_pubCount; + } + if (m_web) + m_web->broadcast(snap); + } + + AppCastingMOOSApp::PostReport(); + return true; +} + +//--------------------------------------------------------- +// ctrlThreadFunc:100ms 周期控制回路(独立线程) + +void ELoad::ctrlThreadFunc() { + double period = m_ctrlPeriodMs / 1000.0; + double next = MOOSTime(false); + while (m_ctrlRunning) { + double now = MOOSTime(false); + + processWebCmds(); + doControlCycle(now); + dbPeriodic(now); + + double cycleEnd = MOOSTime(false); + double cycleT = cycleEnd - now; + + next += period; + double wait = next - cycleEnd; + if (wait < -period) { + // 落后过多(查询阻塞等),重新对齐节拍 + next = cycleEnd + period; + wait = period; + } + // 防止负等待导致睡眠失败(tv_sec<0/tv_nsec<0),等待过短时直接跳过 + if (wait > 0.0005) { + struct timespec ts; + ts.tv_sec = (time_t)wait; + ts.tv_nsec = (long)((wait - (double)ts.tv_sec) * 1e9); + if (ts.tv_nsec < 0) ts.tv_nsec = 0; + nanosleep(&ts, nullptr); + } else { + next = cycleEnd + period; + } + + // 周期诊断(10s 一条,超时抖动可观察) + if (cycleT > period * 1.5) { + static double lastWarn = 0; + if (now - lastWarn >= 10.0) { + lastWarn = now; + LOG_F(WARNING, "[ELoad] control cycle overrun: %.0f ms (period %d ms)", + cycleT * 1000.0, m_ctrlPeriodMs); + } + } + } +} + +//--------------------------------------------------------- +// processWebCmds:出队网页指令并应用 + +void ELoad::processWebCmds() { + std::vector cmds; + { + std::lock_guard lock(m_stateMutex); + cmds.swap(m_webCmdQueue); + } + + for (size_t i = 0; i < cmds.size(); ++i) { + WebCmd& c = cmds[i]; + switch (c.type) { + case WCMD_CONNECT: + m_linkEnable = true; + if (!m_scpi.isConnected()) m_scpi.start(); + LOG_F(INFO, "[ELoad/web] connect"); + break; + case WCMD_DISCONNECT: + m_linkEnable = false; + if (m_loadOn) { m_loadOn = false; } + // 安全:断链前先尝试关闭输出,避免设备保持吸收状态 + if (m_scpi.isConnected()) { + m_dev.output(false); + m_cfgApplied = false; + } + m_scpi.stop(); + LOG_F(INFO, "[ELoad/web] disconnect"); + break; + case WCMD_OUTP_ON: + m_loadOn = true; + LOG_F(INFO, "[ELoad/web] output ON requested"); + break; + case WCMD_OUTP_OFF: + m_loadOn = false; + LOG_F(INFO, "[ELoad/web] output OFF requested"); + break; + case WCMD_REMOTE: + if (m_scpi.isConnected()) m_dev.remote(); + break; + case WCMD_LOCAL: + if (m_scpi.isConnected()) { + m_dev.local(); + m_loadOn = false; + m_remoteApplied = false; + m_cfgApplied = false; + } + break; + case WCMD_MODE_CC: + case WCMD_MODE_CV: { + bool cc = (c.type == WCMD_MODE_CC); + std::lock_guard lock(m_stateMutex); + if (m_ccPriority != cc) { + m_ccPriority = cc; + m_cfgApplied = false; // 使能中切换模式 -> 重新执行配置序列 + } + // CV 优先 = 恒压吸收,控制量固定为电压(volt 模式); + // 切回 CC 后恢复跟随 MOOS 功率 + if (!cc) m_mode = "volt"; + else if (m_mode == "volt") m_mode = "follow"; + LOG_F(INFO, "[ELoad/web] func priority = %s, ctrl mode = %s", + cc ? "CC" : "CV", m_mode.c_str()); + break; + } + case WCMD_CTRL_FOLLOW: + case WCMD_CTRL_MANUAL: + case WCMD_CTRL_CURRENT: { + std::lock_guard lock(m_stateMutex); + if (!m_ccPriority) { + LOG_F(WARNING, "[ELoad/web] CV 优先下控制量固定为电压,忽略该切换"); + break; + } + m_mode = (c.type == WCMD_CTRL_FOLLOW) ? "follow" + : (c.type == WCMD_CTRL_MANUAL) ? "manual" + : "current"; + LOG_F(INFO, "[ELoad/web] ctrl mode = %s", m_mode.c_str()); + break; + } + case WCMD_CLEAR_PROT: + if (m_scpi.isConnected()) { + m_dev.clearProtection(); + LOG_F(INFO, "[ELoad/web] protection cleared"); + } + break; + case WCMD_RESET_DEV: + if (m_scpi.isConnected()) { + m_dev.reset(); + m_remoteApplied = false; + m_cfgApplied = false; + m_loadOn = false; + LOG_F(INFO, "[ELoad/web] device reset (*RST)"); + } + break; + case WCMD_IDENTIFY: { + std::string idn; + if (m_scpi.isConnected() && m_dev.identify(idn)) { + std::lock_guard lock(m_stateMutex); + m_idn = idn; + } + break; + } + case WCMD_SET_POWER: + { + std::lock_guard lock(m_stateMutex); + m_manualPower = c.value; + LOG_F(INFO, "[ELoad/web] manual power = %.1f W", c.value); + } + break; + case WCMD_SET_CURRENT: + { + std::lock_guard lock(m_stateMutex); + m_manualCurrent = c.value; + LOG_F(INFO, "[ELoad/web] manual current = %.4f A", c.value); + } + break; + case WCMD_SET_VOLT: + { + std::lock_guard lock(m_stateMutex); + m_voltSet = c.value; + LOG_F(INFO, "[ELoad/web] volt set = %.1f V (CV 优先)", c.value); + } + break; + case WCMD_SET_VOLTLIM: + { + std::lock_guard lock(m_stateMutex); + m_voltLimit = c.value; + m_cfgApplied = false; + LOG_F(INFO, "[ELoad/web] volt limit = %.1f V", c.value); + } + break; + case WCMD_SET_POWLIM: + { + std::lock_guard lock(m_stateMutex); + m_powLimitPos = c.value; + m_powLimitNeg = c.value; + m_cfgApplied = false; + LOG_F(INFO, "[ELoad/web] power limit = %.1f W", c.value); + } + break; + case WCMD_SET_CURRLIM: + { + std::lock_guard lock(m_stateMutex); + m_currLimitPos = c.value; + m_currLimitNeg = c.value; + m_cfgApplied = false; + LOG_F(INFO, "[ELoad/web] current limit = %.4f A", c.value); + } + break; + default: + break; + } + } +} + +//--------------------------------------------------------- +// doControlCycle:单次控制周期 + +void ELoad::doControlCycle(double now) { + bool connected = m_scpi.isConnected(); + + if (!connected) { + { + std::lock_guard lock(m_stateMutex); + m_cmdCurrent = 0; + } + // 自动连接使能但线程被 stop()(网页断开过)时重启 + if (m_linkEnable && !m_scpi.isConnected()) { + // stop() 已 join 线程,需要重新 start + // (start 内部 m_running 检查可重入) + m_scpi.start(); + } + return; + } + + // 每次连接后先进入远控 + if (!m_remoteApplied) { + if (m_dev.remote()) + m_remoteApplied = true; + else + return; + } + + // 输出状态切换 + bool needOn = m_loadOn; + if (needOn && !m_cfgApplied) + applyEnable(now); + else if (!needOn && m_cfgApplied) + applyDisable(); + + // 控制指令(关键路径)优先下发:CURR/VOLT 为异步入队(不阻塞), + // 确保 100ms 周期内指令准点发出;测量查询(同步阻塞)放在其后, + // 即使查询耗时也不影响控制节拍。 + bool cc = m_ccPriority; + if (cc) { + double targetP = computeTargetPower(now); + double cmdI = computeCmdCurrent(targetP); + { + std::lock_guard lock(m_stateMutex); + m_cmdCurrent = cmdI; + } + if (m_cfgApplied) { + bool send = (std::fabs(cmdI - m_lastSentCurrent) > 0.01) || + (now - m_lastSendTime >= 1.0); + if (send) { + if (m_dev.setCurrent(cmdI)) { + m_lastSentCurrent = cmdI; + m_lastSendTime = now; + } + } + } + } else { + double cmdV = 0; + { + std::lock_guard lock(m_stateMutex); + cmdV = m_voltSet; + m_cmdVolt = cmdV; + } + if (m_cfgApplied) { + bool send = (std::fabs(cmdV - m_lastSentVolt) > 0.05) || + (now - m_lastSendTime >= 1.0); + if (send) { + if (m_dev.setVoltage(cmdV)) { + m_lastSentVolt = cmdV; + m_lastSendTime = now; + } + } + } + } + + // 测量轮询(每周期一条,轮转 V/I/P)——在指令下发之后 + pollDevice(now); + + // 错误队列查询(1Hz,设备故障可见) + pollError(now); +} + +//--------------------------------------------------------- +// applyEnable:使能序列——远控+功能模式FIX+环路优先+限值+初始量+开输出 + +void ELoad::applyEnable(double now) { + double voltLimit = 0, currPos = 0, currNeg = 0, powPos = 0, powNeg = 0, voltSet = 0; + bool cc = true; + double initI = 0; + { + std::lock_guard lock(m_stateMutex); + voltLimit = m_voltLimit; + currPos = m_currLimitPos; + currNeg = m_currLimitNeg; + powPos = m_powLimitPos; + powNeg = m_powLimitNeg; + cc = m_ccPriority; + voltSet = m_voltSet; + initI = m_cmdCurrent; + } + + // 实时功率控制必须保持 FUNC:MODE FIX(防 LIST/BATT/SOLAR 抢占输出) + if (!m_dev.setFunctionModeFixed()) return; + if (cc) { + if (!m_dev.setFunctionCC()) return; + } else { + if (!m_dev.setFunctionCV()) return; + } + if (voltLimit > 0 && !m_dev.setVoltageLimit(voltLimit)) return; + if (currPos > 0 && !m_dev.setCurrentLimitPos(currPos)) return; + if (currNeg > 0 && !m_dev.setCurrentLimitNeg(currNeg)) return; + if (powPos > 0 && !m_dev.setPowerLimitPos(powPos)) return; + if (powNeg > 0 && !m_dev.setPowerLimitNeg(powNeg)) return; + + // 先设定初始量再开输出,避免输出瞬间冲击 + if (cc) { + if (!m_dev.setCurrent(initI)) return; + } else { + if (!m_dev.setVoltage(voltSet)) return; + } + if (!m_dev.output(true)) return; + + m_lastSentCurrent = initI; + m_lastSentVolt = voltSet; + m_lastSendTime = now; + m_cfgApplied = true; + LOG_F(INFO, "[ELoad] output ON: mode=FIX func=%s voltLim=%.1f currLim=%.1f/%.1f " + "powLim=%.1f/%.1f init=%s", + cc ? "CC" : "CV", voltLimit, currPos, currNeg, powPos, powNeg, + cc ? ("I=" + std::to_string(initI)).c_str() + : ("V=" + std::to_string(voltSet)).c_str()); +} + +//--------------------------------------------------------- +// applyDisable:关输出 + +void ELoad::applyDisable() { + if (m_dev.output(false)) { + m_cfgApplied = false; + m_lastSentCurrent = 0; + m_lastSentVolt = 0; + m_appliedPower = 0; + LOG_F(INFO, "[ELoad] output OFF"); + } +} + +//--------------------------------------------------------- +// computeTargetPower:目标功率(MOOS 跟随 / 手动 / 直流模式,含超时与斜率) + +double ELoad::computeTargetPower(double now) { + std::lock_guard lock(m_stateMutex); + + double target; + if (m_mode == "follow") { + target = m_moosPower; + // 功率消息超时保护:超时后目标归零(安全) + if (m_powerStaleSec > 0 && m_moosPowerTime > 0 && + (now - m_moosPowerTime) > m_powerStaleSec) { + target = 0; + } + } else if (m_mode == "manual") { + target = m_manualPower; + } else { + // current 模式:直接电流指令,不经功率折算 + m_appliedPower = 0; + return 0; + } + + // 主机侧功率限幅(第二道防线,设备侧 POW:LIM 为第一道) + if (m_maxPower > 0) { + if (target > m_maxPower) target = m_maxPower; + if (target < -m_maxPower) target = -m_maxPower; + } + + // 斜率限制(W/s) + if (m_slewLimitWps > 0) { + double step = m_slewLimitWps * (m_ctrlPeriodMs / 1000.0); + double d = target - m_appliedPower; + if (std::fabs(d) <= step) + m_appliedPower = target; + else + m_appliedPower += (d > 0 ? step : -step); + return m_appliedPower; + } + + m_appliedPower = target; + return target; +} + +//--------------------------------------------------------- +// computeCmdCurrent:功率折算电流指令 +// I = sign * P / U;U 取实测电压(新鲜时),否则取额定折算电压。 +// m_sinkNegative=true(默认):负电流 = 吸收电能 = 负载模拟。 + +double ELoad::computeCmdCurrent(double targetP) { + std::lock_guard lock(m_stateMutex); + + // volt 模式(CV 优先)不走电流折算 + if (m_mode == "volt") return m_lastSentCurrent; + + if (m_mode == "current") { + double i = m_manualCurrent; + if (m_maxCurrent > 0) { + if (i > m_maxCurrent) i = m_maxCurrent; + if (i < -m_maxCurrent) i = -m_maxCurrent; + } + return i; + } + + double u = m_nominalVolt; + if (m_measVTime > 0 && (MOOSTime(false) - m_measVTime) < 2.0 && m_measV > 1.0) + u = m_measV; + if (u < 1.0) u = m_nominalVolt; + if (u < 1.0) return 0; // 配置异常保护 + + double i = targetP / u; + if (m_sinkNegative) i = -i; + + if (m_maxCurrent > 0) { + if (i > m_maxCurrent) i = m_maxCurrent; + if (i < -m_maxCurrent) i = -m_maxCurrent; + } + return i; +} + +//--------------------------------------------------------- +// pollDevice:测量轮询(每周期一条,轮转 V/I/P) + +void ELoad::pollDevice(double now) { + double v; + switch (m_measRotate % 3) { + case 0: + if (m_dev.measureVoltage(v)) { + std::lock_guard lock(m_stateMutex); + m_measV = v; m_measVTime = now; + } + break; + case 1: + if (m_dev.measureCurrent(v)) { + std::lock_guard lock(m_stateMutex); + m_measI = v; m_measITime = now; + } + break; + default: + if (m_dev.measurePower(v)) { + std::lock_guard lock(m_stateMutex); + m_measP = v; m_measPTime = now; + } + break; + } + ++m_measRotate; +} + +//--------------------------------------------------------- +// pollError:1Hz 周期——错误队列 + FUNC/FUNC:MODE 状态回读 + +void ELoad::pollError(double now) { + if (now - m_lastErrPoll < 1.0) return; + m_lastErrPoll = now; + + // 环路优先与功能模式回读(FIX 防护:若被外部切成 LIST/BATT 等, + // 实时功率控制会失效,必须在网页/日志中醒目提示) + { + std::string func, mode; + bool hasFunc = m_dev.queryFunction(func); + bool hasMode = m_dev.queryFunctionMode(mode); + if (hasFunc || hasMode) { + std::lock_guard lock(m_stateMutex); + if (hasFunc) m_devFunc = func; + if (hasMode) m_devFuncMode = mode; + bool warn = hasMode && (mode != "FIX"); + m_funcModeFixWarn = warn; + if (warn) + LOG_F(WARNING, "[ELoad] FUNC:MODE = %s(非 FIX,实时功率控制失效!)", mode.c_str()); + } + } + + std::string err; + if (!m_dev.nextError(err)) return; + if (err.empty()) return; + // "0,..." 表示无错误 + if (err[0] == '0' && err.size() > 1 && err[1] == ',') return; + + std::lock_guard lock(m_stateMutex); + m_lastError = err; + LOG_F(WARNING, "[ELoad] device error: %s", err.c_str()); +} + +//--------------------------------------------------------- +// dbPeriodic:状态落库(1Hz)+ 高采样测量落库 + 历史清理 + +void ELoad::dbPeriodic(double now) { + if (!m_db || !m_db->isOpen()) return; + + // 高采样测量落库:每个控制周期一条(循环即 100ms 节拍),供功率波形分析。 + // 不再用时间门限——循环若略快于 100ms,门限会导致隔轮写入(出现 200ms 空洞)。 + if (m_measLogPeriodMs > 0) { + std::lock_guard lock(m_stateMutex); + double target = m_mode == "volt" ? m_voltSet : m_appliedPower; + m_db->insertMeas(m_measV, m_measI, m_measP, m_cmdCurrent, target, m_cfgApplied); + } + + if (now - m_lastDbWrite >= 1.0) { + m_lastDbWrite = now; + bool connected = m_scpi.isConnected(); + // 本函数运行于控制线程,m_cfgApplied/m_appliedPower 仅控制线程读写 + std::lock_guard lock(m_stateMutex); + m_db->insertStatus(connected, m_cfgApplied, m_mode, + m_appliedPower, // 超时/限幅/斜率处理后的有效目标功率 + m_cmdCurrent, m_measV, m_measI, m_measP, + m_devFunc.empty() ? (m_ccPriority ? "CURR" : "VOLT") : m_devFunc, + m_scpi.txCount(), m_scpi.rxCount(), m_scpi.txErrorCount()); + } + + if (m_logKeepHours > 0) + pruneHistory(now); +} + +//--------------------------------------------------------- +// pruneHistory:历史数据清理(10 分钟一次) + +void ELoad::pruneHistory(double now) { + if (now - m_lastPrune < 600.0) return; + m_lastPrune = now; + + long long cutoff = (long long)(::time(nullptr)) - (long long)(m_logKeepHours * 3600.0); + long long deleted = m_db->pruneOlderThan(cutoff); + if (deleted > 0) + LOG_F(INFO, "[ELoad] pruned %lld history rows (keep %.0f h)", deleted, m_logKeepHours); +} + +//--------------------------------------------------------- +// scpiLogSink:SCPI 收发落库过滤(配对回调:cmd+响应,例行测量不落库) + +void ELoad::scpiLogSink(const std::string& cmd, const std::string& resp) { + if (!m_db || !m_db->isOpen()) return; + + // 过滤例行轮询:测量查询、周期电流指令、错误查询、状态查询 + static const char* kSkipPrefix[] = { + "MEAS:", "CURR ", "SYST:ERR", "OUTP?", "FUNC?", "*OPC?" + }; + for (size_t i = 0; i < sizeof(kSkipPrefix) / sizeof(kSkipPrefix[0]); ++i) { + if (cmd.rfind(kSkipPrefix[i], 0) == 0) return; + } + + std::string text = cmd; + if (!resp.empty()) text += " => " + resp; + // 仅记录有限长度 + if (text.size() > 512) return; + + m_db->insertScpi(resp.empty() ? 1 : 0, text); +} + +//--------------------------------------------------------- +// buildSnapshot:网页/MOOS JSON 快照(jsoncpp) + +std::string ELoad::buildSnapshot() { + Json::Value root(Json::objectValue); + double now = MOOSTime(false); + + { + std::lock_guard lock(m_stateMutex); + + // 链路 + Json::Value link(Json::objectValue); + link["host"] = m_host; + link["port"] = Json::Value((Json::Int)m_port); + link["enabled"] = m_linkEnable ? 1 : 0; + link["connected"] = m_scpi.isConnected() ? 1 : 0; + link["reconnects"] = Json::Value((Json::UInt64)m_scpi.reconnectCount()); + link["tx"] = Json::Value((Json::UInt64)m_scpi.txCount()); + link["rx"] = Json::Value((Json::UInt64)m_scpi.rxCount()); + link["txErr"] = Json::Value((Json::UInt64)m_scpi.txErrorCount()); + link["err"] = Json::Value((Json::UInt64)m_scpi.errorCount()); + link["idn"] = m_idn; + link["lastError"] = m_lastError; + double lastRx = m_scpi.lastRxTime(); + link["lastRxValid"] = lastRx > 0 ? 1 : 0; + link["lastRxAge"] = lastRx > 0 ? (now - lastRx) : -1; + root["link"] = link; + + // 控制 + Json::Value ctrl(Json::objectValue); + ctrl["mode"] = m_mode; + ctrl["loadOn"] = m_loadOn ? 1 : 0; + ctrl["outpApplied"] = m_cfgApplied ? 1 : 0; + ctrl["ccPriority"] = m_ccPriority ? 1 : 0; + ctrl["moosPower"] = m_moosPower; + ctrl["moosPowerValid"] = m_moosPowerTime > 0 ? 1 : 0; + ctrl["moosPowerAge"] = m_moosPowerTime > 0 ? (now - m_moosPowerTime) : -1; + ctrl["manualPower"] = m_manualPower; + ctrl["manualCurrent"]= m_manualCurrent; + ctrl["voltSet"] = m_voltSet; + ctrl["appliedPower"] = m_appliedPower; + ctrl["cmdCurrent"] = m_cmdCurrent; + ctrl["cmdVolt"] = m_cmdVolt; + ctrl["sign"] = m_sinkNegative ? "negative" : "positive"; + ctrl["nominalVolt"] = m_nominalVolt; + ctrl["maxPower"] = m_maxPower; + ctrl["maxCurrent"] = m_maxCurrent; + ctrl["slewLimitWps"] = m_slewLimitWps; + ctrl["staleSec"] = m_powerStaleSec; + root["ctrl"] = ctrl; + + // 设备 + Json::Value dev(Json::objectValue); + dev["func"] = m_devFunc.empty() ? (m_ccPriority ? "CURR" : "VOLT") : m_devFunc; + dev["funcMode"] = m_devFuncMode; + dev["funcModeFixWarn"] = m_funcModeFixWarn ? 1 : 0; + dev["funcModeValid"] = m_devFuncMode.empty() ? 0 : 1; + dev["voltLimit"] = m_voltLimit; + dev["currLimit"] = m_currLimitPos; + dev["currLimitNeg"] = m_currLimitNeg; + dev["powLimit"] = m_powLimitPos; + dev["powLimitNeg"] = m_powLimitNeg; + dev["measV"] = m_measV; + dev["measVValid"]= m_measVTime > 0 ? 1 : 0; + dev["measVAge"] = m_measVTime > 0 ? (now - m_measVTime) : -1; + dev["measI"] = m_measI; + dev["measIValid"]= m_measITime > 0 ? 1 : 0; + dev["measIAge"] = m_measITime > 0 ? (now - m_measITime) : -1; + dev["measP"] = m_measP; + dev["measPValid"]= m_measPTime > 0 ? 1 : 0; + dev["measPAge"] = m_measPTime > 0 ? (now - m_measPTime) : -1; + root["dev"] = dev; + + // 存储 + Json::Value db(Json::objectValue); + db["statusCount"] = Json::Value((Json::Int64)(m_db ? m_db->countStatus() : 0)); + db["scpiCount"] = Json::Value((Json::Int64)(m_db ? m_db->countScpi() : 0)); + db["measCount"] = Json::Value((Json::Int64)(m_db ? m_db->countMeas() : 0)); + root["db"] = db; + } + + Json::FastWriter writer; + return writer.write(root); +} + +//--------------------------------------------------------- +// handleApi:/api/* 分发。返回 (Content-Type, body),body 空串表示 404。 + +std::pair ELoad::handleApi(const std::string& uri, const std::string& query) { + // JSON 响应统一封装 + auto jsonResp = [](const std::string& body) { + return std::make_pair(std::string("application/json"), body); + }; + + if (uri == "/api/eload_cmd") + return jsonResp(handleCmdApi(query)); + if (uri == "/api/history") { + std::string limStr; + getQueryParam(query, "limit", limStr); + int limit = limStr.empty() ? 100 : atoi(limStr.c_str()); + Json::Value root(Json::objectValue); + Json::Value rows(Json::arrayValue); + if (m_db && m_db->isOpen()) { + std::vector data = m_db->queryRecentStatus(limit); + for (size_t i = 0; i < data.size(); ++i) { + Json::Value r(Json::objectValue); + r["id"] = Json::Value((Json::Int64)data[i].id); + r["time"] = Json::Value((Json::Int64)data[i].time); + r["connected"] = data[i].connected; + r["outp"] = data[i].outp; + r["mode"] = data[i].mode; + r["targetPower"] = data[i].targetPower; + r["cmdCurrent"] = data[i].cmdCurrent; + r["measVoltage"] = data[i].measVoltage; + r["measCurrent"] = data[i].measCurrent; + r["measPower"] = data[i].measPower; + r["funcMode"] = data[i].funcMode; + rows.append(r); + } + } + root["rows"] = rows; + Json::FastWriter writer; + return jsonResp(writer.write(root)); + } + if (uri == "/api/scpi_log") { + std::string limStr; + getQueryParam(query, "limit", limStr); + int limit = limStr.empty() ? 50 : atoi(limStr.c_str()); + Json::Value root(Json::objectValue); + Json::Value rows(Json::arrayValue); + if (m_db && m_db->isOpen()) { + std::vector data = m_db->queryRecentScpi(limit); + for (size_t i = 0; i < data.size(); ++i) { + Json::Value r(Json::objectValue); + r["id"] = Json::Value((Json::Int64)data[i].id); + r["time"] = Json::Value((Json::Int64)data[i].time); + r["dir"] = data[i].direction; + r["text"] = data[i].text; + rows.append(r); + } + } + root["rows"] = rows; + Json::FastWriter writer; + return jsonResp(writer.write(root)); + } + // 高采样测量记录(meas_log):JSON + if (uri == "/api/meas") { + std::string limStr, stStr, etStr; + getQueryParam(query, "limit", limStr); + getQueryParam(query, "start", stStr); + getQueryParam(query, "end", etStr); + Json::Value root(Json::objectValue); + Json::Value rows(Json::arrayValue); + if (m_db && m_db->isOpen()) { + std::vector data; + if (!stStr.empty() && !etStr.empty()) { + double st = atof(stStr.c_str()), et = atof(etStr.c_str()); + int maxRows = limStr.empty() ? 5000 : atoi(limStr.c_str()); + data = m_db->queryMeasRange(st, et, maxRows); + } else { + int limit = limStr.empty() ? 200 : atoi(limStr.c_str()); + data = m_db->queryMeas(limit); + } + for (size_t i = 0; i < data.size(); ++i) { + Json::Value r(Json::objectValue); + r["id"] = Json::Value((Json::Int64)data[i].id); + r["time"] = data[i].time; + r["v"] = data[i].measVoltage; + r["i"] = data[i].measCurrent; + r["p"] = data[i].measPower; + r["cmdI"] = data[i].cmdCurrent; + r["targetP"] = data[i].targetPower; + r["outp"] = data[i].outp; + rows.append(r); + } + } + root["rows"] = rows; + Json::FastWriter writer; + return jsonResp(writer.write(root)); + } + // 高采样测量记录:CSV 导出(离线分析用,Excel/Python 可直接打开) + if (uri == "/api/meas_csv") { + std::string limStr, stStr, etStr; + getQueryParam(query, "limit", limStr); + getQueryParam(query, "start", stStr); + getQueryParam(query, "end", etStr); + std::string csv = "time,meas_voltage_V,meas_current_A,meas_power_W," + "cmd_current_A,target_power_W,outp\n"; + if (m_db && m_db->isOpen()) { + std::vector data; + if (!stStr.empty() && !etStr.empty()) { + double st = atof(stStr.c_str()), et = atof(etStr.c_str()); + int maxRows = limStr.empty() ? 20000 : atoi(limStr.c_str()); + data = m_db->queryMeasRange(st, et, maxRows); + } else { + int limit = limStr.empty() ? 2000 : atoi(limStr.c_str()); + data = m_db->queryMeas(limit); + } + char buf[128]; + for (size_t i = 0; i < data.size(); ++i) { + std::snprintf(buf, sizeof(buf), "%.6f,%.4f,%.4f,%.4f,%.4f,%.4f,%d\n", + data[i].time, data[i].measVoltage, data[i].measCurrent, + data[i].measPower, data[i].cmdCurrent, data[i].targetPower, + data[i].outp); + csv += buf; + } + } + return std::make_pair(std::string("text/csv; charset=utf-8"), csv); + } + return std::make_pair(std::string(""), std::string("")); +} + +//--------------------------------------------------------- +// getQueryParam:简易 query 解析(k=v&k=v,值做 URL 解码) + +namespace { + +int hexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +std::string urlDecode(const std::string& in) { + std::string out; + out.reserve(in.size()); + for (size_t i = 0; i < in.size(); ++i) { + if (in[i] == '%' && i + 2 < in.size() && + hexVal(in[i + 1]) >= 0 && hexVal(in[i + 2]) >= 0) { + out += static_cast(hexVal(in[i + 1]) * 16 + hexVal(in[i + 2])); + i += 2; + } else if (in[i] == '+') { + out += ' '; + } else { + out += in[i]; + } + } + return out; +} + +} // namespace + +bool ELoad::getQueryParam(const std::string& query, const char* key, std::string& out) { + std::string k = std::string(key) + "="; + size_t p = query.find(k); + if (p == std::string::npos) return false; + size_t e = query.find('&', p); + out = urlDecode(query.substr(p + k.size(), + (e == std::string::npos) ? std::string::npos + : e - p - k.size())); + return true; +} + +//--------------------------------------------------------- +// queueWebCmd:网页指令入队(Web 线程调用,控制线程执行) + +bool ELoad::queueWebCmd(WebCmdType t, double v, std::string& err) { + std::lock_guard lock(m_stateMutex); + if (m_webCmdQueue.size() >= 16) { + err = "queue full"; + return false; + } + WebCmd c; + c.type = t; + c.value = v; + m_webCmdQueue.push_back(c); + return true; +} + +//--------------------------------------------------------- +// handleCmdApi:/api/eload_cmd +// +// Web 线程不可直接操作设备/Notify,指令入队后由控制线程应用。 +// 特例:raw 原始 SCPI 调试直接同步查询(ScpiClient 线程安全)。 + +std::string ELoad::handleCmdApi(const std::string& query) { + std::string action, valStr, raw; + getQueryParam(query, "action", action); + getQueryParam(query, "value", valStr); + double v = atof(valStr.c_str()); + + Json::Value root(Json::objectValue); + root["ok"] = true; + root["action"] = action; + + std::string err; + + if (action == "connect") queueWebCmd(WCMD_CONNECT, 0, err); + else if (action == "disconnect") queueWebCmd(WCMD_DISCONNECT, 0, err); + else if (action == "outp_on") queueWebCmd(WCMD_OUTP_ON, 0, err); + else if (action == "outp_off") queueWebCmd(WCMD_OUTP_OFF, 0, err); + else if (action == "remote") queueWebCmd(WCMD_REMOTE, 0, err); + else if (action == "local") queueWebCmd(WCMD_LOCAL, 0, err); + else if (action == "mode_cc") queueWebCmd(WCMD_MODE_CC, 0, err); + else if (action == "mode_cv") queueWebCmd(WCMD_MODE_CV, 0, err); + else if (action == "mode_follow") queueWebCmd(WCMD_CTRL_FOLLOW, 0, err); + else if (action == "mode_manual") queueWebCmd(WCMD_CTRL_MANUAL, 0, err); + else if (action == "mode_current") queueWebCmd(WCMD_CTRL_CURRENT, 0, err); + else if (action == "clear_protect") queueWebCmd(WCMD_CLEAR_PROT, 0, err); + else if (action == "reset_dev") queueWebCmd(WCMD_RESET_DEV, 0, err); + else if (action == "identify") queueWebCmd(WCMD_IDENTIFY, 0, err); + else if (action == "set_power") queueWebCmd(WCMD_SET_POWER, v, err); + else if (action == "set_current") queueWebCmd(WCMD_SET_CURRENT, v, err); + else if (action == "set_volt") queueWebCmd(WCMD_SET_VOLT, v, err); + else if (action == "set_voltlim") queueWebCmd(WCMD_SET_VOLTLIM, v, err); + else if (action == "set_powlimit") queueWebCmd(WCMD_SET_POWLIM, v, err); + else if (action == "set_currlimit") queueWebCmd(WCMD_SET_CURRLIM, v, err); + else if (action == "raw") { + getQueryParam(query, "cmd", raw); + if (raw.empty()) { + root["ok"] = false; + root["error"] = "missing cmd"; + } else if (!m_scpi.isConnected()) { + root["ok"] = false; + root["error"] = "not connected"; + } else { + std::string resp; + bool isQuery = (raw.find('?') != std::string::npos); + if (isQuery) { + if (m_scpi.query(raw, resp, 500)) + root["response"] = resp; + else { + root["ok"] = false; + root["error"] = "query timeout"; + } + } else { + if (m_scpi.cmd(raw)) + root["response"] = "(command sent)"; + else { + root["ok"] = false; + root["error"] = "send failed"; + } + } + } + Json::FastWriter writer; + return writer.write(root); + } else { + root["ok"] = false; + root["error"] = "bad action"; + Json::FastWriter writer; + return writer.write(root); + } + + if (!err.empty()) { + root["ok"] = false; + root["error"] = err; + } + Json::FastWriter writer; + return writer.write(root); +} + +//--------------------------------------------------------- +// buildReport + +bool ELoad::buildReport() { + m_msgs << "============================================" << "\n"; + m_msgs << "pELoad 电子负载控制程序" << "\n"; + m_msgs << "============================================" << "\n"; + m_msgs << "设备: " << m_host << ":" << m_port + << " 链路使能: " << (m_linkEnable ? 1 : 0) + << " 连接: " << (m_scpi.isConnected() ? 1 : 0) << "\n"; + { + std::lock_guard lock(m_stateMutex); + m_msgs << "IDN: " << (m_idn.empty() ? "-" : m_idn) << "\n"; + m_msgs << "控制: mode=" << m_mode + << " loadOn=" << (m_loadOn ? 1 : 0) + << " 目标功率=" << m_appliedPower << " W" + << " 指令电流=" << m_cmdCurrent << " A" << "\n"; + m_msgs << "实测: V=" << m_measV << " I=" << m_measI + << " P=" << m_measP << "\n"; + m_msgs << "MOOS 功率消息: " << m_moosPower << " W (var=" << m_powerVar + << (m_moosPowerTime > 0 + ? ")" + : ", 未收到)") << "\n"; + if (!m_lastError.empty()) + m_msgs << "设备错误: " << m_lastError << "\n"; + } + m_msgs << "SCPI: tx=" << m_scpi.txCount() << " rx=" << m_scpi.rxCount() + << " txErr=" << m_scpi.txErrorCount() + << " 重连=" << m_scpi.reconnectCount() << "\n"; + if (m_db) + m_msgs << "数据库: " << m_dbPath + << " (状态记录 " << m_db->countStatus() + << " 条, SCPI 记录 " << m_db->countScpi() << " 条)\n"; + return true; +} diff --git a/src/pELoad/ELoad.h b/src/pELoad/ELoad.h new file mode 100644 index 0000000..4247f5c --- /dev/null +++ b/src/pELoad/ELoad.h @@ -0,0 +1,168 @@ +#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 +#include +#include +#include +#include + +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 pollDevice(double now); // 测量轮询与错误查询 + void pollError(double now); + void dbPeriodic(double now); // 状态落库 + 历史清理 + void pruneHistory(double now); + + //---- 网页 ---- + std::string buildSnapshot(); + // 返回 (Content-Type, body);body 空串表示 404 + std::pair 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 + }; + struct WebCmd { + WebCmdType type; + double value; + WebCmd() : type(WCMD_CONNECT), value(0) {} + }; + bool queueWebCmd(WebCmdType t, double v, 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) + + //---- 设备配置值(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; + std::string m_devFunc; // FUNC? 回读:CURR / VOLT + std::string m_devFuncMode; // FUNC:MODE? 回读:FIX/LIST/... + bool m_funcModeFixWarn = false; // 回读非 FIX 时告警 + double m_cmdCurrent = 0; // 最近下发的电流指令(CC) + double m_cmdVolt = 0; // 最近下发的电压指令(CV) + + //---- 控制线程私有(无需加锁)---- + bool m_remoteApplied = false; + bool m_cfgApplied = false; + 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::vector m_webCmdQueue; // web 线程入队 -> 控制线程出队 + + std::atomic m_ctrlRunning{false}; + std::thread m_ctrlThread; + + //---- 统计 ---- + unsigned long m_pubCount = 0; +}; + +} // namespace eload +#endif // PELOAD_ELOAD_H diff --git a/src/pELoad/ELoad_Info.cpp b/src/pELoad/ELoad_Info.cpp new file mode 100644 index 0000000..e550393 --- /dev/null +++ b/src/pELoad/ELoad_Info.cpp @@ -0,0 +1,116 @@ +/****************************************************************/ +/* NAME: ELoad_Info */ +/* FILE: ELoad_Info.cpp */ +/****************************************************************/ + +#include +#include +#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","= "); + 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(" 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); +} diff --git a/src/pELoad/ELoad_Info.h b/src/pELoad/ELoad_Info.h new file mode 100644 index 0000000..444f463 --- /dev/null +++ b/src/pELoad/ELoad_Info.h @@ -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 diff --git a/src/pELoad/driver/IT6000C.cpp b/src/pELoad/driver/IT6000C.cpp new file mode 100644 index 0000000..d20fb67 --- /dev/null +++ b/src/pELoad/driver/IT6000C.cpp @@ -0,0 +1,121 @@ +#include "IT6000C.h" +#include +#include + +namespace eload { + +//============================================================================ +// 基础 +//============================================================================ + +bool IT6000C::identify(std::string& idn) { + return m_scpi.query("*IDN?", idn, 300); +} + +bool IT6000C::remote() { + // 连接建立后必须先进入远控,否则设置命令被面板本地态拒绝 + return m_scpi.cmd("SYST:REM"); +} + +bool IT6000C::local() { + return m_scpi.cmd("SYST:LOC"); +} + +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::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); } + +} // namespace eload diff --git a/src/pELoad/driver/IT6000C.h b/src/pELoad/driver/IT6000C.h new file mode 100644 index 0000000..fcf1b96 --- /dev/null +++ b/src/pELoad/driver/IT6000C.h @@ -0,0 +1,78 @@ +#ifndef PELOAD_IT6000C_H +#define PELOAD_IT6000C_H + +#include +#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 measureVoltage(double& v); // MEAS:VOLT? + bool measureCurrent(double& i); // MEAS:CURR? + bool measurePower(double& p); // MEAS: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 diff --git a/src/pELoad/driver/ScpiClient.cpp b/src/pELoad/driver/ScpiClient.cpp new file mode 100644 index 0000000..b637271 --- /dev/null +++ b/src/pELoad/driver/ScpiClient.cpp @@ -0,0 +1,375 @@ +#include "ScpiClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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(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(&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(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(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((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 item; + { + std::unique_lock 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, ""); + closeSocket(); + m_queueCv.notify_all(); + 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 ? "" : ""); + if (!m_connected) { + item->done = true; + m_queueCv.notify_all(); + return; + } + } + } else { + item->ok = true; + if (m_logSink) m_logSink(item->cmd, ""); + } + item->done = true; + m_queueCv.notify_all(); + } +} + +//---------------------------------------------------------------------- +// 对外接口 +//---------------------------------------------------------------------- +bool ScpiClient::cmd(const std::string& scpi) { + std::shared_ptr item(new Work()); + item->cmd = scpi; + item->expectReply = false; + { + std::lock_guard 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::query(const std::string& scpi, std::string& resp, int timeoutMs) { + // 同一时刻仅一个同步查询(避免控制线程/网页线程交叉) + std::lock_guard qlock(m_queryMutex); + + std::shared_ptr item(new Work()); + item->cmd = scpi; + item->expectReply = true; + { + std::lock_guard 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 lock(m_queueMutex); + while (!item->done) { + int elapsed = static_cast((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 >::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; +} + +} // namespace eload diff --git a/src/pELoad/driver/ScpiClient.h b/src/pELoad/driver/ScpiClient.h new file mode 100644 index 0000000..e3297ab --- /dev/null +++ b/src/pELoad/driver/ScpiClient.h @@ -0,0 +1,117 @@ +#ifndef PELOAD_SCPI_CLIENT_H +#define PELOAD_SCPI_CLIENT_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; + // 连接状态变化回调(IO 线程内调用) + using StateCallback = std::function; + + 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; } + + // 写命令(无响应期待)。未连接/发送失败按错误计数并丢弃。 + bool cmd(const std::string& scpi); + + // 同步查询:发送 scpi 并等待一行响应。 + // timeoutMs 建议不大于控制周期(100ms 循环里建议 <= 80)。 + bool query(const std::string& scpi, std::string& resp, int timeoutMs = 200); + + // 统计 + 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; + 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 m_running{false}; + std::atomic m_connected{false}; + std::thread m_thread; + + // 命令队列:cmd() 与 query() 共用;IO 线程逐条处理 + std::mutex m_queueMutex; + std::condition_variable m_queueCv; + std::deque > m_queue; + // 同一时刻仅允许一个同步查询 + std::mutex m_queryMutex; + + LogSink m_logSink; // 任意线程注册,IO 线程调用(start 前 set) + StateCallback m_stateCb; + + std::vector m_buf; // 流式接收缓冲(半包/粘包按 \n 切分) + + std::atomic m_txCount{0}; + std::atomic m_txErrorCount{0}; + std::atomic m_rxCount{0}; + std::atomic m_errorCount{0}; + std::atomic m_reconnectCount{0}; + std::atomic m_lastRxTime{0.0}; +}; + +} // namespace eload +#endif // PELOAD_SCPI_CLIENT_H diff --git a/src/pELoad/main.cpp b/src/pELoad/main.cpp new file mode 100644 index 0000000..231e05b --- /dev/null +++ b/src/pELoad/main.cpp @@ -0,0 +1,54 @@ +/************************************************************/ +/* NAME: pELoad */ +/* FILE: main.cpp */ +/************************************************************/ + +#include +#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; +} diff --git a/src/pELoad/pELoad.moos b/src/pELoad/pELoad.moos new file mode 100644 index 0000000..9a7136a --- /dev/null +++ b/src/pELoad/pELoad.moos @@ -0,0 +1,82 @@ +// 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 + + //======== 网页 ======== + // (避开 pCCU 8080 / pPowerManger 8090 / pPowerMangerHost 18080 / pMotor 18081) + web_port = 18082 + web_enable = true + + logpath = pELoad.log +} diff --git a/src/pELoad/store/DbStore.cpp b/src/pELoad/store/DbStore.cpp new file mode 100644 index 0000000..4bdf61e --- /dev/null +++ b/src/pELoad/store/DbStore.cpp @@ -0,0 +1,402 @@ +#include "DbStore.h" +#include "sqlite3.h" +#include +#include + +namespace eload { + +namespace { +// 小数秒精度的 unix 时间(与 eload_log 的整秒对齐,供跨表分析) +double nowUnix() { + struct timeval tv; + gettimeofday(&tv, nullptr); + return static_cast(tv.tv_sec) + static_cast(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," + " cmd_current 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," + " target_power REAL," + " outp INTEGER" + ");")) return false; + exec("CREATE INDEX IF NOT EXISTS idx_meas_log_time ON meas_log(time);"); + + return prepareInsert() && prepareMeasInsert() && prepareScpiInsert(); +} + +bool DbStore::prepareInsert() { + const char* sql = + "INSERT INTO eload_log (time, connected, outp, mode, target_power, cmd_current, " + "meas_voltage, meas_current, meas_power, func_mode, tx_count, rx_count, tx_err) " + "VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13);"; + 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, target_power, outp) VALUES (?1,?2,?3,?4,?5,?6,?7);"; + 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 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 cmdCurrent, + double measV, double measI, double measP, + const std::string& funcMode, + unsigned long tx, unsigned long rx, unsigned long txErr) { + std::lock_guard 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(::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++, cmdCurrent); + 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(tx)); + sqlite3_bind_int64(m_stmtInsert, i++, static_cast(rx)); + sqlite3_bind_int64(m_stmtInsert, i++, static_cast(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 targetPower, bool outp) { + std::lock_guard 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, targetPower); + sqlite3_bind_int(m_stmtMeasInsert, 7, 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 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(::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 DbStore::queryRecentStatus(int limit) { + std::lock_guard lock(m_mutex); + std::vector 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, cmd_current, " + "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(sqlite3_column_text(stmt, 4)); + r.targetPower = sqlite3_column_double(stmt, 5); + r.cmdCurrent = sqlite3_column_double(stmt, 6); + r.measVoltage = sqlite3_column_double(stmt, 7); + r.measCurrent = sqlite3_column_double(stmt, 8); + r.measPower = sqlite3_column_double(stmt, 9); + const char* fm = reinterpret_cast(sqlite3_column_text(stmt, 10)); + r.funcMode = fm ? fm : ""; + r.txCount = sqlite3_column_int64(stmt, 11); + r.rxCount = sqlite3_column_int64(stmt, 12); + r.txErr = sqlite3_column_int64(stmt, 13); + rows.push_back(std::move(r)); + } + sqlite3_finalize(stmt); + return rows; +} + +std::vector DbStore::queryRecentScpi(int limit) { + std::lock_guard lock(m_mutex); + std::vector 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(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 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 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 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 DbStore::queryMeas(int limit) { + std::lock_guard lock(m_mutex); + std::vector 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, 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.targetPower = sqlite3_column_double(stmt, 6); + r.outp = sqlite3_column_int(stmt, 7); + rows.push_back(std::move(r)); + } + sqlite3_finalize(stmt); + return rows; +} + +std::vector DbStore::queryMeasRange(double startSec, double endSec, int maxRows) { + std::lock_guard lock(m_mutex); + std::vector 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, 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.targetPower = sqlite3_column_double(stmt, 6); + r.outp = sqlite3_column_int(stmt, 7); + rows.push_back(std::move(r)); + } + sqlite3_finalize(stmt); + return rows; +} + +long long DbStore::pruneOlderThan(long long cutoff) { + std::lock_guard 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 diff --git a/src/pELoad/store/DbStore.h b/src/pELoad/store/DbStore.h new file mode 100644 index 0000000..6453a19 --- /dev/null +++ b/src/pELoad/store/DbStore.h @@ -0,0 +1,115 @@ +#ifndef PELOAD_DB_STORE_H +#define PELOAD_DB_STORE_H + +#include +#include +#include + +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 + double cmdCurrent; // 指令电流 A + 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 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 cmdCurrent, + double measV, double measI, double measP, + const std::string& funcMode, + unsigned long tx, unsigned long rx, unsigned long txErr); + + // 高采样测量落库(每个控制周期调用,meas_log) + void insertMeas(double measV, double measI, double measP, + double cmdCurrent, double targetPower, bool outp); + + // SCPI 非常规收发落库:text 为 "CMD" 或 "CMD => RESP"(已配对) + // direction: 1=命令 0=查询应答 + void insertScpi(int direction, const std::string& text); + + // 历史查询(网页展示) + std::vector queryRecentStatus(int limit); + // 测量查询:limit 条最近记录(倒序);或指定时间范围(unix 秒) + std::vector queryMeas(int limit); + std::vector queryMeasRange(double startSec, double endSec, int maxRows); + struct ScpiRow { + long long id; + long long time; + int direction; + std::string text; + }; + std::vector 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 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 diff --git a/src/pELoad/web/WebServer.cpp b/src/pELoad/web/WebServer.cpp new file mode 100644 index 0000000..57fa9ce --- /dev/null +++ b/src/pELoad/web/WebServer.cpp @@ -0,0 +1,147 @@ +#include "WebServer.h" +#include "pages/index.h" +#include + +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 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; + } + + 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 handler) { + m_onOpen = std::move(handler); +} + +void WebServer::setApiHandler(std::function(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(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(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 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 lock(m_pendingMutex); + m_pending = text; +} + +} // namespace eload diff --git a/src/pELoad/web/WebServer.h b/src/pELoad/web/WebServer.h new file mode 100644 index 0000000..db22a54 --- /dev/null +++ b/src/pELoad/web/WebServer.h @@ -0,0 +1,55 @@ +#ifndef PELOAD_WEB_SERVER_H +#define PELOAD_WEB_SERVER_H + +#define UNIX +#include "mongoose.h" +#include +#include +#include +#include +#include +#include + +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 handler); + + // 处理 /api/* 请求:返回 (Content-Type, body)。 + // 默认内容类型 application/json;文本/CSV 接口由处理器自行指定。 + void setApiHandler(std::function(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 m_running{false}; + std::thread m_thread; + + std::mutex m_pendingMutex; + std::string m_pending; // latest snapshot to broadcast + std::vector m_wsConnections; // web thread only + std::function m_onOpen; + std::function(const std::string&, const std::string&)> m_apiHandler; +}; + +} // namespace eload +#endif // PELOAD_WEB_SERVER_H diff --git a/src/pELoad/web/pages/index.h b/src/pELoad/web/pages/index.h new file mode 100644 index 0000000..e639d04 --- /dev/null +++ b/src/pELoad/web/pages/index.h @@ -0,0 +1,443 @@ +#ifndef PELOAD_PAGE_INDEX_H +#define PELOAD_PAGE_INDEX_H + +#include + +//============================================================================ +// pELoad 电子负载控制页面(纯前端,内嵌 HTML)。 +// +// 操作顺序(防误触 + 步骤引导): +// ① 连接设备(链路绿 → 步骤2 可操作) +// ② 设置参数(选环路优先 CC/CV → 控制模式/目标量 → 限值) +// ③ 开启输出(确认后下发使能序列) +// 所有下发的指令均带确认对话框;按钮按当前状态禁用并提示原因。 +// +// 模式说明: +// CC 优先(电流型负载模拟):控制量 = 电流,功率折算 I=P/U,可跟随 MOOS +// 功率 / 手动功率 / 手动电流; +// CV 优先(恒压吸收):控制量 = 电压(VOLT),功率由外部源决定。 +// FUNC:MODE 必须保持 FIX(实时功率控制前提),非 FIX 时页面红色告警。 +// +// 设备说明:docs/电子负载说明手册(IT6000C 双向可编程直流电源) +//============================================================================ + +namespace eload { +const std::string INDEX_HTML = R"HTML( + + + + +pELoad 电子负载控制 + + + +
+

pELoad 电子负载控制

+ 加载中... + + 未连接 + 输出关 + 模式 - +
+ +
+
1连接设备
+ ▸ +
2设置参数
+ ▸ +
3开启输出
+ + +
+ +
加载中...
+ + + +)HTML"; + +} // namespace eload +#endif // PELOAD_PAGE_INDEX_H \ No newline at end of file diff --git a/src/pELoad/需求.txt b/src/pELoad/需求.txt new file mode 100644 index 0000000..79a1334 --- /dev/null +++ b/src/pELoad/需求.txt @@ -0,0 +1,8 @@ +本程序用于整个能源-动力系统中的电子负载控制,具备以下功能/需求 +1.通过以太网与docs/电子负载说明手册中的电子负载连接,并可以进行基本的控制、操作、参数设定等 +2.具备100ms周期的功率控制能力,后续有其他程序会通过MOOSDB发布功率消息,改功率消息为实施计算的电机功率,通过电子负载模拟电机功率的变化 +3.使用web页面进行人机交互 +4.具备sqlit3的数据存储功能 +5.使用moos文件进行配置电子负载地址、端口等信息 + +电子负载的详细操作及指令见docs/电子负载说明手册中的文件 \ No newline at end of file