"""自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。 服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测: · FLAT:窗口内电流平在一个不低的水平上 —— **中位数 ≥ flat_min_ua 且 p95 − p05 < flat_ua**。卡死的手机就是这样; · LOW :窗口内电流一直低于 low_ua —— 关机,或者睡下去之后再也没醒过(代理不轮询了)。 adb 探活留了接口(`Prober`),手机能从服务器够到(比如经 frp)时再接上。 阈值的依据(2026-10-06,板 B 五台小米8 的 1 Hz 遥测,300 s 窗口每 30 s 滑一次): | 状态 | 窗口数 | 中位数 | p95 − p05 | max − min | 窗内最大值 | |---|---|---|---|---|---| | 真卡死(10-04 第 1 路,平在 520 mA 五个半小时) | 590 | 518~524 mA | 5~20 mA | 17~189 mA | — | | 健康、亮屏常醒(10-04 17:00 ~ 10-05 20:00,第 1~4 路) | 4×3187 | ≥ 213 mA | ≥ 42 mA | ≥ 141 mA | — | | 健康、空闲睡着(10-05 23:15 起 5.8 h,五路) | 5×665 | 7~12 mA | ≥ 8 mA | ≥ 213 mA | ≥ 221 mA | · 平线原来用 max − min < 5 mA:真卡死时偶尔冒一个尖峰,那 590 个窗口**一个都判不出来**。换成对尖峰不敏感的 p95 − p05,卡死 ≤ 20 mA、健康亮屏 ≥ 42 mA,取 30 mA。 · 平线还要「不低」(中位数 ≥ 100 mA):空闲睡着的手机 95 % 的时间都平在 ~10 mA(ROM 文档 §4.18),那是正常的。 卡死的手机 CPU 是醒着的(见过 254 / 258 / 365 / 520 mA),不会掉到那么低。 · 过低仍是「窗内最大值 < 30 mA」:睡着的手机每 25 s 被代理的长轮询叫醒一次,窗内最大值都在 221 mA 以上; 5 分钟里一个尖峰都没有 = 关机了,或者睡死了。 ⚠️ 样本只有**一次**真卡死和一晚上的睡眠数据;过低那一条没有真实的关机样本。还没开过一次自动恢复。 ⚠️ 熄屏但 WiFi 省电没还回去(旧 ROM / rc 触发器没生效)时会平在 ~178 mA,会被判成平线 —— 那本身也是该处理的异常。 所以自动恢复仍然**默认关闭、逐台打开**,而且: · 只在「这台手机开了自动恢复 + 板子在线 + 刀片在位 + 这一路有电 + 执行器空闲」时才判定, 任何一条不满足就把积累的历史清掉 —— 失联不动作(§10.2); · 每小时最多 `max_per_hour` 次,超了就 GAVE_UP 停手、告警,要人来复位 —— 一个误判的阈值最坏的后果是「把一台正常的手机每 5 分钟重启一次」,这条闸就是防它的。 状态机:IDLE ──判定──▶ SOFT(长按电源 10 s)──等 settle──▶ 观察一个窗口 ▲ │ 仍然判定 │ 没判定 └──── 恢复 ◀──────────┼──────────────────────────────┘ ▼ HARD(断电重启)──仍然判定──▶ GAVE_UP(停手,等人) """ import asyncio import collections import json import logging from dataclasses import asdict, dataclass from typing import Callable, Deque, Dict, List, Optional, Tuple log = logging.getLogger("prrack.recovery") IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up" @dataclass class Params: window_s: int = 300 # 判定窗口 flat_ua: int = 30000 # 平线:窗口内 p95 − p05 小于它(30 mA)…… flat_min_ua: int = 100000 # ……并且中位数不低于它(100 mA):睡着的手机平在 ~10 mA 是正常的 low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机 / 睡死(30 mA) settle_s: int = 120 # 动作之后先等它重启完,这段不看 max_per_hour: int = 3 # 每台每小时最多动作几次 soft_ms: int = 10000 # 一级:长按电源键时长(需求 §4.1 ≈10 s) @staticmethod def load(text: str) -> "Params": p = Params() try: for k, v in json.loads(text or "{}").items(): if hasattr(p, k) and isinstance(v, int) and v > 0: setattr(p, k, v) except ValueError: pass return p class Detector: """一台手机的电流窗口。样本 1 条/s;窗口没攒满(覆盖不到 window_s 的 90 %)一律不下结论。""" def __init__(self, p: Params): self.p = p self.s: Deque[Tuple[int, int]] = collections.deque() def reset(self): self.s.clear() def add(self, ts: int, ua: int): if self.s and ts <= self.s[-1][0]: return # 时间倒退 / 重复:丢掉(一条迟到的遥测不能把窗口搅乱) self.s.append((ts, ua)) horizon = ts - self.p.window_s * 1000 while self.s and self.s[0][0] < horizon: self.s.popleft() def full(self, now: int) -> bool: if not self.s: return False covered = self.s[-1][0] - self.s[0][0] return covered >= self.p.window_s * 900 and len(self.s) >= self.p.window_s * 0.8 def verdict(self, now: int) -> Optional[str]: if not self.full(now): return None vals = sorted(u for _, u in self.s) n = len(vals) if vals[-1] < self.p.low_ua: return "low" # 用分位数而不是 max − min:卡死时偶发的一个尖峰不该把平线判据废掉(依据见模块头) spread = vals[n * 95 // 100] - vals[n * 5 // 100] if vals[n // 2] >= self.p.flat_min_ua and spread < self.p.flat_ua: return "flat" return None Action = Tuple[str, dict] # (原因, 要发的命令) class PhoneRecovery: """一台手机的状态机。纯逻辑:喂样本、吐动作,时钟由调用方给。""" def __init__(self, p: Params): self.p = p self.det = Detector(p) self.state = IDLE self.since = 0 # 进入当前状态的时刻 self.reason = "" self.attempts: List[int] = [] def eligible_reset(self): self.det.reset() def manual_reset(self): self.state, self.reason, self.attempts = IDLE, "", [] self.det.reset() def feed(self, now: int, ua: int, rail: int) -> Tuple[Optional[str], Optional[Action]]: """返回 (新状态或 None, 动作或 None)。""" if self.state == GAVE_UP: return None, None if self.state in (SOFT, HARD) and now - self.since < self.p.settle_s * 1000: return None, None # 还在重启过程中,这段电流不代表任何东西 self.det.add(now, ua) v = self.det.verdict(now) if v is None: if self.state in (SOFT, HARD) and self.det.full(now): self.state, self.reason = IDLE, "recovered" return IDLE, None return None, None self.attempts = [t for t in self.attempts if now - t < 3600 * 1000] if len(self.attempts) >= self.p.max_per_hour or self.state == HARD: self.state, self.reason = GAVE_UP, ("rate_limit" if self.state != HARD else v) return GAVE_UP, None nxt = SOFT if self.state == IDLE else HARD self.state, self.since, self.reason = nxt, now, v self.attempts.append(now) self.det.reset() if nxt == SOFT: return SOFT, (v, {"cmd": "key", "rail": rail, "keys": "pwr", "ms": self.p.soft_ms}) return HARD, (v, {"cmd": "power_cycle", "rail": rail}) class Prober: """adb 探活的接口(预留)。返回 True 活着 / False 死了 / None 不知道(没配、够不着)。""" async def probe(self, addr: str) -> Optional[bool]: return None class RecoveryManager: """把状态机接到 Hub 上:订阅遥测,判定,经 hub.call 下发动作,记录并推送。""" def __init__(self, hub, clock: Callable[[], int]): self.hub = hub self.db = hub.db self.clock = clock self.params = Params.load(self.db.setting("recovery", "{}")) self.phones: Dict[Tuple[str, int], PhoneRecovery] = {} self.enabled: Dict[Tuple[str, int], bool] = {} self.reload() hub.tele_observers.append(self.on_tele) def reload(self): for r in self.db.q("SELECT board_id, rail, auto_recover FROM phones"): key = (r["board_id"], r["rail"]) was = self.enabled.get(key) self.enabled[key] = bool(r["auto_recover"]) if was and not self.enabled[key] and key in self.phones: self.phones[key].manual_reset() # 关掉 = 清状态(含 GAVE_UP) def set_params(self, p: Params): self.params = p self.db.set_setting("recovery", json.dumps(asdict(p))) for ph in self.phones.values(): ph.p = p ph.det.p = p def phone(self, board: str, rail: int) -> PhoneRecovery: key = (board, rail) if key not in self.phones: self.phones[key] = PhoneRecovery(self.params) return self.phones[key] def states(self) -> List[dict]: return [{"board": b, "rail": r, "state": ph.state, "reason": ph.reason, "since": ph.since, "attempts_1h": len([t for t in ph.attempts if self.clock() - t < 3600 * 1000]), "enabled": self.enabled.get((b, r), False)} for (b, r), ph in sorted(self.phones.items())] def reset(self, board: str, rail: int): self.phone(board, rail).manual_reset() self._log(board, rail, IDLE, "manual_reset") def on_tele(self, board: str, ts: int, st: dict): now = self.clock() for r in st.get("rails", []): rail = r.get("r") key = (board, rail) if not self.enabled.get(key): continue ph = self.phone(board, rail) ok = r.get("pr") and r.get("en") and r.get("st") == "idle" and isinstance(r.get("ua"), int) if not ok: ph.eligible_reset() continue state, action = ph.feed(now, r["ua"], rail) if state is not None: self._log(board, rail, state, ph.reason, action[1] if action else None) if action is not None: asyncio.ensure_future(self._act(board, rail, action[1])) async def _act(self, board: str, rail: int, cmd: dict): try: rsp = await self.hub.call(board, cmd, "auto-recovery") if not rsp.get("ok"): log.warning("自动恢复 %s#%d 的 %s 被板子拒了:%s", board, rail, cmd["cmd"], rsp) except Exception as e: # 超时也只记下来:失联不再加码 log.warning("自动恢复 %s#%d 下发失败:%s", board, rail, e) def _log(self, board: str, rail: int, state: str, reason: str, cmd: Optional[dict] = None): detail = json.dumps(cmd) if cmd else "" now = self.clock() rid = self.db.x("INSERT INTO recovery_log (ts, board_id, rail, state, reason, detail) " "VALUES (?,?,?,?,?,?)", (now, board, rail, state, reason, detail)) self.hub.broadcast({"t": "recovery", "row": {"id": rid, "ts": now, "board_id": board, "rail": rail, "state": state, "reason": reason, "detail": detail}})