需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。
配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。
- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。
顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。
修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。
自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
220 lines
12 KiB
Python
220 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("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
|