文件
phonerail/rack/backend/prrack/db.py
T
zhou yang和Claude Opus 5.5 26c04ce878 机架管理前后端(rack/):FastAPI + SQLite + Vue3,经统一门户上线
- 后端 prrack:MQTT(phonerail-backend @ 127.0.0.1:1884)↔ 遥测入库 / 事件增量同步 / WebSocket 推送;
  命令白名单 + 重传同 seq;审计记门户 X-Auth-User(只绑 127.0.0.1,main.py 拒绝非回环)。
- 自动恢复:只看电流(平线 / 过低,服务器够不到手机 adb),先长按电源再断电重启,
  每小时限次后停手;默认逐台关闭。adb 探活接口 recovery.Prober 预留。
- 前端:总览 / 板子页(五路操作、电流电压曲线)/ 事件 / 操作记录 / 自动恢复;手机宽度可用。
- 部署 deploy/deploy.sh:服务器 Python 3.8 上先跑测试再替换;systemd phonerail-rack,每日备份。
- 门户应用 id 16、key rack(phonerail 已被需求文档站占用),授权 admin + staff。

测试 26 例(本机 3.14 / 服务器 3.8 均过)。线上板 b81f3fc9c8bc 在线,WS 经门户 101 + 1 条/s 遥测。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 12:33:28 +08:00

182 行
8.5 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""SQLite 存储(WAL)。一个文件,备份就是拷它(同门户 / evse 的做法)。
数据量估算:一块板 5 路 × 1 条/s = 43 万行/天。原始 1 s 数据只留 `RAW_KEEP_S`(2 天),
每分钟降采样一行(均值 / 最小 / 最大)留 `MIN_KEEP_S`(90 天)——
电流曲线的用途是「这台手机前天夜里是不是卡死过」,一分钟的 min/max 足够看出来。
事件、审计、恢复记录**永久保留**(量小,而且是排查的唯一线索)。
⚠️ 时间戳一律是**服务器收到的时刻**(毫秒)。板子没有墙钟(需求 §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);
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
);
"""
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))
return 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,))
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]}