PD 供电(板卡页新增一格):
- 优先用遥测里的实时值 status.pd(固件 ≥0.1.17);老固件退回最近一条 PD_LIMIT 事件(快照的 pd)。
显示 20 V / 档位电流 / 整板功率上限 / 输入功率占上限的比例。
- PD_LIMIT 事件写成「PD 20 V / 3.00 A,上限 60 W」,不再把那两列当电压电流显示;arg 2 = 运行中换档。
耗电量(板卡页新增卡片):
- 新表 energy_day(永久保留):从分钟聚合滚出每日能量与有数据秒数,rail 0 = 底板 12 V 输入,1~5 = 各路主板。
每分钟重算今天 / 昨天,更早的只补缺不覆盖(分钟数据 90 天后会被清)。按服务器本地零点切日。
- GET /api/boards/{id}/energy;前端 EnergyCard:今日 / 昨日 / 近 7 天 / 近 30 天 / 累计(各带平均功率)、
每日柱状图、今日各路主板。量的是 12 V 输入,不含 PD 20→12 V 降压损耗。
后端测试 57 项全过;已部署。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
296 行
16 KiB
Python
296 行
16 KiB
Python
"""SQLite 存储(WAL)。一个文件,备份就是拷它(同门户 / evse 的做法)。
|
||
|
||
数据量估算:一块板 5 路 × 1 条/s = 43 万行/天。原始 1 s 数据只留 `RAW_KEEP_S`(2 天),
|
||
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
|
||
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
|
||
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
|
||
每日耗电量(`energy_day`)也永久保留:它从分钟聚合里滚出来,分钟数据过了 90 天被清掉之后就再也算不回来了。
|
||
|
||
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §10.12:
|
||
上电从 0 计),它自己的 `t_ms` 只存在事件表的 `t_ms` 列里,作参考。
|
||
"""
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
from typing import Iterable, List, Optional, Sequence
|
||
|
||
RAW_KEEP_S = 2 * 86400
|
||
MIN_KEEP_S = 90 * 86400
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS boards (
|
||
id TEXT PRIMARY KEY, -- STA MAC 12 位小写十六进制
|
||
name TEXT NOT NULL DEFAULT '',
|
||
location TEXT NOT NULL DEFAULT '',
|
||
fw TEXT NOT NULL DEFAULT '',
|
||
ip TEXT NOT NULL DEFAULT '',
|
||
online INTEGER NOT NULL DEFAULT 0,
|
||
last_seen INTEGER NOT NULL DEFAULT 0,
|
||
ev_cursor INTEGER NOT NULL DEFAULT 0, -- 下一条要拉的事件序号
|
||
created INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS phones (
|
||
board_id TEXT NOT NULL,
|
||
rail INTEGER NOT NULL,
|
||
label TEXT NOT NULL DEFAULT '',
|
||
adb_addr TEXT NOT NULL DEFAULT '', -- 预留:服务器够得着手机时填 host:port
|
||
auto_recover INTEGER NOT NULL DEFAULT 0, -- 默认关:逐台打开
|
||
notes TEXT NOT NULL DEFAULT '',
|
||
PRIMARY KEY (board_id, rail)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tele (
|
||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||
mv INTEGER, ua INTEGER, en INTEGER, vb INTEGER, pr INTEGER, pg INTEGER, flt INTEGER
|
||
);
|
||
CREATE INDEX IF NOT EXISTS tele_idx ON tele (board_id, rail, ts);
|
||
CREATE TABLE IF NOT EXISTS tele_min (
|
||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||
mv_avg INTEGER, ua_avg INTEGER, ua_min INTEGER, ua_max INTEGER, n INTEGER,
|
||
PRIMARY KEY (board_id, rail, ts)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS board_tele (
|
||
ts INTEGER NOT NULL, board_id TEXT NOT NULL,
|
||
in_mv INTEGER, in_ua INTEGER, temp0 INTEGER, temp1 INTEGER, fan_duty INTEGER
|
||
);
|
||
CREATE INDEX IF NOT EXISTS board_tele_idx ON board_tele (board_id, ts);
|
||
-- 整板遥测的分钟聚合(温度 / 风扇 / 输入),和 tele_min 一样留 90 天:「前天夜里底板多热」要看得到
|
||
CREATE TABLE IF NOT EXISTS board_tele_min (
|
||
ts INTEGER NOT NULL, board_id TEXT NOT NULL,
|
||
t0_avg INTEGER, t0_min INTEGER, t0_max INTEGER,
|
||
t1_avg INTEGER, t1_min INTEGER, t1_max INTEGER,
|
||
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
|
||
PRIMARY KEY (board_id, ts)
|
||
);
|
||
-- 每日耗电量(Db.energy_rollup)。rail 0 = 底板 12 V 输入,1~5 = 各路手机主板。
|
||
-- mws = 这一天累计的能量(mW·s,÷3.6e6 = Wh),secs = 这一天里有数据的秒数(平均功率 = mws / secs)。
|
||
-- day 是服务器本地时间的日期:「当日」按机房的零点切,不按 UTC。
|
||
CREATE TABLE IF NOT EXISTS energy_day (
|
||
board_id TEXT NOT NULL, day TEXT NOT NULL, rail INTEGER NOT NULL,
|
||
mws INTEGER NOT NULL, secs INTEGER NOT NULL,
|
||
PRIMARY KEY (board_id, day, rail)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS events (
|
||
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
||
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
||
received INTEGER NOT NULL,
|
||
PRIMARY KEY (board_id, seq)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS events_recv_idx ON events (received);
|
||
CREATE TABLE IF NOT EXISTS audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts INTEGER NOT NULL, user TEXT NOT NULL, board_id TEXT NOT NULL, rail INTEGER,
|
||
request TEXT NOT NULL, response TEXT NOT NULL, ok INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS recovery_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts INTEGER NOT NULL, board_id TEXT NOT NULL, rail INTEGER NOT NULL,
|
||
state TEXT NOT NULL, reason TEXT NOT NULL, detail TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY, value TEXT NOT NULL
|
||
);
|
||
-- 固件库(firmware.py):镜像文件本身在 --fw-dir 下,按 sha256 前 16 位命名
|
||
CREATE TABLE IF NOT EXISTS firmware (
|
||
sha256 TEXT PRIMARY KEY,
|
||
version TEXT NOT NULL,
|
||
project TEXT NOT NULL,
|
||
built TEXT NOT NULL DEFAULT '', -- 镜像头里的编译日期时间
|
||
idf TEXT NOT NULL DEFAULT '',
|
||
size INTEGER NOT NULL,
|
||
filename TEXT NOT NULL DEFAULT '',
|
||
uploaded_by TEXT NOT NULL,
|
||
uploaded INTEGER NOT NULL,
|
||
notes TEXT NOT NULL DEFAULT ''
|
||
);
|
||
"""
|
||
|
||
|
||
def now_ms() -> int:
|
||
return int(time.time() * 1000)
|
||
|
||
|
||
class Db:
|
||
"""一个连接 + 一把锁。后端是单进程(uvicorn 单 worker),写入量是每秒几十行,
|
||
不值得上连接池;锁是为了 paho 线程之外偶尔还有线程池里的查询。"""
|
||
|
||
def __init__(self, path: str):
|
||
self.path = path
|
||
self._lock = threading.Lock()
|
||
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
||
self.conn.row_factory = sqlite3.Row
|
||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||
self.conn.execute("PRAGMA synchronous=NORMAL")
|
||
self.conn.executescript(SCHEMA)
|
||
|
||
def close(self):
|
||
with self._lock:
|
||
self.conn.close()
|
||
|
||
# ---- 通用 ----------------------------------------------------------
|
||
def q(self, sql: str, args: Sequence = ()) -> List[sqlite3.Row]:
|
||
with self._lock:
|
||
return self.conn.execute(sql, args).fetchall()
|
||
|
||
def x(self, sql: str, args: Sequence = ()) -> int:
|
||
with self._lock:
|
||
cur = self.conn.execute(sql, args)
|
||
return cur.lastrowid
|
||
|
||
def many(self, sql: str, rows: Iterable[Sequence]):
|
||
with self._lock:
|
||
self.conn.execute("BEGIN")
|
||
try:
|
||
self.conn.executemany(sql, rows)
|
||
self.conn.execute("COMMIT")
|
||
except Exception:
|
||
self.conn.execute("ROLLBACK")
|
||
raise
|
||
|
||
# ---- 板子 ----------------------------------------------------------
|
||
def ensure_board(self, board: str):
|
||
self.x("INSERT OR IGNORE INTO boards (id, created) VALUES (?, ?)", (board, now_ms()))
|
||
self.many("INSERT OR IGNORE INTO phones (board_id, rail) VALUES (?, ?)",
|
||
[(board, r) for r in range(1, 6)])
|
||
|
||
def set_board_state(self, board: str, online: bool, fw: str = None, ip: str = None):
|
||
self.ensure_board(board)
|
||
self.x("UPDATE boards SET online=?, fw=COALESCE(?, fw), ip=COALESCE(?, ip), last_seen=? "
|
||
"WHERE id=?", (1 if online else 0, fw, ip, now_ms(), board))
|
||
|
||
def setting(self, key: str, default: str) -> str:
|
||
rows = self.q("SELECT value FROM settings WHERE key=?", (key,))
|
||
return rows[0]["value"] if rows else default
|
||
|
||
def set_setting(self, key: str, value: str):
|
||
self.x("INSERT INTO settings (key, value) VALUES (?, ?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
||
|
||
# ---- 遥测 ----------------------------------------------------------
|
||
def downsample(self, upto_ms: Optional[int] = None) -> int:
|
||
"""把还没聚合的整分钟原始数据聚成 tele_min。幂等(主键冲突就跳过)。
|
||
只聚到 `upto_ms` 之前的**整分钟**:正在进行的这一分钟数据还不全。"""
|
||
upto = (upto_ms if upto_ms is not None else now_ms()) // 60000 * 60000
|
||
with self._lock:
|
||
row = self.conn.execute("SELECT MAX(ts) FROM tele_min").fetchone()
|
||
start = (row[0] + 60000) if row[0] is not None else 0
|
||
cur = self.conn.execute(
|
||
"INSERT OR IGNORE INTO tele_min (ts, board_id, rail, mv_avg, ua_avg, ua_min, ua_max, n) "
|
||
"SELECT ts/60000*60000 AS m, board_id, rail, CAST(AVG(mv) AS INTEGER), "
|
||
"CAST(AVG(ua) AS INTEGER), MIN(ua), MAX(ua), COUNT(*) FROM tele "
|
||
"WHERE ts >= ? AND ts < ? AND ua IS NOT NULL GROUP BY m, board_id, rail",
|
||
(start, upto))
|
||
n = cur.rowcount
|
||
row = self.conn.execute("SELECT MAX(ts) FROM board_tele_min").fetchone()
|
||
bstart = (row[0] + 60000) if row[0] is not None else 0
|
||
cur = self.conn.execute(
|
||
"INSERT OR IGNORE INTO board_tele_min (ts, board_id, t0_avg, t0_min, t0_max, t1_avg, t1_min, "
|
||
"t1_max, fan_avg, in_mv_avg, in_ua_avg, n) "
|
||
"SELECT ts/60000*60000 AS m, board_id, CAST(AVG(temp0) AS INTEGER), MIN(temp0), MAX(temp0), "
|
||
"CAST(AVG(temp1) AS INTEGER), MIN(temp1), MAX(temp1), CAST(AVG(fan_duty) AS INTEGER), "
|
||
"CAST(AVG(in_mv) AS INTEGER), CAST(AVG(in_ua) AS INTEGER), COUNT(*) FROM board_tele "
|
||
"WHERE ts >= ? AND ts < ? GROUP BY m, board_id", (bstart, upto))
|
||
return n + cur.rowcount
|
||
|
||
def prune(self, now: Optional[int] = None):
|
||
t = now if now is not None else now_ms()
|
||
with self._lock:
|
||
self.conn.execute("DELETE FROM tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||
self.conn.execute("DELETE FROM board_tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||
|
||
# ---- 耗电量 --------------------------------------------------------
|
||
def energy_rollup(self, days_back: int = 1, now: Optional[int] = None):
|
||
"""把分钟聚合滚成每日耗电量。重算最近 `days_back`+1 个本地日(含今天,幂等覆盖),
|
||
更早的日子**只补缺、不覆盖**:分钟数据过了保留期会被清,拿残缺的一天去覆盖就把历史改小了。
|
||
每分钟的能量 = 均值功率 × 这一分钟里的样本数(遥测 1 s 一条,n 就是有数据的秒数,最多按 60 算);
|
||
功率拿 mv_avg × ua_avg 近似,电压几乎不动(同 board_history)。板子离线的时段没有行,自然不计。"""
|
||
t = now if now is not None else now_ms()
|
||
day = "date(ts/1000,'unixepoch','localtime')"
|
||
srcs = (("0", "in_mv_avg", "in_ua_avg", "board_tele_min"), ("rail", "mv_avg", "ua_avg", "tele_min"))
|
||
with self._lock:
|
||
cut = self.conn.execute("SELECT date(?/1000,'unixepoch','localtime',?)",
|
||
(t, "-%d days" % max(0, days_back))).fetchone()[0]
|
||
for rail, mv, ua, table in srcs:
|
||
self.conn.execute(
|
||
"INSERT INTO energy_day (board_id, day, rail, mws, secs) "
|
||
"SELECT board_id, {day} AS d, {rail} AS r, "
|
||
"CAST(SUM({mv}*{ua}/1e6*MIN(n,60)) AS INTEGER), SUM(MIN(n,60)) FROM {table} "
|
||
"WHERE {mv} IS NOT NULL AND {ua} IS NOT NULL GROUP BY board_id, d, r "
|
||
"ON CONFLICT(board_id, day, rail) DO UPDATE SET mws=excluded.mws, secs=excluded.secs "
|
||
"WHERE excluded.day >= ?".format(day=day, rail=rail, mv=mv, ua=ua, table=table), (cut,))
|
||
|
||
def energy(self, board: str, days: int = 90) -> List[dict]:
|
||
"""最近 `days` 个有数据的本地日,旧 → 新。每天:底板输入的能量 / 有数据秒数,各路主板的能量。"""
|
||
rows = self.q("SELECT day, rail, mws, secs FROM energy_day WHERE board_id=? AND day IN "
|
||
"(SELECT DISTINCT day FROM energy_day WHERE board_id=? ORDER BY day DESC LIMIT ?) "
|
||
"ORDER BY day, rail", (board, board, days))
|
||
out: dict = {}
|
||
for r in rows:
|
||
d = out.setdefault(r["day"], {"day": r["day"], "in_mws": None, "secs": 0, "rails": [None] * 5})
|
||
if r["rail"] == 0:
|
||
d["in_mws"], d["secs"] = r["mws"], r["secs"]
|
||
elif 1 <= r["rail"] <= 5:
|
||
d["rails"][r["rail"] - 1] = {"mws": r["mws"], "secs": r["secs"]}
|
||
return list(out.values())
|
||
|
||
def energy_total(self, board: str) -> dict:
|
||
r = self.q("SELECT COALESCE(SUM(mws),0) mws, COALESCE(SUM(secs),0) secs, MIN(day) since, COUNT(*) days "
|
||
"FROM energy_day WHERE board_id=? AND rail=0", (board,))[0]
|
||
return dict(r)
|
||
|
||
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
||
否则回分钟聚合(带 min/max,界面画成带状)。点太多时按时间桶再抽一次。"""
|
||
span = max(1, t1 - t0)
|
||
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
|
||
if raw_ok:
|
||
bucket = max(1000, span // max_points)
|
||
rows = self.q(
|
||
"SELECT ts/?*? AS t, CAST(AVG(mv) AS INTEGER) mv, CAST(AVG(ua) AS INTEGER) ua, "
|
||
"MIN(ua) ua_min, MAX(ua) ua_max FROM tele WHERE board_id=? AND rail=? "
|
||
"AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, rail, t0, t1))
|
||
res = "raw"
|
||
else:
|
||
bucket = max(60000, span // max_points // 60000 * 60000)
|
||
rows = self.q(
|
||
"SELECT ts/?*? AS t, CAST(AVG(mv_avg) AS INTEGER) mv, CAST(AVG(ua_avg) AS INTEGER) ua, "
|
||
"MIN(ua_min) ua_min, MAX(ua_max) ua_max FROM tele_min WHERE board_id=? AND rail=? "
|
||
"AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, rail, t0, t1))
|
||
res = "minute"
|
||
return {"resolution": res, "bucket_ms": bucket,
|
||
"points": [[r["t"], r["mv"], r["ua"], r["ua_min"], r["ua_max"]] for r in rows]}
|
||
|
||
def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
|
||
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。
|
||
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua, rails_mw];读不到的是 None。
|
||
rails_mw 是五路手机主板合计功率(mW),和输入功率 in_mv×in_ua 一比就是板上损耗。"""
|
||
span = max(1, t1 - t0)
|
||
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
|
||
if raw_ok:
|
||
bucket = max(1000, span // max_points)
|
||
rows = self.q(
|
||
"SELECT ts/?*? AS t, CAST(AVG(temp0) AS INTEGER) a0, MIN(temp0) n0, MAX(temp0) x0, "
|
||
"CAST(AVG(temp1) AS INTEGER) a1, MIN(temp1) n1, MAX(temp1) x1, CAST(AVG(fan_duty) AS INTEGER) f, "
|
||
"CAST(AVG(in_mv) AS INTEGER) mv, CAST(AVG(in_ua) AS INTEGER) ua FROM board_tele "
|
||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||
res = "raw"
|
||
else:
|
||
bucket = max(60000, span // max_points // 60000 * 60000)
|
||
rows = self.q(
|
||
"SELECT ts/?*? AS t, CAST(AVG(t0_avg) AS INTEGER) a0, MIN(t0_min) n0, MAX(t0_max) x0, "
|
||
"CAST(AVG(t1_avg) AS INTEGER) a1, MIN(t1_min) n1, MAX(t1_max) x1, CAST(AVG(fan_avg) AS INTEGER) f, "
|
||
"CAST(AVG(in_mv_avg) AS INTEGER) mv, CAST(AVG(in_ua_avg) AS INTEGER) ua FROM board_tele_min "
|
||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||
res = "minute"
|
||
# 五路主板合计功率(mW):每个时刻五路 mv×ua 求和,再按桶取均值(除以桶里的时刻数,不是行数)。
|
||
# 分钟聚合只有各路均值,拿 mv_avg×ua_avg 近似 —— 电压几乎不动,误差可以忽略。
|
||
src = ("mv*ua", "tele") if res == "raw" else ("mv_avg*ua_avg", "tele_min")
|
||
rails_mw = {r["t"]: r["p"] for r in self.q(
|
||
"SELECT ts/?*? AS t, CAST(SUM(%s)/1e6/COUNT(DISTINCT ts) AS INTEGER) p FROM %s "
|
||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t" % src, (bucket, bucket, board, t0, t1))}
|
||
return {"resolution": res, "bucket_ms": bucket,
|
||
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"],
|
||
rails_mw.get(r["t"])] for r in rows]}
|