手机主板温度:代理 0.8.0 上报,rack 拓扑图显示
- 机架代理 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 <[email protected]>
这个提交包含在:
@@ -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)
|
||||
+11
-1
@@ -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)]
|
||||
|
||||
+15
-1
@@ -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:
|
||||
|
||||
在新工单中引用
屏蔽一个用户