feat(设备): 电量显示(大屏 + 监控页)+ 低电量 webhook 告警

需求:大屏和监控页都要能看到每台设备的电量,并且"低于多少电量就发通知"。

数据从哪来:`adb -s <serial> shell dumpsys battery`(**只读**,实测 0.2~0.35s/台,
11 台并发一轮 0.44s)。设备只要在 `adb devices` 里就说明 transport 现成,
**不需要 connect**——所以连空闲设备也能查。这是本模块敢每轮扫全量的前提,
也是这条只读路径与"空闲设备不主动 connect"红线(DEVELOPMENT.md §2 #3)的边界,
已写进文档免得后人误加 connect。

- core/device_battery.py(新):后台 daemon 线程 `device-battery`(照 device_discovery
  的 init_app/shutdown 模式),默认 60s 一轮,采「设备池 ∩ 在线」(与 list_ready 同口径,
  adb 可见但不归平台管的设备不上屏也不告警)。
  · 结果**只放内存**(不落库 → 不建表、不动备份覆盖清单),离线设备保留最后读数;
  · 读失败不清缓存:list_devices 失败时若照清会把整份缓存抹掉、大屏全体变「—」;
  · 配置存 `app_meta.device_battery`(照 step_defaults:出厂值 + 白名单 + 范围钳制 +
    只落与出厂值不同的字段),改阈值不用重启(线程每轮重读配置)。
- 告警:事件 `device.battery.low` / `device.battery.recovered`(默认关,去通知页勾订阅)。
  **按档位做状态沿**(0 正常 / 1 低 / 2 严重):掉档才报、严重再报一次、恢复报一次;
  迟滞 +5% 避免在阈值上下反复告警;同档位每 6h 重复提醒一次。
  "充电中"看的是 `status:` 行(2/5),**不是"插着电"**——所以"插着却没充电"
  (劣质线/温控/满电停充 status:4)仍会告警,那正是最该知道的情况(实测 fleet 里
  就有一台 8% 插着 AC 但 `Max charging current: 0`)。
- GET /api/status 每台设备多一个 `battery`:`{level,charging,at,tier}`。
  **tier 由后端算好**,前端只按它上色——阈值只在「工具 → 设备发现 → 电量监控」一处定义,
  免得 JS 里再判一遍、两边不一致。
- 前端:大屏卡片型号行右侧加电量徽标(绿/黄/红 + ⚡ 充电中,离线置灰),
  `cardHtml`/`renderGrid` 增量更新/脏检查白名单三处都改了(漏一处就不刷新);
  监控页设备表新增可排序的「电量」列(空表 colspan 11 顺带对齐了)。
- 接口:GET/POST /api/devices/battery[/settings] + POST /scan(需设备权限,
  与 /api/devices/discovery/* 同权限档)。

自测:单元 30 项(解析真机输出/档位/状态机/钳制,含 scale=255、无 status 行、
垃圾输出等边界)、集成 27 项(临时库自建设备池:缓存/通知/离线保留/删设备清理/
配置往返/阈值改动即时生效)、真实 11 台设备一轮 0.44s 全读到、API 端到端
(登录/权限/钳制/越界/REST 往返/页面 DOM)、两份前端各自语法检查 + 渲染用例。
This commit is contained in:
2026-09-24 08:37:42 +08:00
parent 773cfbb763
commit beb51637dd
16 changed files with 651 additions and 13 deletions
+26 -1
View File
@@ -54,7 +54,7 @@ daemon 线程。所以:
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` · `device.battery.low` · `.battery.recovered` |
| 安装 | `apk.install.started` · `.finished` |
| 系统 | `system.backup.exported` · `.imported` · `.restored` · `.restore_failed` · `service.started` · `.stopping` · `user.login` |
| AI | `ai.audit.finished` · `.failed` · `.skipped` |
@@ -106,6 +106,31 @@ daemon 线程。所以:
- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条
webhook 上勾 `task.notify.custom` / `task.patrol.hit`。
### 3.1 设备电量告警(`device.battery.low` / `.battery.recovered`)
采集与判定都在 `core/device_battery.py`(后台线程 `device-battery`,默认 60s 一轮,
`adb -s <serial> shell dumpsys battery` **只读**查询)。阈值配置在
**「工具 → 设备发现 → 电量监控」**,存在 `app_meta.device_battery`(见 [DATA_MODEL.md](DATA_MODEL.md) §5)。
阈值语义(**这是唯一判定处,前端只读后端算好的 `tier` 上色**):
| 档位 | 条件 | 通知 |
|---|---|---|
| `0` 正常 | `电量 >= 低电量阈值 + 5`(迟滞)或 充电中 | 从低/严重回到 0 → 发 `device.battery.recovered` |
| `1` 低 | `严重阈值 < 电量 <= 低电量阈值` | **掉到这一档才发** `device.battery.low`(`warning`) |
| `2` 严重 | `电量 <= 严重阈值` | 掉到这一档再发一次(`error`),阈值字段是严重阈值 |
- **迟滞 +5**:回升要到 `低电量阈值 + 5` 才算恢复。没有它,电量在阈值上下浮动
(充电器接触不良)会反复告警。
- **"充电中"看的是 `status:` 行**(`2` 充电中 / `5` 已充满),不是"插着电"。
所以**"插着却没充电"**(劣质线、温控停充、满电停充 `status:4`)**仍然会告警**——
那正是最该让人知道的情况。想连充电中一起报,把「充电中不告警」勾掉即可。
- **重复提醒**:同一档位持续不恢复时,每 **6 小时**再提醒一次(`core/device_battery.RE_NOTIFY_S`)。
- 只对**「设备池 ∩ 在线」**的设备告警:adb 里能看到但不归平台管的设备不吵人。
- 一周内改了阈值**不用重启**:采集线程每轮重读配置,档位下一轮就按新阈值算。
排查:`logs/core.log` 搜 `core.battery`(每轮一行「电量采集完成: N/M 台」)。
---
## 4. 配置