手机主板温度:代理 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._clients: Set[asyncio.Queue] = set()
self._tasks: List[asyncio.Task] = [] self._tasks: List[asyncio.Task] = []
self.tele_observers: List[Callable[[str, int, dict], None]] = [] self.tele_observers: List[Callable[[str, int, dict], None]] = []
self.phone_live: Dict[tuple, dict] = {} # (board, rail) → 手机自己报的那一半(fleet.py),只显示
# ---- 生命周期 ------------------------------------------------------------ # ---- 生命周期 ------------------------------------------------------------
async def start(self): 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 "", 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"], "online": b["online"], "stale": stale, "fw": b["fw"], "ip": b["ip"],
"last_tele": b["last_tele"], "status": b["status"], "pd": self.pd(board), "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]: def boards(self) -> List[dict]:
return [self.snapshot(b) for b in sorted(self.latest)] 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 .bus import MemBroker, MemBus, PahoBus
from .db import Db, now_ms from .db import Db, now_ms
from .firmware import FwStore from .firmware import FwStore
from .fleet import FleetPoller
from .hub import Hub from .hub import Hub
from .recovery import RecoveryManager from .recovery import RecoveryManager
from .simboard import HUNG, SimBoard from .simboard import HUNG, SimBoard
@@ -77,9 +78,20 @@ def build(args):
await hub.stop() await hub.stop()
db.close() 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 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 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: if fw is not None:
async def fw_gc(): async def fw_gc():
async def loop(): async def loop():
@@ -120,6 +132,8 @@ def main(argv=None):
help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA") help="固件下载目录(nginx 7765 的 /fw/,生产是 /root/phonerail/fw);不给就没有 OTA")
ap.add_argument("--fw-base", default="http://home.example:7765/fw", ap.add_argument("--fw-base", default="http://home.example:7765/fw",
help="板子下载固件用的 URL 前缀(板子只能走公网)") 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 块模拟板子") ap.add_argument("--sim", type=int, default=0, help="不连 broker,起 N 块模拟板子")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if not ipaddress.ip_address(args.bind).is_loopback: 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) db.energy_rollup(days_back=1, now=d2)
assert db.energy(B)[0]["in_mws"] == 360000 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" 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 \ --static /root/phonerail-rack/frontend/dist \
--mqtt-env /root/phonerail/mqtt.env \ --mqtt-env /root/phonerail/mqtt.env \
--fw-dir /root/phonerail/fw \ --fw-dir /root/phonerail/fw \
--apptask-env /root/apptask/apptask-py.env \
--bind 127.0.0.1 --port 7764 --bind 127.0.0.1 --port 7764
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
+19 -2
查看文件
@@ -76,6 +76,10 @@
<span class="readout pw">{{ W(x.r.mv, x.r.ua) }}<small>W</small></span></div> <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)' }"> <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> {{ 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> </template>
<div class="ps muted" v-else>空槽</div> <div class="ps muted" v-else>空槽</div>
</div> </div>
@@ -128,11 +132,14 @@ import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import RailOps from './RailOps.vue' import RailOps from './RailOps.vue'
import { api } from '../api' 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 }) const props = defineProps({ board: Object, sel: Number, recStates: Object })
defineEmits(['select']) defineEmits(['select'])
// 手机主板 / CPU 温度的提示线(只改颜色,不参与任何动作):50 °C 橙、60 °C 红
const TEMP_WARN_MC = 50000
const TEMP_BAD_MC = 60000
const LIVE_UA = 5000 const LIVE_UA = 5000
const s = computed(() => props.board?.status) const s = computed(() => props.board?.status)
const online = computed(() => !!props.board?.online && !props.board?.stale && !!s.value) 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 live = online.value && pr && r.en && Math.abs(r.ua ?? 0) > LIVE_UA
const fault = pr && r.flt && !(r.chg > 0) const fault = pr && r.flt && !(r.chg > 0)
const phone = props.board?.phones?.find(x => x.rail === i) 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 w1 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 母线 → 降压(12 V 侧)
const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse
const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机 const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机
return { 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 里 ctl: online.value && pr, // 图上元件可点(调压 / 上下电 / USB);序列类动作另看执行器空闲,在 RailOps 里
rev: live && (r.ua ?? 0) < -LIVE_UA, rev: live && (r.ua ?? 0) < -LIVE_UA,
f: flow(r?.ua, p), 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); } .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 { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps .muted { font-weight: 400; font-size: 11px; } .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.bad { border-color: var(--st-bad); }
.phone.busy { border-color: var(--st-busy); } .phone.busy { border-color: var(--st-busy); }
.phone.warn { border-color: var(--st-warn); } .phone.warn { border-color: var(--st-warn); }
+6
查看文件
@@ -51,6 +51,12 @@ function handle(m) {
case 'board': case 'board':
state.boards[m.board.id] = m.board state.boards[m.board.id] = m.board
break 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': { case 'tele': {
const b = state.boards[m.board] const b = state.boards[m.board]
if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true } if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true }