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