pPowerMangerHost:反馈数据 SQLite 落库,新增历史查询 API 与曲线绘图
- 新增 FeedbackStore(sqlite3 封装),fb_log 表按消息落盘,环形时间保留 - HostSim 每次收到反馈即批量写库,支持 DB_FILE/FB_LOG_KEEP_SECONDS 等配置 - 新增 /api/history、/api/rawlog 接口;曲线页新增历史数据加载 - 数据库目录不存在时自动创建;新增板卡原生编译脚本 scripts/build-board.sh
This commit is contained in:
@@ -245,6 +245,8 @@ _deps
|
||||
bin/pPowerManger.log
|
||||
bin/ccuState.txt
|
||||
*.db
|
||||
# pPowerMangerHost 反馈落盘数据库
|
||||
feedback_data.db
|
||||
|
||||
# PowerManager compiled binaries (built into bin/ by CMake)
|
||||
bin/pPowerManger
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
#=======================================================================
|
||||
# FILE: scripts/build-board.sh
|
||||
# DESC: 把源码同步到目标板卡 (RK3588 / Ubuntu 22.04),在板卡原生编译
|
||||
# (aarch64 直编,比本地 QEMU 模拟快约 10 倍),并把产物部署到
|
||||
# /root/work/h100/bin/,可选重启 systemd 服务。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/build-board.sh # 同步+编译+部署(不重启)
|
||||
# ./scripts/build-board.sh --start # 编译后重启 3 个服务
|
||||
# ./scripts/build-board.sh --clean # 板卡上重新 cmake 再编译
|
||||
# ./scripts/build-board.sh --jobs 4 # 指定并行度(默认板卡 nproc)
|
||||
#
|
||||
# 参数:
|
||||
# --host <ip> 目标主机(默认 192.168.0.223)
|
||||
# --user <user> 登录用户(默认 root)
|
||||
# --board-src <dir> 板卡源码目录(默认 /root/work/h100/src)
|
||||
# --board-bin <dir> 板卡部署目录(默认 /root/work/h100/bin)
|
||||
# --jobs <n> 编译并行度(默认板卡 nproc)
|
||||
# --start 编译部署后重启服务
|
||||
# --clean 先重新 cmake(配置变更时用)
|
||||
# -h, --help 显示帮助
|
||||
#
|
||||
# 前置: 板卡已配免密 SSH,且有编译环境(cmake/g++/MOOS/jsoncpp),
|
||||
# 源码已能本地编译(本脚本只负责同步+板卡编译)。
|
||||
#=======================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
|
||||
|
||||
HOST="192.168.0.223"
|
||||
USER="root"
|
||||
BOARD_SRC="/root/work/h100/src"
|
||||
BOARD_BIN="/root/work/h100/bin"
|
||||
JOBS=""
|
||||
DO_START=0
|
||||
DO_CLEAN=0
|
||||
|
||||
SERVICES=(moosdb pPowerManger pPowerMangerHost)
|
||||
|
||||
info() { printf '\033[1;36m[build-board]\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m[build-board]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[build-board]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[build-board]\033[0m %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 参数解析
|
||||
#-------------------------------------------------------------------
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) [ $# -ge 2 ] || { err "--host 需要参数"; exit 1; }; HOST="$2"; shift 2 ;;
|
||||
--user) [ $# -ge 2 ] || { err "--user 需要参数"; exit 1; }; USER="$2"; shift 2 ;;
|
||||
--board-src) [ $# -ge 2 ] || { err "--board-src 需要参数"; exit 1; }; BOARD_SRC="$2"; shift 2 ;;
|
||||
--board-bin) [ $# -ge 2 ] || { err "--board-bin 需要参数"; exit 1; }; BOARD_BIN="$2"; shift 2 ;;
|
||||
--jobs) [ $# -ge 2 ] || { err "--jobs 需要参数"; exit 1; }; JOBS="$2"; shift 2 ;;
|
||||
--start) DO_START=1; shift ;;
|
||||
--clean) DO_CLEAN=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) err "未知参数: $1(见 --help)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SSH_TARGET="${USER}@${HOST}"
|
||||
SSH="ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 1. 前置检查
|
||||
#-------------------------------------------------------------------
|
||||
if ! ${SSH} "${SSH_TARGET}" 'true' 2>/dev/null; then
|
||||
err "无法免密 SSH 到 ${SSH_TARGET}"
|
||||
err "请先配置免密登录: ssh-copy-id ${SSH_TARGET}"
|
||||
exit 1
|
||||
fi
|
||||
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 2. 同步源码到板卡(排除构建产物 / git / 数据)
|
||||
#-------------------------------------------------------------------
|
||||
info "同步源码到板卡 ${BOARD_SRC} ..."
|
||||
rsync -az --delete \
|
||||
--exclude='build/' \
|
||||
--exclude='build-arm64/' \
|
||||
--exclude='build-native/' \
|
||||
--exclude='build-nosa-test/' \
|
||||
--exclude='bin/' \
|
||||
--exclude='.git/' \
|
||||
--exclude='.cache/' \
|
||||
--exclude='reports/' \
|
||||
--exclude='*.log' \
|
||||
--exclude='*.db' \
|
||||
--exclude='CMakeCache.txt' \
|
||||
"${PROJECT_ROOT}/" "${SSH_TARGET}:${BOARD_SRC}/" || { err "rsync 源码失败"; exit 1; }
|
||||
ok "源码已同步"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 3. 板卡原生编译
|
||||
#-------------------------------------------------------------------
|
||||
if [ "${DO_CLEAN}" = "1" ]; then
|
||||
info "重新 cmake(--clean)..."
|
||||
${SSH} "${SSH_TARGET}" "rm -rf ${BOARD_SRC}/build-native && mkdir -p ${BOARD_SRC}/build-native && \
|
||||
cd ${BOARD_SRC}/build-native && cmake -DCMAKE_BUILD_TYPE=Release .." \
|
||||
|| { err "cmake 失败"; exit 1; }
|
||||
ok "cmake 完成"
|
||||
else
|
||||
${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_SRC}/build-native"
|
||||
# 若首次未配置过则先 cmake
|
||||
if ! ${SSH} "${SSH_TARGET}" "[ -f ${BOARD_SRC}/build-native/Makefile ]"; then
|
||||
info "首次编译,先 cmake ..."
|
||||
${SSH} "${SSH_TARGET}" "cd ${BOARD_SRC}/build-native && cmake -DCMAKE_BUILD_TYPE=Release .." \
|
||||
|| { err "cmake 失败"; exit 1; }
|
||||
ok "cmake 完成"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${JOBS}" ]; then
|
||||
JOBS="$(${SSH} "${SSH_TARGET}" nproc)"
|
||||
fi
|
||||
|
||||
info "板卡编译 (make -j${JOBS}) ..."
|
||||
if ! ${SSH} "${SSH_TARGET}" "cd ${BOARD_SRC}/build-native && make -j${JOBS}"; then
|
||||
err "板卡编译失败(见上方输出)"
|
||||
exit 1
|
||||
fi
|
||||
ok "板卡编译完成"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 4. 部署产物到板卡运行目录
|
||||
#-------------------------------------------------------------------
|
||||
info "部署产物到 ${BOARD_BIN}/ ..."
|
||||
${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_BIN} && \
|
||||
cp -f ${BOARD_SRC}/bin/pPowerManger ${BOARD_BIN}/pPowerManger && \
|
||||
cp -f ${BOARD_SRC}/bin/pPowerMangerHost ${BOARD_BIN}/pPowerMangerHost && \
|
||||
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost" \
|
||||
|| { err "部署产物失败"; exit 1; }
|
||||
ok "产物已部署"
|
||||
|
||||
echo ""
|
||||
ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)"
|
||||
${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60"
|
||||
|
||||
#-------------------------------------------------------------------
|
||||
# 5. 可选重启服务
|
||||
#-------------------------------------------------------------------
|
||||
if [ "${DO_START}" = "1" ]; then
|
||||
info "重启服务 ..."
|
||||
${SSH} "${SSH_TARGET}" "systemctl restart ${SERVICES[*]}" \
|
||||
|| { err "重启服务失败"; exit 1; }
|
||||
sleep 3
|
||||
ok "服务状态: $(${SSH} "${SSH_TARGET}" "systemctl is-active ${SERVICES[*]}")"
|
||||
else
|
||||
echo ""
|
||||
info "未重启服务。需要重启请加 --start 或手动:"
|
||||
echo " systemctl restart ${SERVICES[*]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
ok "完成。"
|
||||
@@ -9,12 +9,17 @@
|
||||
SET(MONGOOSE_SRC
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver/mongoose.c)
|
||||
|
||||
SET(SQLITE_SRC
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3/sqlite3.c)
|
||||
|
||||
SET(SRC
|
||||
HostSim.cpp
|
||||
HostSim_Info.cpp
|
||||
WebServer.cpp
|
||||
FeedbackStore.cpp
|
||||
main.cpp
|
||||
${MONGOOSE_SRC}
|
||||
${SQLITE_SRC}
|
||||
)
|
||||
|
||||
#--------------------------------------------------------
|
||||
@@ -105,6 +110,7 @@ ADD_EXECUTABLE(pPowerMangerHost ${SRC})
|
||||
|
||||
TARGET_INCLUDE_DIRECTORIES(pPowerMangerHost PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3
|
||||
${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
TARGET_LINK_LIBRARIES(pPowerMangerHost
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "FeedbackStore.h"
|
||||
#include "sqlite3.h"
|
||||
#include "json/json.h"
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace std;
|
||||
|
||||
// 创建路径的父目录(递归,类似 mkdir -p)
|
||||
static void ensureParentDir(const string& filePath)
|
||||
{
|
||||
size_t pos = filePath.find_last_of('/');
|
||||
if (pos == string::npos) return; // 无目录部分
|
||||
string dir = filePath.substr(0, pos);
|
||||
if (dir.empty()) return;
|
||||
|
||||
string cur;
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
size_t slash = dir.find('/', start);
|
||||
string part = (slash == string::npos) ? dir.substr(start)
|
||||
: dir.substr(start, slash - start);
|
||||
if (!part.empty()) {
|
||||
if (!cur.empty()) cur += '/';
|
||||
cur += part;
|
||||
mkdir(cur.c_str(), 0755);
|
||||
}
|
||||
if (slash == string::npos) break;
|
||||
start = slash + 1;
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackStore::FeedbackStore()
|
||||
: m_db(nullptr), m_appendStmt(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
FeedbackStore::~FeedbackStore()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
static int64_t nowMs()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
bool FeedbackStore::open(const string& path)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_db) return true;
|
||||
|
||||
ensureParentDir(path);
|
||||
|
||||
if (sqlite3_open(path.c_str(), &m_db) != SQLITE_OK) {
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* schema =
|
||||
"CREATE TABLE IF NOT EXISTS fb_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" ts INTEGER NOT NULL,"
|
||||
" msg TEXT NOT NULL,"
|
||||
" payload TEXT NOT NULL"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_fb_log_msg_ts ON fb_log(msg, ts);";
|
||||
|
||||
char* err = nullptr;
|
||||
if (sqlite3_exec(m_db, schema, nullptr, nullptr, &err) != SQLITE_OK) {
|
||||
sqlite3_free(err);
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FeedbackStore::close()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_appendStmt) {
|
||||
sqlite3_finalize(m_appendStmt);
|
||||
m_appendStmt = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool FeedbackStore::begin()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, "BEGIN;", nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
return rc == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool FeedbackStore::append(int64_t ts, const string& msg, const string& payload)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
|
||||
if (!m_appendStmt) {
|
||||
const char* sql = "INSERT INTO fb_log (ts, msg, payload) VALUES (?, ?, ?);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_appendStmt, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_bind_int64(m_appendStmt, 1, ts);
|
||||
sqlite3_bind_text(m_appendStmt, 2, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(m_appendStmt, 3, payload.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(m_appendStmt);
|
||||
sqlite3_reset(m_appendStmt);
|
||||
sqlite3_clear_bindings(m_appendStmt);
|
||||
return rc == SQLITE_DONE;
|
||||
}
|
||||
|
||||
bool FeedbackStore::commit()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, "COMMIT;", nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
return rc == SQLITE_OK;
|
||||
}
|
||||
|
||||
void FeedbackStore::prune(int64_t keepSeconds)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db || keepSeconds <= 0) return;
|
||||
|
||||
int64_t cutoff = nowMs() - keepSeconds * 1000;
|
||||
const char* sql = "DELETE FROM fb_log WHERE ts < ?;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_int64(stmt, 1, cutoff);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
string FeedbackStore::querySeries(const string& msg, const string& field,
|
||||
int64_t fromTs, int64_t toTs, int limit)
|
||||
{
|
||||
Json::Value result(Json::arrayValue);
|
||||
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db || msg.empty() || field.empty()) {
|
||||
return "[]";
|
||||
}
|
||||
if (limit <= 0) limit = 2000;
|
||||
if (toTs <= 0) toTs = nowMs();
|
||||
|
||||
const char* sql =
|
||||
"SELECT ts, payload FROM fb_log "
|
||||
"WHERE msg = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC LIMIT ?;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return "[]";
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 2, fromTs);
|
||||
sqlite3_bind_int64(stmt, 3, toTs);
|
||||
sqlite3_bind_int(stmt, 4, limit);
|
||||
|
||||
Json::CharReaderBuilder builder;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int64_t ts = sqlite3_column_int64(stmt, 0);
|
||||
const char* payload = (const char*)sqlite3_column_text(stmt, 1);
|
||||
if (!payload) continue;
|
||||
|
||||
Json::Value root;
|
||||
JSONCPP_STRING errs;
|
||||
istringstream stream(payload);
|
||||
if (!Json::parseFromStream(builder, stream, &root, &errs)) continue;
|
||||
if (!root.isMember(field) || !root[field].isNumeric()) continue;
|
||||
|
||||
Json::Value pt(Json::arrayValue);
|
||||
pt.append((Json::Int64)ts);
|
||||
pt.append(root[field].asDouble());
|
||||
result.append(pt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb.settings_["indentation"] = "";
|
||||
return Json::writeString(wb, result);
|
||||
}
|
||||
|
||||
string FeedbackStore::queryRaw(const string& msg, int64_t fromTs, int64_t toTs, int limit)
|
||||
{
|
||||
Json::Value result(Json::objectValue);
|
||||
Json::Value rows(Json::arrayValue);
|
||||
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_db && !msg.empty()) {
|
||||
if (limit <= 0) limit = 500;
|
||||
if (toTs <= 0) toTs = nowMs();
|
||||
|
||||
const char* sql =
|
||||
"SELECT ts, payload FROM fb_log "
|
||||
"WHERE msg = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC LIMIT ?;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 2, fromTs);
|
||||
sqlite3_bind_int64(stmt, 3, toTs);
|
||||
sqlite3_bind_int(stmt, 4, limit);
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
Json::Value row(Json::objectValue);
|
||||
row["ts"] = (Json::Int64)sqlite3_column_int64(stmt, 0);
|
||||
const char* p = (const char*)sqlite3_column_text(stmt, 1);
|
||||
row["payload"] = p ? p : "";
|
||||
rows.append(row);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
result["rows"] = rows;
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb.settings_["indentation"] = "";
|
||||
return Json::writeString(wb, result);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#ifndef FEEDBACKSTORE_H
|
||||
#define FEEDBACKSTORE_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
// 轻量 SQLite 封装:按时间序列存储上位机反馈消息的原始 JSON。
|
||||
// 表结构:
|
||||
// fb_log(id INTEGER PK AUTOINCREMENT, ts INTEGER, msg TEXT, payload TEXT)
|
||||
// 索引: (msg, ts)
|
||||
class FeedbackStore
|
||||
{
|
||||
public:
|
||||
FeedbackStore();
|
||||
~FeedbackStore();
|
||||
|
||||
bool open(const std::string& path);
|
||||
void close();
|
||||
bool isOpen() const { return m_db != nullptr; }
|
||||
|
||||
// 批写入:begin -> append*N -> commit
|
||||
bool begin();
|
||||
bool append(int64_t ts, const std::string& msg, const std::string& payload);
|
||||
bool commit();
|
||||
|
||||
// 按保留时间清理:删除 ts 早于 (当前时间 - keepSeconds) 的记录
|
||||
void prune(int64_t keepSeconds);
|
||||
|
||||
// 查询某消息某数值字段的时间序列,返回 JSON 数组字符串 [[ts,value],...]
|
||||
std::string querySeries(const std::string& msg, const std::string& field,
|
||||
int64_t fromTs, int64_t toTs, int limit);
|
||||
|
||||
// 查询某消息的原始记录,返回 JSON {"rows":[{"ts":..,"payload":".."},...]}
|
||||
std::string queryRaw(const std::string& msg,
|
||||
int64_t fromTs, int64_t toTs, int limit);
|
||||
|
||||
private:
|
||||
sqlite3* m_db;
|
||||
sqlite3_stmt* m_appendStmt;
|
||||
std::mutex m_mutex;
|
||||
};
|
||||
|
||||
#endif // FEEDBACKSTORE_H
|
||||
@@ -7,18 +7,30 @@
|
||||
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include "MBUtils.h"
|
||||
#include "HostSim.h"
|
||||
#include "WebServer.h"
|
||||
#include "FeedbackStore.h"
|
||||
#include "json/json.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
static int64_t hostNowMs()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Constructor
|
||||
|
||||
HostSim::HostSim()
|
||||
: m_feedbackDirty(false), m_webServer(nullptr), m_webPort(18080)
|
||||
: m_feedbackDirty(false), m_webServer(nullptr), m_webPort(18080),
|
||||
m_feedbackStore(nullptr), m_dbFile("feedback_data.db"),
|
||||
m_fbLogEnabled(true), m_fbLogKeepSeconds(86400),
|
||||
m_fbLogPruneIntervalSeconds(60), m_lastPruneTs(0)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,6 +39,11 @@ HostSim::HostSim()
|
||||
|
||||
HostSim::~HostSim()
|
||||
{
|
||||
if (m_feedbackStore) {
|
||||
m_feedbackStore->close();
|
||||
delete m_feedbackStore;
|
||||
m_feedbackStore = nullptr;
|
||||
}
|
||||
if (m_webServer) {
|
||||
m_webServer->stop();
|
||||
delete m_webServer;
|
||||
@@ -79,6 +96,7 @@ bool HostSim::Iterate()
|
||||
}
|
||||
if (dirty && m_webServer) {
|
||||
m_webServer->broadcast(buildSnapshot());
|
||||
persistFeedback(hostNowMs());
|
||||
lock_guard<mutex> lock(m_feedbackMutex);
|
||||
m_feedbackDirty = false;
|
||||
}
|
||||
@@ -98,14 +116,34 @@ bool HostSim::OnStartUp()
|
||||
for (p = sParams.begin(); p != sParams.end(); p++) {
|
||||
string line = *p;
|
||||
string param = tolower(biteStringX(line, '='));
|
||||
string value = line;
|
||||
string value = stripBlankEnds(line);
|
||||
|
||||
if (param == "web_port") {
|
||||
m_webPort = atoi(value.c_str());
|
||||
} else if (param == "db_file") {
|
||||
m_dbFile = value;
|
||||
} else if (param == "fb_log_enabled") {
|
||||
m_fbLogEnabled = (tolower(value) == "true" || value == "1");
|
||||
} else if (param == "fb_log_keep_seconds") {
|
||||
m_fbLogKeepSeconds = atoll(value.c_str());
|
||||
} else if (param == "fb_log_prune_interval_seconds") {
|
||||
m_fbLogPruneIntervalSeconds = atoll(value.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动反馈落盘存储(SQLite)
|
||||
if (m_fbLogEnabled) {
|
||||
m_feedbackStore = new FeedbackStore();
|
||||
if (m_feedbackStore->open(m_dbFile)) {
|
||||
cout << "pPowerMangerHost feedback logging to " << m_dbFile << endl;
|
||||
} else {
|
||||
cerr << "pPowerMangerHost failed to open feedback DB: " << m_dbFile << endl;
|
||||
delete m_feedbackStore;
|
||||
m_feedbackStore = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动网页服务器
|
||||
m_webServer = new WebServer();
|
||||
m_webServer->setOnMessage([this](const string& msg) {
|
||||
@@ -114,6 +152,9 @@ bool HostSim::OnStartUp()
|
||||
m_webServer->setOnOpen([this]() {
|
||||
return this->buildSnapshot();
|
||||
});
|
||||
m_webServer->setApiHandler([this](const string& uri, const string& query) {
|
||||
return this->handleApi(uri, query);
|
||||
});
|
||||
m_webServer->start(m_webPort);
|
||||
|
||||
RegisterVariables();
|
||||
@@ -205,6 +246,99 @@ string HostSim::buildSnapshot()
|
||||
return Json::writeString(wb, root);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: persistFeedback
|
||||
// 把当前反馈缓存批量写入 SQLite(每次收到即存)
|
||||
|
||||
void HostSim::persistFeedback(int64_t nowMs)
|
||||
{
|
||||
if (!m_feedbackStore || !m_feedbackStore->isOpen()) return;
|
||||
|
||||
vector<pair<string, string> > entries;
|
||||
{
|
||||
lock_guard<mutex> lock(m_feedbackMutex);
|
||||
for (map<string, string>::iterator it = m_feedback.begin();
|
||||
it != m_feedback.end(); ++it) {
|
||||
entries.push_back(make_pair(it->first, it->second));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.empty()) return;
|
||||
|
||||
m_feedbackStore->begin();
|
||||
for (size_t i = 0; i < entries.size(); i++) {
|
||||
m_feedbackStore->append(nowMs, entries[i].first, entries[i].second);
|
||||
}
|
||||
m_feedbackStore->commit();
|
||||
|
||||
if (nowMs - m_lastPruneTs >= m_fbLogPruneIntervalSeconds * 1000) {
|
||||
m_feedbackStore->prune(m_fbLogKeepSeconds);
|
||||
m_lastPruneTs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: handleApi
|
||||
// 处理 /api/history 与 /api/rawlog 查询
|
||||
|
||||
static string urlDecode(const string& s)
|
||||
{
|
||||
string out;
|
||||
for (size_t i = 0; i < s.size(); i++) {
|
||||
if (s[i] == '%' && i + 2 < s.size()) {
|
||||
int v = 0;
|
||||
for (int j = 1; j <= 2; j++) {
|
||||
char c = s[i + j];
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= (c - '0');
|
||||
else if (c >= 'a' && c <= 'f') v |= (c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') v |= (c - 'A' + 10);
|
||||
}
|
||||
out += (char)v;
|
||||
i += 2;
|
||||
} else if (s[i] == '+') {
|
||||
out += ' ';
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static string queryGet(const string& query, const string& key)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos <= query.size()) {
|
||||
size_t amp = query.find('&', pos);
|
||||
string kv = query.substr(pos, amp == string::npos ? string::npos : amp - pos);
|
||||
size_t eq = kv.find('=');
|
||||
if (eq != string::npos && kv.substr(0, eq) == key) {
|
||||
return urlDecode(kv.substr(eq + 1));
|
||||
}
|
||||
if (amp == string::npos) break;
|
||||
pos = amp + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
string HostSim::handleApi(const string& uri, const string& query)
|
||||
{
|
||||
if (!m_feedbackStore || !m_feedbackStore->isOpen()) return "";
|
||||
|
||||
string msg = queryGet(query, "msg");
|
||||
string field = queryGet(query, "field");
|
||||
int64_t fromTs = atoll(queryGet(query, "from").c_str());
|
||||
int64_t toTs = atoll(queryGet(query, "to").c_str());
|
||||
int limit = atoi(queryGet(query, "limit").c_str());
|
||||
|
||||
if (uri == "/api/history") {
|
||||
return m_feedbackStore->querySeries(msg, field, fromTs, toTs, limit);
|
||||
} else if (uri == "/api/rawlog") {
|
||||
return m_feedbackStore->queryRaw(msg, fromTs, toTs, limit);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: RegisterVariables
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <atomic>
|
||||
|
||||
class WebServer;
|
||||
class FeedbackStore;
|
||||
|
||||
// 模拟 pPowerManger 上位机:网页 <-> MOOSDB 桥
|
||||
class HostSim : public CMOOSApp
|
||||
@@ -37,11 +38,14 @@ class HostSim : public CMOOSApp
|
||||
void pushCommand(const std::string& key, const std::string& jsonValue);
|
||||
// 取当前所有反馈变量的 JSON 快照
|
||||
std::string buildSnapshot();
|
||||
// 处理 /api/* 请求,返回 JSON 响应体(空串表示 404)
|
||||
std::string handleApi(const std::string& uri, const std::string& query);
|
||||
|
||||
private:
|
||||
void registerFeedbackVars();
|
||||
bool drainCommands();
|
||||
void handleWebMessage(const std::string& msg);
|
||||
void persistFeedback(int64_t nowMs);
|
||||
|
||||
private: // State variables
|
||||
// 反馈缓存:key -> 最新 JSON 字符串
|
||||
@@ -56,6 +60,14 @@ class HostSim : public CMOOSApp
|
||||
// 网页服务器
|
||||
WebServer* m_webServer;
|
||||
int m_webPort;
|
||||
|
||||
// 反馈落盘存储(SQLite)
|
||||
FeedbackStore* m_feedbackStore;
|
||||
std::string m_dbFile;
|
||||
bool m_fbLogEnabled;
|
||||
int64_t m_fbLogKeepSeconds;
|
||||
int64_t m_fbLogPruneIntervalSeconds;
|
||||
int64_t m_lastPruneTs;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -15,8 +15,24 @@ void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源路由:在嵌入式资源表 webassets 中按路径查找并返回
|
||||
// /api/* 接口:交给宿主程序处理,返回 JSON
|
||||
std::string path(hm->uri.buf, hm->uri.len);
|
||||
if (path.rfind("/api/", 0) == 0) {
|
||||
std::string body;
|
||||
if (m_apiHandler) {
|
||||
std::string query(hm->query.buf, hm->query.len);
|
||||
body = m_apiHandler(path, query);
|
||||
}
|
||||
if (!body.empty()) {
|
||||
mg_http_reply(c, 200, "Content-Type: application/json\r\n",
|
||||
"%.*s", (int)body.size(), body.c_str());
|
||||
} else {
|
||||
mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源路由:在嵌入式资源表 webassets 中按路径查找并返回
|
||||
if (path == "/" || path == "/index.html") {
|
||||
path = "index.html";
|
||||
} else if (!path.empty() && path[0] == '/') {
|
||||
@@ -76,6 +92,10 @@ void WebServer::setOnOpen(std::function<std::string()> handler) {
|
||||
m_onOpen = handler;
|
||||
}
|
||||
|
||||
void WebServer::setApiHandler(std::function<std::string(const std::string&, const std::string&)> handler) {
|
||||
m_apiHandler = handler;
|
||||
}
|
||||
|
||||
bool WebServer::start(int port) {
|
||||
if (m_running) return true;
|
||||
m_port = port;
|
||||
|
||||
@@ -27,6 +27,9 @@ public:
|
||||
// 新 WebSocket 客户端连接时回调,返回需要立即推送给该客户端的初始文本(如快照)
|
||||
void setOnOpen(std::function<std::string()> handler);
|
||||
|
||||
// 处理 /api/* HTTP 请求,返回 JSON 响应体(空串表示 404)
|
||||
void setApiHandler(std::function<std::string(const std::string& uri, const std::string& query)> handler);
|
||||
|
||||
private:
|
||||
static bool serverThreadCB(void* pParam);
|
||||
bool serverThreadFunc();
|
||||
@@ -41,6 +44,7 @@ private:
|
||||
std::vector<mg_connection*> m_wsConnections;
|
||||
std::function<void(const std::string&)> m_onMessage;
|
||||
std::function<std::string()> m_onOpen;
|
||||
std::function<std::string(const std::string&, const std::string&)> m_apiHandler;
|
||||
};
|
||||
|
||||
#endif // WEBSERVER_H
|
||||
|
||||
@@ -5,4 +5,10 @@ ProcessConfig = pPowerMangerHost
|
||||
AppTick = 4
|
||||
CommsTick = 4
|
||||
WEB_PORT = 18080
|
||||
|
||||
// 反馈落盘(SQLite)
|
||||
DB_FILE = feedback_data.db
|
||||
FB_LOG_ENABLED = true
|
||||
FB_LOG_KEEP_SECONDS = 86400 // 历史保留时长(秒),24h
|
||||
FB_LOG_PRUNE_INTERVAL_SECONDS = 60 // 清理检查周期(秒)
|
||||
}
|
||||
|
||||
@@ -367,6 +367,25 @@
|
||||
<div id="seriesBox" style="margin-bottom:8px"></div>
|
||||
<canvas id="chart"></canvas>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>历史数据(本地 SQLite)</h2>
|
||||
<div class="row">
|
||||
<label>消息</label>
|
||||
<select id="histMsg"></select>
|
||||
<label>字段</label>
|
||||
<select id="histField" style="min-width:220px"></select>
|
||||
<label>时间范围</label>
|
||||
<select id="histRange">
|
||||
<option value="300">最近 5 分钟</option>
|
||||
<option value="3600">最近 1 小时</option>
|
||||
<option value="21600">最近 6 小时</option>
|
||||
<option value="86400" selected>最近 24 小时</option>
|
||||
</select>
|
||||
<button id="btnLoadHistory">加载历史</button>
|
||||
</div>
|
||||
<div class="muted" id="histEcho"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
@@ -1097,6 +1116,52 @@ function drawChart() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 11.5 历史数据(本地 SQLite)
|
||||
// ============================================================
|
||||
function populateHistMsgs() {
|
||||
el("histMsg").innerHTML = FEEDBACKS.map(([k, t]) =>
|
||||
`<option value="${k}">${esc(t)}</option>`).join("");
|
||||
populateHistFields();
|
||||
}
|
||||
|
||||
function populateHistFields() {
|
||||
const msg = el("histMsg").value;
|
||||
const d = feedbackData[msg];
|
||||
const sel = el("histField");
|
||||
if (!d || typeof d !== "object") { sel.innerHTML = '<option value="">暂无字段</option>'; return; }
|
||||
const fields = Object.keys(d).filter(k => typeof d[k] === "number");
|
||||
sel.innerHTML = fields.length
|
||||
? fields.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join("")
|
||||
: '<option value="">无数值字段</option>';
|
||||
}
|
||||
|
||||
function loadHistorySeries() {
|
||||
const msg = el("histMsg").value;
|
||||
const field = el("histField").value;
|
||||
if (!msg || !field) { log("请选择消息和字段", "err"); return; }
|
||||
const rangeSec = Number(el("histRange").value);
|
||||
const to = Date.now();
|
||||
const from = to - rangeSec * 1000;
|
||||
const url = "/api/history?msg=" + encodeURIComponent(msg) +
|
||||
"&field=" + encodeURIComponent(field) +
|
||||
"&from=" + from + "&to=" + to + "&limit=2000";
|
||||
fetch(url).then(r => r.json()).then(points => {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
el("histEcho").textContent = "该字段在所选范围内无数据";
|
||||
return;
|
||||
}
|
||||
const key = "历史 " + msg + "." + field;
|
||||
history[key] = { color: COLORS[nextColor % COLORS.length], points: points };
|
||||
nextColor++;
|
||||
renderSeriesBox();
|
||||
drawChart();
|
||||
el("histEcho").textContent = "已加载 " + points.length + " 点 → " + key;
|
||||
}).catch(err => {
|
||||
el("histEcho").textContent = "加载失败: " + err;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 12. Tab 切换
|
||||
// ============================================================
|
||||
@@ -1109,7 +1174,6 @@ function switchTab(name) {
|
||||
});
|
||||
if (name === "chart") { refreshFieldOptions(); drawChart(); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 13. 初始化
|
||||
// ============================================================
|
||||
@@ -1168,6 +1232,11 @@ function init() {
|
||||
el("btnAddSeries").onclick = () => addSeries(el("fieldSel").value);
|
||||
el("btnClearSeries").onclick = () => { history = {}; nextColor = 0; renderSeriesBox(); drawChart(); };
|
||||
|
||||
// 历史数据
|
||||
populateHistMsgs();
|
||||
el("histMsg").onchange = populateHistFields;
|
||||
el("btnLoadHistory").onclick = loadHistorySeries;
|
||||
|
||||
// 日志
|
||||
el("btnClearLog").onclick = () => { el("log").innerHTML = ""; };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user