手机主板温度:代理 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]>
这个提交包含在:
zhou yang
2026-10-06 07:06:15 +08:00
共同撰写人 Claude Opus 5.5
父节点 580468e48e
当前提交 3bf4548c62
共修改 7 个文件,包含 175 行新增和 4 行删除
+84
查看文件
@@ -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:
+39
查看文件
@@ -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}
+1
查看文件
@@ -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
+19 -2
查看文件
@@ -76,6 +76,10 @@
<span class="readout pw">{{ W(x.r.mv, x.r.ua) }}<small>W</small></span></div>
<div class="ps" :style="{ color: loud(x.h.level) ? LEVEL_COLOR[x.h.level] : 'var(--muted)' }">
{{ x.h.text }}<span class="muted readout"> {{ V(x.r.mv) }} V</span></div>
<div class="pt" v-if="x.temp" :title="x.temp.tip">
<span :style="{ color: x.temp.color }">主板 <b class="readout">{{ x.temp.board }}</b> °C</span>
<span class="muted">CPU <span class="readout">{{ x.temp.cpu }}</span> °C</span>
</div>
</template>
<div class="ps muted" v-else>空槽</div>
</div>
@@ -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); }
+6
查看文件
@@ -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 }