diff --git a/rack/README.md b/rack/README.md index e0f72c5..5617781 100644 --- a/rack/README.md +++ b/rack/README.md @@ -90,7 +90,7 @@ bash rack/deploy/deploy.sh # 构建 → 传 → 服务器上 Python 3.8 ## 已知限制 / 下一步 -- **自动恢复阈值没有实测依据**(平线 5 mA / 过低 30 mA / 窗口 5 分钟)。插上刀片和手机后先看几天曲线再定,默认逐台关闭。 +- **自动恢复阈值 2026-10-06 按板 B 实测定过一次**(平线:p95 − p05 < 30 mA 且中位数 ≥ 100 mA / 过低:窗内最大 < 30 mA / 窗口 5 分钟;依据见 `recovery.py` 模块头)。样本只有一次真卡死 + 一晚睡眠数据,**还没开过一次自动恢复**,默认逐台关闭。 - **adb 探活**:接口 `recovery.Prober` 已留,服务器目前够不着手机(机架在 192.168.8.x 的 NAT 后面)。 - 前端 Element Plus 是整包引入(gzip ≈335 KB),手机首屏偏慢;改成按需引入可以再砍一半。 - 没有细分权限:门户授权了这个应用的人都能操作;审计记着是谁。 diff --git a/rack/backend/prrack/recovery.py b/rack/backend/prrack/recovery.py index 1850234..90a55c0 100644 --- a/rack/backend/prrack/recovery.py +++ b/rack/backend/prrack/recovery.py @@ -1,12 +1,28 @@ """自动崩溃恢复(需求 §4.1 两级重启:先软后硬),2026-09-27 定为「先只看电流」。 服务器够不到手机的 adb(手机在机架那边的 NAT 后面),所以这一版的判据只来自板子遥测: - · FLAT:窗口内电流几乎不变(max − min < flat_ua)且不低 —— 卡死的手机常见的样子; - · LOW :窗口内电流一直低于 low_ua —— 关机 / 掉到待机,却本该在跑。 + · 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 停手、告警,要人来复位 —— @@ -33,8 +49,9 @@ IDLE, SOFT, HARD, GAVE_UP = "idle", "soft", "hard", "gave_up" @dataclass class Params: window_s: int = 300 # 判定窗口 - flat_ua: int = 5000 # 窗口内电流摆幅小于它 = 平线(5 mA) - low_ua: int = 30000 # 窗口内电流一直低于它 = 像关机(30 mA) + 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) @@ -78,10 +95,13 @@ class Detector: def verdict(self, now: int) -> Optional[str]: if not self.full(now): return None - vals = [u for _, u in self.s] - if max(vals) < self.p.low_ua: + vals = sorted(u for _, u in self.s) + n = len(vals) + if vals[-1] < self.p.low_ua: return "low" - if max(vals) - min(vals) < self.p.flat_ua: + # 用分位数而不是 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 diff --git a/rack/backend/tests/test_api.py b/rack/backend/tests/test_api.py index 89a8e45..6837c01 100644 --- a/rack/backend/tests/test_api.py +++ b/rack/backend/tests/test_api.py @@ -90,7 +90,7 @@ def test_phone_settings_toggle_auto_recovery(env): rec = c.get("/api/recovery").json() assert rec["params"]["window_s"] == 300 r = c.put("/api/recovery/params", json={"window_s": 120, "flat_ua": -5, "bogus": 1}) - assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 5000 # 负数不收 + assert r.json()["window_s"] == 120 and r.json()["flat_ua"] == 30000 # 负数不收 def test_history_after_telemetry(env): diff --git a/rack/backend/tests/test_core.py b/rack/backend/tests/test_core.py index ff470be..fb8d5a5 100644 --- a/rack/backend/tests/test_core.py +++ b/rack/backend/tests/test_core.py @@ -237,6 +237,34 @@ def test_low_current_is_detected_and_normal_is_not(): assert feed(ph2, 0, 61, lambda s: 800 + s % 3)[-1][2][0] == "low" +# 2026-10-06 按实测改的平线判据(recovery.py 模块头的表):下面三条钉的是「改之前判错」的那几种形状。 + +def test_flat_tolerates_occasional_spikes(): + """真卡死(10-04 第 1 路):平在 ~520 mA,偶尔冒一个 +150 mA 的尖峰。max − min 判据一个窗口都抓不到。""" + ph = PhoneRecovery(Params(window_s=60, settle_s=30)) + r = feed(ph, 0, 61, lambda s: 520000 + (s % 3) * 4000 + (150000 if s % 25 == 0 else 0)) + assert r and r[-1][1] == SOFT and r[-1][2][0] == "flat" + + +def test_sleeping_phone_is_healthy(): + """空闲睡着:平在 ~10 mA,每 25 s 被长轮询叫醒一秒(~250 mA)。既不是平线(低)也不是过低(有尖峰)。""" + ph = PhoneRecovery(Params(window_s=300, settle_s=30)) + assert feed(ph, 0, 900, lambda s: 250000 if s % 25 == 0 else 9000 + s % 4 * 1000) == [] + + +def test_asleep_and_never_waking_is_low(): + """睡下去之后再也没醒(代理不轮询了):5 分钟里一个尖峰都没有 ⇒ 过低。""" + ph = PhoneRecovery(Params(window_s=300, settle_s=30)) + r = feed(ph, 0, 301, lambda s: 9000 + s % 4 * 1000) + assert r and r[-1][2][0] == "low" + + +def test_flat_but_low_level_is_not_flat(): + """平在 60 mA(低于 flat_min_ua、高于 low_ua):不下结论 —— 没见过卡死的手机掉到这么低。""" + ph = PhoneRecovery(Params(window_s=60, settle_s=30)) + assert feed(ph, 0, 200, lambda s: 60000 + s % 3 * 1000) == [] + + def test_rate_limit_gives_up(): ph = PhoneRecovery(Params(window_s=60, settle_s=30, max_per_hour=2)) t = 0 diff --git a/rack/frontend/src/pages/Recovery.vue b/rack/frontend/src/pages/Recovery.vue index 5eab07a..63b4e68 100644 --- a/rack/frontend/src/pages/Recovery.vue +++ b/rack/frontend/src/pages/Recovery.vue @@ -1,14 +1,15 @@