From 3bf4548c62d07995d97e5dd4ba77e49cdcfe1ddd Mon Sep 17 00:00:00 2001 From: zhou yang Date: Tue, 6 Oct 2026 07:06:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E6=9C=BA=E4=B8=BB=E6=9D=BF=E6=B8=A9?= =?UTF-8?q?=E5=BA=A6=EF=BC=9A=E4=BB=A3=E7=90=86=200.8.0=20=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=EF=BC=8Crack=20=E6=8B=93=E6=89=91=E5=9B=BE=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 机架代理 0.8.0(Thermal.java):读 /sys/class/thermal 的 quiet_therm(主板热敏)和最热的 CPU 核, 每次长轮询拼在 URL 后面(&tb=&tc=,m°C),不为它多醒一次。电池那一路不报(电池拆了)。 - rack 后端 prrack/fleet.py:每 5 秒读 AppTask 后端的 /api/fleet/phones,按机架位 B-n 对到第 n 路, 放进快照 phones[].live(在线 / 在跑活 / 代理版本 / 温度);变了才推 phones_live。只显示,不参与自动恢复。 读不到就清空,不留旧值。服务单元加 --apptask-env。 - 前端拓扑图每台手机多一行「主板 / CPU」温度(50 °C 橙、60 °C 红,只改颜色)。 - agent_upgrade_safe.sh:升级后 4 分钟没开机就 power_cycle 救一次并补收尾。 五台已升到 0.8.0(两路并行推);第 2、1、5 路 staged 重启后卡死 / 僵住,断电重启后版本都已是新的。 后端测试 58 项全过;已部署。 Co-Authored-By: Claude Opus 5.5 --- rack/backend/prrack/fleet.py | 84 +++++++++++++++++++++++ rack/backend/prrack/hub.py | 12 +++- rack/backend/prrack/main.py | 16 ++++- rack/backend/tests/test_core.py | 39 +++++++++++ rack/deploy/phonerail-rack.service | 1 + rack/frontend/src/components/Topology.vue | 21 +++++- rack/frontend/src/store.js | 6 ++ 7 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 rack/backend/prrack/fleet.py diff --git a/rack/backend/prrack/fleet.py b/rack/backend/prrack/fleet.py new file mode 100644 index 0000000..95e818b --- /dev/null +++ b/rack/backend/prrack/fleet.py @@ -0,0 +1,84 @@ +"""每台手机「自己说的」那一半:在不在线、在不在跑活、主板 / CPU 温度。 + +板子只知道这一路有没有电、多大电流;手机主板多热、代理还活没活着,是机架代理(PhoneRailAgent) +向 AppTask 后端(127.0.0.1:7736)报的。这里每隔几秒去那边读一次 `/api/fleet/phones`, +按机架位对到「哪块板的第几路」,放进板子快照的 `phones[].live`。 + +规矩: +1. **只读、只显示。** 这些数不参与任何判据 —— 自动恢复仍然只看电流(recovery.py),不看手机温度也不看它在不在线。 +2. **读不到就是没有。** AppTask 后端挂了 / 令牌不对 / 超时 ⇒ 整份 live 清空,界面上那一行消失, + 不留着上一次的温度冒充现在的。 +3. **对不上不猜。** 机架位形如 `B-3`(板名-路号):只有一块板时全归它;多块板时只认「板子名字就是那个字母」的。 + 没填机架位、路号不在 1~5、板名对不上的手机,一律不摆。 +""" +import asyncio +import json +import logging +import re +import urllib.request +from typing import Dict, List, Optional, Tuple + +log = logging.getLogger("prrack.fleet") + +POLL_S = 5.0 +TIMEOUT_S = 4.0 +RAIL_RE = re.compile(r"^(.*?)[-_ ]?([1-5])$") + + +def _board_key(name: str) -> str: + return re.sub(r"[\s板]", "", name or "").upper() + + +def map_phones(phones: List[dict], boards: List[Tuple[str, str]]) -> Dict[Tuple[str, int], dict]: + """fleet 的手机表 → {(板子 id, 路号): live}。`boards` 是 [(id, name)]。规矩 3 在这里。""" + out: Dict[Tuple[str, int], dict] = {} + for p in phones: + m = RAIL_RE.match(str(p.get("rail") or "").strip()) + if not m or not p.get("enabled", True): + continue + prefix, rail = m.group(1).upper(), int(m.group(2)) + if len(boards) == 1: + board = boards[0][0] + else: + hit = [b for b, name in boards if prefix and _board_key(name) == prefix] + if len(hit) != 1: + continue + board = hit[0] + at = p.get("temp_at") + out[(board, rail)] = { + "fleet_rail": p.get("rail"), "online": bool(p.get("online")), "busy": bool(p.get("busy")), + "agent": p.get("version") or "", "ip": p.get("ip") or "", + "temp_board_mc": p.get("temp_board_mc"), "temp_cpu_mc": p.get("temp_cpu_mc"), + "temp_at": at * 1000 if isinstance(at, int) else None, # fleet 是秒,这边一律毫秒 + } + return out + + +class FleetPoller: + def __init__(self, hub, url: str, token: str): + self.hub = hub + self.url = url.rstrip("/") + "/api/fleet/phones" + self.token = token + self._warned = False + + def _fetch(self) -> List[dict]: + req = urllib.request.Request(self.url, headers={"X-Auth": self.token}) + with urllib.request.urlopen(req, timeout=TIMEOUT_S) as r: + return json.loads(r.read().decode()).get("phones") or [] + + async def run(self): + loop = asyncio.get_running_loop() + while True: + live: Optional[Dict[Tuple[str, int], dict]] + try: + phones = await loop.run_in_executor(None, self._fetch) + boards = [(r["id"], r["name"]) for r in self.hub.db.q("SELECT id, name FROM boards")] + live = map_phones(phones, boards) + self._warned = False + except Exception as e: # 读不到:清空(规矩 2),只在第一次吭一声 + if not self._warned: + log.warning("AppTask 的手机表读不到(%s)—— 手机温度 / 在线状态不显示", e) + self._warned = True + live = {} + self.hub.set_phone_live(live) + await asyncio.sleep(POLL_S) diff --git a/rack/backend/prrack/hub.py b/rack/backend/prrack/hub.py index 096e7fb..ba7eeac 100644 --- a/rack/backend/prrack/hub.py +++ b/rack/backend/prrack/hub.py @@ -46,6 +46,7 @@ class Hub: self._clients: Set[asyncio.Queue] = set() self._tasks: List[asyncio.Task] = [] self.tele_observers: List[Callable[[str, int, dict], None]] = [] + self.phone_live: Dict[tuple, dict] = {} # (board, rail) → 手机自己报的那一半(fleet.py),只显示 # ---- 生命周期 ------------------------------------------------------------ async def start(self): @@ -222,7 +223,16 @@ class Hub: return {"id": board, "name": meta[0]["name"] if meta else "", "location": meta[0]["location"] if meta else "", "online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"], "last_tele": b["last_tele"], "status": b["status"], "pd": self.pd(board), - "phones": [dict(p) for p in phones]} + "phones": [dict(p, live=self.phone_live.get((board, p["rail"]))) for p in phones]} + + def set_phone_live(self, live: Dict[tuple, dict]): + """fleet.py 每轮读完调一次。只把**变了的板子**推给网页(温度几秒才动一下,不值得每轮都推)。""" + old, self.phone_live = self.phone_live, live + for board in {k[0] for k in old} | {k[0] for k in live}: + a = {r: v for (b, r), v in old.items() if b == board} + b_ = {r: v for (b, r), v in live.items() if b == board} + if a != b_: + self.broadcast({"t": "phones_live", "board": board, "live": b_}) def boards(self) -> List[dict]: return [self.snapshot(b) for b in sorted(self.latest)] diff --git a/rack/backend/prrack/main.py b/rack/backend/prrack/main.py index b8a5c2c..463d9ce 100644 --- a/rack/backend/prrack/main.py +++ b/rack/backend/prrack/main.py @@ -22,6 +22,7 @@ from .api import create_app from .bus import MemBroker, MemBus, PahoBus from .db import Db, now_ms from .firmware import FwStore +from .fleet import FleetPoller from .hub import Hub from .recovery import RecoveryManager from .simboard import HUNG, SimBoard @@ -77,9 +78,20 @@ def build(args): await hub.stop() db.close() + # 手机自己报的那一半(在线 / 温度)从 AppTask 后端读(fleet.py)。没配令牌文件就不读,界面上那一行不出现。 + token = "" + if args.apptask_env and os.path.isfile(args.apptask_env): + token = read_env(args.apptask_env).get("APPTASK_TOKEN", "") + if token: + async def fleet_up(): + asyncio.ensure_future(FleetPoller(hub, args.apptask_url, token).run()) + else: + fleet_up = None + log.info("没有 AppTask 令牌(--apptask-env):不读手机温度 / 在线状态") + static = args.static if args.static and os.path.isdir(args.static) else None fw = FwStore(db, args.fw_dir, args.fw_base) if args.fw_dir else None - startup = [up] + startup = [up] + ([fleet_up] if fleet_up else []) if fw is not None: async def fw_gc(): async def loop(): @@ -120,6 +132,8 @@ def main(argv=None): help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA") ap.add_argument("--fw-base", default="http://home.example:7765/fw", help="板子下载固件用的 URL 前缀(板子只能走公网)") + ap.add_argument("--apptask-url", default="http://127.0.0.1:7736", help="AppTask 后端(读机架手机的在线状态 / 温度)") + ap.add_argument("--apptask-env", default=None, help="含 APPTASK_TOKEN= 的 env 文件(生产是 /root/apptask/apptask-py.env)") ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子") args = ap.parse_args(argv) if not ipaddress.ip_address(args.bind).is_loopback: diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py index 22ce37f..28ff92a 100644 --- a/rack/backend/tests/test_core.py +++ b/rack/backend/tests/test_core.py @@ -399,3 +399,42 @@ def test_energy_is_rolled_up_per_local_day_and_old_days_are_not_shrunk(tmp_path) db.energy_rollup(days_back=1, now=d2) assert db.energy(B)[0]["in_mws"] == 360000 assert len(db.energy(B, days=1)) == 1 and db.energy(B, days=1)[0]["day"] == "2026-10-05" + + +# ---- 手机自己报的那一半(fleet.py)--------------------------------------------- + +def test_fleet_phones_are_mapped_to_board_rails(tmp_path): + from prrack.fleet import map_phones + ph = lambda rail, **kw: dict({"rail": rail, "enabled": True, "online": True, "busy": False, "version": "0.8.0", + "temp_board_mc": 28000, "temp_cpu_mc": 31000, "temp_at": 1700000000}, **kw) + phones = [ph("B-1"), ph("B-3", busy=True), ph(""), ph("B-9"), ph("B-2", enabled=False), + ph("B-5", temp_board_mc=None, temp_cpu_mc=None, temp_at=None)] + one = map_phones(phones, [(B, "")]) + assert sorted(r for _, r in one) == [1, 3, 5] # 没填机架位 / 路号越界 / 停用的不摆 + assert one[(B, 1)]["temp_board_mc"] == 28000 and one[(B, 1)]["temp_at"] == 1700000000000 + assert one[(B, 3)]["busy"] is True and one[(B, 5)]["temp_board_mc"] is None + # 多块板:只认名字就是那个字母的;对不上不猜 + two = map_phones(phones + [ph("A-1")], [(B, "板 B"), ("aaaaaaaaaaaa", "")]) + assert sorted(two) == [(B, 1), (B, 3), (B, 5)] + + hub, sim, db = rig(tmp_path) + got = [] + + async def go(): + await hub.start() + sim.start() + sim.tick(1000) + await settle() + q = hub.subscribe_client() + hub.set_phone_live(one) + hub.set_phone_live(one) # 没变:不再推 + while not q.empty(): + got.append(q.get_nowait()) + snap = hub.snapshot(B) + assert [p["live"] and p["live"]["temp_cpu_mc"] for p in snap["phones"]] == [31000, None, 31000, None, None] + hub.set_phone_live({}) # AppTask 读不到:清空,界面那一行消失 + assert all(p["live"] is None for p in hub.snapshot(B)["phones"]) + + run(go()) + msgs = [m for m in got if m["t"] == "phones_live"] + assert len(msgs) == 1 and set(msgs[0]["live"]) == {1, 3, 5} diff --git a/rack/deploy/phonerail-rack.service b/rack/deploy/phonerail-rack.service index 6659559..c85da86 100644 --- a/rack/deploy/phonerail-rack.service +++ b/rack/deploy/phonerail-rack.service @@ -11,6 +11,7 @@ ExecStart=/root/phonerail-rack/venv/bin/python -m prrack.main \ --static /root/phonerail-rack/frontend/dist \ --mqtt-env /root/phonerail/mqtt.env \ --fw-dir /root/phonerail/fw \ + --apptask-env /root/apptask/apptask-py.env \ --bind 127.0.0.1 --port 7764 Restart=on-failure RestartSec=3 diff --git a/rack/frontend/src/components/Topology.vue b/rack/frontend/src/components/Topology.vue index cbfeaa0..7d99285 100644 --- a/rack/frontend/src/components/Topology.vue +++ b/rack/frontend/src/components/Topology.vue @@ -76,6 +76,10 @@ {{ W(x.r.mv, x.r.ua) }}W
{{ x.h.text }} {{ V(x.r.mv) }} V
+
+ 主板 {{ x.temp.board }} °C + CPU {{ x.temp.cpu }} °C +
空槽
@@ -128,11 +132,14 @@ import { computed, reactive, watch } from 'vue' import { ElMessage } from 'element-plus' import RailOps from './RailOps.vue' import { api } from '../api' -import { mA, V, W, railHealth, LEVEL_COLOR } from '../util' +import { mA, V, W, C, railHealth, LEVEL_COLOR } from '../util' const props = defineProps({ board: Object, sel: Number, recStates: Object }) defineEmits(['select']) +// 手机主板 / CPU 温度的提示线(只改颜色,不参与任何动作):50 °C 橙、60 °C 红 +const TEMP_WARN_MC = 50000 +const TEMP_BAD_MC = 60000 const LIVE_UA = 5000 const s = computed(() => props.board?.status) const online = computed(() => !!props.board?.online && !props.board?.stale && !!s.value) @@ -160,11 +167,19 @@ const rails = computed(() => [1, 2, 3, 4, 5].map(i => { const live = online.value && pr && r.en && Math.abs(r.ua ?? 0) > LIVE_UA const fault = pr && r.flt && !(r.chg > 0) const phone = props.board?.phones?.find(x => x.rail === i) + // 手机主板温度:机架代理(≥0.8.0)每次轮询报的 quiet_therm 和最热的 CPU 核;手机不在线 / 老代理 ⇒ 这一行不出现 + const lv = phone?.live + const tmax = Math.max(lv?.temp_board_mc ?? -1e9, lv?.temp_cpu_mc ?? -1e9) + const temp = lv?.online && tmax > -1e9 ? { + board: C(lv.temp_board_mc), cpu: C(lv.temp_cpu_mc), + color: tmax >= TEMP_BAD_MC ? LEVEL_COLOR.bad : tmax >= TEMP_WARN_MC ? LEVEL_COLOR.warn : 'var(--ink-2)', + tip: `手机自己报的(机架代理 ${lv.agent},机架位 ${lv.fleet_rail}):主板热敏 quiet_therm 和最热的 CPU 核,约 25 秒更新一次`, + } : null const w1 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 母线 → 降压(12 V 侧) const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机 return { - i, r, h, pr, fault, w1, w2, w3, label: phone?.label || '', + i, r, h, pr, fault, w1, w2, w3, temp, label: phone?.label || '', ctl: online.value && pr, // 图上元件可点(调压 / 上下电 / USB);序列类动作另看执行器空闲,在 RailOps 里 rev: live && (r.ua ?? 0) < -LIVE_UA, f: flow(r?.ua, p), @@ -282,6 +297,8 @@ const vbN = computed(() => rails.value.filter(x => x.pr && x.r.vb).length) .pv .pw { margin-left: auto; font-size: 14px; color: var(--ink-2); } .ps { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ps .muted { font-weight: 400; font-size: 11px; } +.pt { display: flex; gap: 10px; font-size: 11px; white-space: nowrap; overflow: hidden; margin-top: 1px; } +.pt b { font-weight: 600; } .phone.bad { border-color: var(--st-bad); } .phone.busy { border-color: var(--st-busy); } .phone.warn { border-color: var(--st-warn); } diff --git a/rack/frontend/src/store.js b/rack/frontend/src/store.js index 44791c3..f98e52f 100644 --- a/rack/frontend/src/store.js +++ b/rack/frontend/src/store.js @@ -51,6 +51,12 @@ function handle(m) { case 'board': state.boards[m.board.id] = m.board break + case 'phones_live': { + // 手机自己报的那一半(在线 / 温度,后端 fleet.py);live 里没有的路 = 读不到,清掉 + const b = state.boards[m.board] + if (b) for (const p of b.phones || []) p.live = m.live[p.rail] ?? null + break + } case 'tele': { const b = state.boards[m.board] if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true }