需求:大屏和监控页都要能看到每台设备的电量,并且"低于多少电量就发通知"。
数据从哪来:`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)、两份前端各自语法检查 + 渲染用例。
230 lines
12 KiB
Python
230 lines
12 KiB
Python
"""通知事件目录(**唯一真相**)。
|
||
|
||
约定:
|
||
- **key 点分层**:`模块.对象.动作`(如 `task.device.failed`),支持通配订阅
|
||
(`task.*`、`device.*`、`*`),匹配规则见 `match()`。
|
||
- **登记 ≠ 会发**:所有事件 `default` 一律 False —— 用户在「系统 → 通知」里
|
||
按 webhook 勾选才会推送。`recommend` 只用于界面高亮"建议开启",不改默认值。
|
||
- **聚合**:`agg_window` 秒内的同 `(hook, event, agg_key 取值)` 事件合并成一条
|
||
(保留前几个样本);`agg_window=0` = 不聚合,立即发(低频高危事件用它)。
|
||
- **fields**:该事件保证携带的上下文字段,供前端画字段表、也是模板占位符白名单。
|
||
|
||
新增事件:在 `EVENTS` 里加一条 + 在触发点调 `notifier.notify(key, **fields)`,
|
||
并同步 `doc/NOTIFY.md` 的事件表(见 doc/README.md 的文档同步红线)。
|
||
"""
|
||
from collections import namedtuple
|
||
|
||
# key, label, category, default, recommend, agg_window, agg_key, fields, desc
|
||
EventDef = namedtuple(
|
||
"EventDef",
|
||
"key label category default recommend agg_window agg_key fields desc")
|
||
|
||
|
||
def _e(key, label, category, fields, desc="", agg_window=30, agg_key=None,
|
||
recommend=False):
|
||
"""构造一条事件定义(default 恒为 False:登记不等于推送)。"""
|
||
return EventDef(key, label, category, False, recommend, agg_window,
|
||
agg_key, tuple(fields), desc)
|
||
|
||
|
||
# 聚合键:按"任务批次"合并(同一次任务的多台设备结果合成一条)
|
||
_BY_JOB = ("job_id",)
|
||
# 按"设备"合并(一台设备的重复抖动合成一条)
|
||
_BY_SERIAL = ("serial",)
|
||
|
||
EVENTS = [
|
||
# ---------------- 任务批次 ----------------
|
||
_e("task.batch.started", "任务批次开始", "任务批次",
|
||
["job_id", "job_name", "task_type", "device_count", "serials_preview"],
|
||
"一次任务开始铺开到 N 台设备", agg_window=0, recommend=True),
|
||
_e("task.batch.finished", "任务批次结束", "任务批次",
|
||
["job_id", "job_name", "total", "success", "failed", "stopped", "skipped",
|
||
"failed_devices", "duration_s"],
|
||
"整批跑完(成功/失败/停止台数 + 耗时;有失败时列出失败设备与型号)",
|
||
agg_window=0, recommend=True),
|
||
_e("task.batch.no_device", "任务无可用设备", "任务批次",
|
||
["job_id", "job_name", "target"],
|
||
"触发时一台可用设备都没有(任务空跑)", agg_window=0, recommend=True),
|
||
_e("task.batch.unknown_type", "任务类型不存在", "任务批次",
|
||
["job_id", "job_name", "task_type"],
|
||
"任务类型已从代码里删掉,任务永远不会执行", agg_window=0, recommend=True),
|
||
_e("task.cron.stopped", "定时停止任务", "任务批次",
|
||
["job_id", "job_name", "stopped_count", "serials"],
|
||
"cron_stop 到点,停掉了正在跑的设备", agg_window=0),
|
||
|
||
# ---------------- 任务 · 单设备(权威结论点) ----------------
|
||
_e("task.device.success", "设备任务成功", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "attempt", "duration_s"],
|
||
"某台设备上的任务最终成功。**只在目标设备只有 1 台时发送**——多设备批次看"
|
||
"「任务批次结束」就够了,13 台设备就是 13 条刷屏", agg_key=_BY_JOB),
|
||
|
||
_e("task.device.failed", "设备任务失败", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "attempts",
|
||
"cause", "msg"],
|
||
"某台设备上的任务最终失败(带真实原因与型号)", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.offline", "设备离线放弃", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "error"],
|
||
"设备离线,不重试直接失败", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.error", "单次尝试异常", "任务·单设备",
|
||
["serial", "device_name", "model", "job_name", "attempt", "error"],
|
||
"某次尝试抛异常(后面还会重试,噪音较大)", agg_key=_BY_JOB),
|
||
_e("task.device.retry", "设备任务重试", "任务·单设备",
|
||
["serial", "device_name", "job_name", "attempt", "next_attempt", "delay_s", "reason"],
|
||
"即将重试", agg_key=_BY_JOB),
|
||
_e("task.device.stopped", "设备任务被停止", "任务·单设备",
|
||
["serial", "device_name", "model", "job_name", "attempt", "phase"],
|
||
"用户手动停止 / cron 停止", agg_key=_BY_JOB),
|
||
_e("task.device.preempted", "设备被抢占", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id", "preempted_job_name"],
|
||
"本任务抢占了该设备上正在跑的其他任务", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.preempt_timeout", "抢占超时跳过", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id"],
|
||
"等被抢占任务退出超时,本设备放弃执行", agg_window=0, recommend=True),
|
||
_e("task.device.released", "抢占结束归还", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id", "returned", "reason"],
|
||
"抢占结束时重新拉起被抢占的任务", agg_key=_BY_JOB),
|
||
|
||
# ---------------- Worker 状态机(单次 attempt 级,默认关) ----------------
|
||
_e("worker.connected", "设备已连接", "Worker",
|
||
["serial", "model", "remote_adb_url"],
|
||
"worker 连上设备(单次尝试级,噪音大)"),
|
||
_e("worker.attempt.done", "单次执行完成", "Worker",
|
||
["serial", "task_type", "duration_s", "max_duration_hit"],
|
||
"单次 attempt 正常跑完(不代表任务最终成功)"),
|
||
_e("worker.attempt.error", "单次执行出错", "Worker",
|
||
["serial", "task_type", "error", "transient"],
|
||
"单次 attempt 抛异常"),
|
||
|
||
# ---------------- 业务(任务内容) ----------------
|
||
_e("task.patrol.hit", "任务巡检命中", "业务",
|
||
["title", "message", "patrol_name", "check_label", "detail", "action_label",
|
||
"job_id", "job_name", "serial", "device_name", "model"],
|
||
"任务里配置的「公共巡检」命中(熄屏/元素/前台…),已执行动作——"
|
||
"标题正文可在巡检项里自己写,支持 {device}/{job}/{time}/{app}/{screen}",
|
||
agg_window=0, recommend=True),
|
||
_e("task.notify.custom", "任务自定义通知", "业务",
|
||
["title", "message", "job_id", "job_name", "serial", "device_name", "model"],
|
||
"任务里的「发通知」步骤被触发——标题与正文由任务自己写,支持 "
|
||
"{device}/{serial}/{job}/{time}/{app}/{screen} 占位符", agg_window=0,
|
||
recommend=True),
|
||
_e("task.selector.invalid", "选择器连续失效", "业务",
|
||
["serial", "device_name", "selector", "miss_count"],
|
||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||
recommend=True),
|
||
|
||
# ---------------- 设备 ----------------
|
||
_e("device.online", "设备恢复在线", "设备",
|
||
["serials", "count"],
|
||
"原本断联的设备又能连上了", agg_window=0, recommend=True),
|
||
_e("device.offline", "设备断联", "设备",
|
||
["serials", "count", "devices"],
|
||
"设备池里的设备连不上了", agg_window=0, recommend=True),
|
||
_e("device.discovered", "发现新设备", "设备",
|
||
["serials", "count"],
|
||
"扫描到尚未在池内的设备(待认领)", agg_window=0),
|
||
_e("device.claimed", "设备自动认领", "设备",
|
||
["pairs", "count"],
|
||
"指纹匹配成功,自动把旧记录迁到新地址", agg_window=0),
|
||
_e("device.heartbeat_timeout", "心跳超时", "设备",
|
||
["serial", "device_name", "timeout_s", "task_job", "model"],
|
||
"设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True),
|
||
_e("device.battery.low", "设备电量低", "设备",
|
||
["serial", "device_name", "model", "battery", "charging", "threshold"],
|
||
"设备电量掉到阈值以下(默认 20%,可在「工具 → 设备发现 → 电量监控」改)。"
|
||
"充电中的设备不报——插着充电器说明正在补电;"
|
||
"「插着却没充电」(劣质线/温控/满电停充)仍会报,那正是最该知道的情况",
|
||
agg_window=0, agg_key=_BY_SERIAL, recommend=True),
|
||
_e("device.battery.recovered", "设备电量已恢复", "设备",
|
||
["serial", "device_name", "model", "battery", "charging", "threshold"],
|
||
"低电量设备重新充上电,或电量回升到阈值以上(含恢复迟滞)", agg_window=0,
|
||
agg_key=_BY_SERIAL),
|
||
|
||
# ---------------- 应用安装 ----------------
|
||
_e("apk.install.started", "应用安装开始", "安装",
|
||
["apk_id", "apk_name", "package_name", "total"],
|
||
"开始往 N 台设备推装", agg_window=0),
|
||
_e("apk.install.finished", "应用安装完成", "安装",
|
||
["apk_name", "success", "failed", "skipped", "total", "failed_items"],
|
||
"批量安装结束(含失败台数与原因)", agg_window=0, recommend=True),
|
||
|
||
# ---------------- 系统 ----------------
|
||
_e("system.backup.exported", "备份已导出", "系统",
|
||
["filename", "size", "tables", "include_apk", "user"],
|
||
"有人导出了整库备份", agg_window=0, recommend=True),
|
||
_e("system.backup.imported", "备份导入已挂起", "系统",
|
||
["token_prefix", "env_label", "force"],
|
||
"上传了备份并确认导入(重启后生效)", agg_window=0),
|
||
_e("system.backup.restored", "备份已恢复", "系统",
|
||
["applied_rows", "schema_version"],
|
||
"启动时应用了待恢复的备份(所以这条是重启后才发)", agg_window=0,
|
||
recommend=True),
|
||
_e("system.backup.restore_failed", "备份恢复失败", "系统",
|
||
["error", "fail_dir"],
|
||
"恢复校验没过,已搁置(数据未被改动)", agg_window=0, recommend=True),
|
||
_e("service.started", "服务已启动", "系统",
|
||
["version", "env", "db_target", "device_count", "job_count"],
|
||
"平台进程起来了", agg_window=0, recommend=True),
|
||
_e("service.stopping", "服务正在停止", "系统",
|
||
["uptime_s"],
|
||
"平台进程收到退出信号", agg_window=0),
|
||
_e("user.login", "用户登录", "系统",
|
||
["username"],
|
||
"有人登录了后台", agg_window=0),
|
||
|
||
# ---------------- AI ----------------
|
||
_e("ai.audit.finished", "经验巡检完成", "AI",
|
||
["reviewed", "suggested", "summary"],
|
||
"每日经验库巡检跑完(或手动触发)", agg_window=0, recommend=True),
|
||
_e("ai.audit.failed", "经验巡检异常", "AI",
|
||
["error"],
|
||
"巡检过程中报错", agg_window=0),
|
||
_e("ai.audit.skipped", "经验巡检跳过", "AI",
|
||
["reason"],
|
||
"没配模型 Key 等原因跳过巡检", agg_window=0),
|
||
|
||
# ---------------- 其它 ----------------
|
||
_e("notify.test", "测试通知", "其它",
|
||
["hook_name", "operator"],
|
||
"「发送测试」按钮专用", agg_window=0),
|
||
]
|
||
|
||
BY_KEY = {e.key: e for e in EVENTS}
|
||
|
||
# 界面上的分组顺序
|
||
CATEGORIES = ("任务批次", "任务·单设备", "业务", "设备", "安装", "系统", "AI",
|
||
"Worker", "其它")
|
||
|
||
|
||
def get(key):
|
||
"""按 key 取事件定义;未知 key 返回 None(未知事件会被 notify 丢弃)。"""
|
||
return BY_KEY.get(key)
|
||
|
||
|
||
def match(pattern, key):
|
||
"""事件订阅匹配。
|
||
|
||
- `*` → 全部
|
||
- `task.*` → task 下所有(含 task.device.failed)
|
||
- `task.device.*` → 任意层级前缀匹配
|
||
- `task.device.failed` → 精确
|
||
"""
|
||
if not pattern:
|
||
return False
|
||
if pattern == "*" or pattern == key:
|
||
return True
|
||
if pattern.endswith(".*"):
|
||
return key.startswith(pattern[:-1]) # "task.*" → "task."
|
||
return False
|
||
|
||
|
||
def list_events():
|
||
"""给接口/前端用的事件目录(按 CATEGORIES 排序)。"""
|
||
order = {c: i for i, c in enumerate(CATEGORIES)}
|
||
rows = [{"key": e.key, "label": e.label, "category": e.category,
|
||
"default": e.default, "recommend": e.recommend,
|
||
"agg_window": e.agg_window, "agg_key": list(e.agg_key or ()),
|
||
"fields": list(e.fields), "desc": e.desc}
|
||
for e in EVENTS]
|
||
rows.sort(key=lambda r: (order.get(r["category"], 99), r["key"]))
|
||
return rows
|