diff --git a/rack/README.md b/rack/README.md index de53e25..472e4bc 100644 --- a/rack/README.md +++ b/rack/README.md @@ -12,8 +12,8 @@ | 目录 | 内容 | |---|---| | `backend/prrack/` | `protocol.py` 板侧协议镜像(命令白名单 / 事件表 / seq)· `bus.py` MQTT 抽象(paho / 进程内假 broker)· `hub.py` 核心 · `recovery.py` 自动恢复 · `db.py` 存储 · `firmware.py` 固件库 + OTA 命令 · `api.py` HTTP+WS · `simboard.py` 模拟板子 · `main.py` 入口 | -| `backend/tests/` | 41 例(pytest),本机与服务器(Python 3.8)都跑 | -| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) | +| `backend/tests/` | 42 例(pytest),本机与服务器(Python 3.8)都跑 | +| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入)。主题在 `src/theme.css`(亮 / 暗两套变量,跟随系统),图表公共部分在 `src/chart.js` | | `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 | ## 规矩(都来自板侧协议 §10.8,改代码前先看) @@ -24,6 +24,18 @@ 4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。 5. **命令白名单在 `protocol.build_command`**:`wifi` / `mqtt`(只收 USB)与 `events`(服务器自己同步)不给界面。板子的校验仍是唯一权威。 +## 界面(2026-09-27 改版) + +- 仪器前面板风格:每块板一块面板,五个「刀片槽」各有状态灯、电流读数和**最近两分钟电流小曲线**(平线 = 疑似卡死)。 + 只有要人看的状态(异常 / 故障 / 正在执行)上色,「运行」「断电」「空槽」保持安静;总览顶上一句话列出需要看的路。 +- 读数用 DIN 系等宽数字(系统自带:macOS/iOS `DIN Alternate`、Windows `Bahnschrift`),不加载网络字体(国内拉不到 Google Fonts)。 +- 暗色模式跟随系统(`html.dark`,Element Plus 的暗色变量也认它),图表在切换时重画。 +- 曲线**不用双 Y 轴**:一路的电流 / 电压、底板的温度 / 风扇,都是上下两块共用时间轴(dataviz 规矩)。 + 两路温度用 dataviz 参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 `validate_palette.js`,全部通过。 +- **底板温度曲线**:`GET /api/boards/{id}/history`(`Db.board_history`)。6 小时以内回每秒原始数据,更长回分钟聚合 + `board_tele_min`(温度带每分钟 min/max、风扇、输入电压电流;与每路的 `tele_min` 一样留 90 天)。 + NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37(R2 规划 §32)。 + ## 推固件(OTA,板侧 C2) 界面「固件」页:上传 `firmware/build/phonerail.bin` → 选板子推送。进度、待验证、回滚都直接显示遥测里的 `ota` 一块。 diff --git a/rack/backend/prrack/api.py b/rack/backend/prrack/api.py index 9f1dc2b..4886f44 100644 --- a/rack/backend/prrack/api.py +++ b/rack/backend/prrack/api.py @@ -108,6 +108,12 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None, except CmdTimeout as e: raise HTTPException(504, str(e)) + @app.get("/api/boards/{board}/history") + def board_history(board: str, start: int, end: Optional[int] = None): + """整板曲线(温度 / 风扇 / 输入),见 Db.board_history。""" + _board_or_404(hub, board) + return hub.db.board_history(board, start, end or now_ms()) + @app.get("/api/boards/{board}/rails/{rail}/history") def history(board: str, rail: int, start: int, end: Optional[int] = None): _board_or_404(hub, board) diff --git a/rack/backend/prrack/db.py b/rack/backend/prrack/db.py index 1b324dd..7b2eba1 100644 --- a/rack/backend/prrack/db.py +++ b/rack/backend/prrack/db.py @@ -52,6 +52,14 @@ CREATE TABLE IF NOT EXISTS board_tele ( 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) +); 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, @@ -162,7 +170,17 @@ class Db: "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 + 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() @@ -170,6 +188,7 @@ class Db: 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 history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict: """[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据, @@ -192,3 +211,29 @@ class Db: 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];读不到的是 None。""" + 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=? AND ts= 100 + p = h["points"][0] + assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000 + # 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾 + assert db.downsample(base + 180000) > 0 + m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时 + rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,)) + assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060] + assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119] + assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300 + assert m["resolution"] == "raw" + old = db.board_history(B, base - 10 * 86400000, base + 120000) + # 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大 + assert old["resolution"] == "minute" and old["points"][0][2] == 35000 + assert max(p[3] for p in old["points"]) == 35119 + assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404 diff --git a/rack/backend/tests/test_firmware.py b/rack/backend/tests/test_firmware.py index 1ebcd6a..bcb83d7 100644 --- a/rack/backend/tests/test_firmware.py +++ b/rack/backend/tests/test_firmware.py @@ -199,8 +199,13 @@ def test_ota_events_are_named_by_arg(env): c, hub, sim = env sim._ev(0, 18, 3, 3, 996064) # 失败:哈希不对 sim._ev(0, 18, 5) # 回滚 - sim.tick(now_ms()) - assert wait_for(lambda: len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2) + + # 每次检查前都推一条遥测:Hub 正在同步上一批事件(启动时的 BOOT)时会跳过这一条遥测带来的 + # 「有新事件」,只推一次的话偶尔再也没人触发同步(服务器上 3 次里红过 1 次)。真板 1 s 一条,没有这个问题。 + def synced(): + sim.tick(now_ms()) + return len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2 + assert wait_for(synced) ev = {e["arg"]: e for e in c.get("/api/events").json() if e["type"] == 18} assert ev[3]["name"] == "固件升级(失败):哈希不对" and ev[3]["severe"] is True assert ev[5]["name"] == "固件升级(回滚)" and ev[5]["severe"] is True diff --git a/rack/frontend/src/App.vue b/rack/frontend/src/App.vue index 664968d..d70ffe4 100644 --- a/rack/frontend/src/App.vue +++ b/rack/frontend/src/App.vue @@ -1,7 +1,7 @@ diff --git a/rack/frontend/src/api.js b/rack/frontend/src/api.js index 0106794..50aef89 100644 --- a/rack/frontend/src/api.js +++ b/rack/frontend/src/api.js @@ -24,6 +24,8 @@ export const api = { patchPhone: (board, rail, body) => req('PATCH', `/api/boards/${board}/rails/${rail}`, body), history: (board, rail, start, end) => req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`), + boardHistory: (board, start, end) => + req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`), events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)), audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)), recovery: () => req('GET', '/api/recovery'), diff --git a/rack/frontend/src/chart.js b/rack/frontend/src/chart.js new file mode 100644 index 0000000..5ebffca --- /dev/null +++ b/rack/frontend/src/chart.js @@ -0,0 +1,57 @@ +// ECharts 的公共部分:按需引入 + 取色 + 两张上下叠放的图共用一条时间轴。 +// +// 规矩(dataviz skill): +// · 不用双 Y 轴 —— 电流和电压、温度和风扇,一律上下两个 grid,共用 x(dataZoom 联动); +// · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过); +// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线; +// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。 +import * as echarts from 'echarts/core' +import { LineChart } from 'echarts/charts' +import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components' +import { CanvasRenderer } from 'echarts/renderers' +import { cssVar, dark } from './theme' + +echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer]) +export { echarts } + +const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] } + +export function palette() { + const s = SERIES[dark.value ? 'dark' : 'light'] + return { + s1: s[0], s2: s[1], + ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'), + grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'), + } +} + +export function band(hex, a = 0.15) { + const n = parseInt(hex.slice(1), 16) + return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})` +} + +// 上下两块:上面主图(高),下面副图(矮)。返回 grid / xAxis / dataZoom 的公共配置。 +export function stacked(p, { topName, bottomName, topFmt, bottomFmt, bottomMin, bottomMax, topMin, topMax, bottomInterval }) { + const axisLabel = { color: p.muted, fontSize: 11 } + const x = i => ({ + type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false }, + axisLabel: { ...axisLabel, show: i === 1, hideOverlap: true }, splitLine: { show: false }, + }) + const y = (i, name, fmt, min, max, interval) => ({ + type: 'value', gridIndex: i, name, nameTextStyle: { color: p.muted, fontSize: 11, align: 'left', padding: [0, 0, 0, -30] }, + scale: true, min, max, interval, splitNumber: i === 0 ? 4 : 2, axisLabel: { ...axisLabel, formatter: fmt }, + splitLine: { lineStyle: { color: p.grid } }, axisLine: { show: false }, axisTick: { show: false }, + }) + return { + animation: false, + textStyle: { fontFamily: getComputedStyle(document.body).fontFamily }, + grid: [{ left: 48, right: 16, top: 30, height: '48%' }, { left: 48, right: 16, top: '75%', bottom: 44 }], + xAxis: [x(0), x(1)], + yAxis: [y(0, topName, topFmt, topMin, topMax), y(1, bottomName, bottomFmt, bottomMin, bottomMax, bottomInterval)], + axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: p.muted } }, + dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] }, + { type: 'slider', xAxisIndex: [0, 1], height: 16, bottom: 8, borderColor: p.axis, fillerColor: band(p.s1, 0.12), + dataBackground: { lineStyle: { color: p.axis }, areaStyle: { color: p.bay } }, textStyle: { color: p.muted } }], + tooltip: { trigger: 'axis', backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 } }, + } +} diff --git a/rack/frontend/src/components/BoardChart.vue b/rack/frontend/src/components/BoardChart.vue new file mode 100644 index 0000000..22455e5 --- /dev/null +++ b/rack/frontend/src/components/BoardChart.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/rack/frontend/src/components/HistoryChart.vue b/rack/frontend/src/components/HistoryChart.vue index 488c7c8..9afc23b 100644 --- a/rack/frontend/src/components/HistoryChart.vue +++ b/rack/frontend/src/components/HistoryChart.vue @@ -1,58 +1,55 @@ diff --git a/rack/frontend/src/components/OtaState.vue b/rack/frontend/src/components/OtaState.vue index 20573c8..022bc4b 100644 --- a/rack/frontend/src/components/OtaState.vue +++ b/rack/frontend/src/components/OtaState.vue @@ -8,7 +8,7 @@ 待验证 上次失败:{{ OTA_ERR_ZH[o.err] || o.err }} 回滚过:{{ o.bad }} - — + — @@ -17,7 +17,7 @@ import { computed } from 'vue' import { OTA_ERR_ZH } from '../api' -const props = defineProps({ status: Object }) +const props = defineProps({ status: Object, quiet: Boolean }) // quiet:没事时什么都不显示(不画「—」) const o = computed(() => props.status?.ota || {}) const busy = computed(() => o.value.st === 'dl') const pct = computed(() => (o.value.size ? Math.floor((o.value.got / o.value.size) * 100) : 0)) diff --git a/rack/frontend/src/components/RailCard.vue b/rack/frontend/src/components/RailCard.vue index 13cba7e..5786639 100644 --- a/rack/frontend/src/components/RailCard.vue +++ b/rack/frontend/src/components/RailCard.vue @@ -1,14 +1,17 @@