diff --git a/README.md b/README.md index a0ad241..30298a5 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ | **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) | | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | -| **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 | +| **步骤编辑器** | 可视化拖拽编排 20 种步骤(含循环/条件/OCR/通知),可打包成"自定义动作"复用 | +| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | @@ -140,6 +141,7 @@ auto_control/ │ ├── system_backup.py # 数据备份导出/导入(重启生效) │ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录 │ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理) +│ ├── patrol.py # 任务级公共巡检(检查项/动作注册表,穿插执行) │ ├── tailscale_client.py # Tailscale API v2 客户端 │ ├── ssh_client.py # SSH 封装(当前无人调用,预留) │ ├── logger.py # 分文件日志(core/task/web/action) @@ -148,7 +150,7 @@ auto_control/ ├── tasks/ # 任务定义层 │ ├── base.py # BaseTask + _TASK_TYPES + register_task │ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型) -│ └── task.py # STEP_TYPES(18 种步骤)+ Worker + 执行器 +│ └── task.py # STEP_TYPES(20 种步骤)+ Worker + 执行器 │ ├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033) │ ├── mcp_server.py # 工具定义 + 平台登录 + 门控 @@ -246,7 +248,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el | Tab | 内容 | 可见性 | |-----|------|--------| | **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") | -| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | +| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器 / **公共巡检**)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | | **日志** | 子分栏:**文件日志**(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载筛选结果、自动刷新)、**步骤明细**(按设备/任务/结果/时间过滤、按运行归组的概览、导出 CSV) | 需"日志查看" | | **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 | | **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 | @@ -274,7 +276,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el `generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。 -**18 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): +**20 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): | 类别 | 步骤 | |------|------| @@ -396,7 +398,7 @@ read_log_text()`,`file` 参数走白名单,不接受任意路径。 | [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) | | [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) | | [doc/API.md](doc/API.md) | HTTP 接口全量说明 | -| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(18 种步骤、选择器、自定义动作、新增任务类型) | +| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(20 种步骤、公共巡检、选择器、自定义动作、新增任务类型) | | [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 | | [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) | | [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) | diff --git a/core/notifier.py b/core/notifier.py index 7f7a595..f49b5cf 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -310,10 +310,15 @@ _LEVEL_BY_HINT = ( ) -def _level_of(event_key): - for hints, lv in _LEVEL_BY_HINT: +def _level_of(event_key, fields=None): + """消息级别:**事件自己带的 `level` 优先**(步骤「发通知」/巡检都能自己选), + 否则按事件 key 猜(failed→error、success→success…),猜不到算 info。""" + lv = (fields or {}).get("level") + if lv in ("info", "success", "warning", "error"): + return lv + for hints, lv2 in _LEVEL_BY_HINT: if any(h in event_key for h in hints): - return lv + return lv2 return "info" @@ -331,6 +336,8 @@ _FIELD_LABELS = { "timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号", "apk_name": "应用", "apk_id": "应用ID", "package_name": "包名", "success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数", + "title": "标题", "message": "内容", "patrol_name": "巡检", + "check_label": "检查", "action_label": "动作", "detail": "结果", "stopped": "停止", "failed_devices": "失败设备", "failed_items": "失败明细", "filename": "文件", "size": "大小(字节)", "tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人", @@ -350,7 +357,9 @@ def _label(k): def _subject(ev, fields): - """标题主体:优先任务名,其次设备名/serial。""" + """标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。""" + if fields.get("title"): + return str(fields["title"]) for k in ("job_name", "apk_name", "hook_name"): if fields.get(k): return str(fields[k]) @@ -365,9 +374,11 @@ def build_message(ev, fields, hook=None): """把事件渲染成统一消息体(title/summary/fields/level/markdown)。""" fields = dict(fields or {}) merged = fields.pop("_merged", None) - level = _level_of(ev.key) + level = _level_of(ev.key, fields) subj = _subject(ev, fields) - title = ev.label + (f":{subj}" if subj else "") + # 「发通知」步骤自带标题时**直接用它**:用户写的是"抖音号1 掉线了", + # 前面再挂一个"任务自定义通知:"纯属噪音(其它事件照旧带事件名) + title = str(fields["title"]) if fields.get("title") else ev.label + (f":{subj}" if subj else "") if merged and merged.get("merged_count", 0) > 1: title = f"{ev.label}:{subj}" if subj else ev.label title += f"({merged['merged_count']} 次)" @@ -382,7 +393,7 @@ def build_message(ev, fields, hook=None): continue # 标题里已经写出来的主体(任务名/设备名)不再重复占一行: # "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音 - if k in ("job_name", "device_name", "apk_name", "hook_name") \ + if k in ("job_name", "device_name", "apk_name", "hook_name", "title") \ and str(v) == str(subj): continue # 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址 diff --git a/core/notify_events.py b/core/notify_events.py index b035bc8..5836231 100644 --- a/core/notify_events.py +++ b/core/notify_events.py @@ -96,6 +96,17 @@ EVENTS = [ "单次 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, diff --git a/core/patrol.py b/core/patrol.py new file mode 100644 index 0000000..990d250 --- /dev/null +++ b/core/patrol.py @@ -0,0 +1,110 @@ +"""任务内**公共巡检**:任务运行期间按间隔穿插检查设备状态,命中就动作 + 通知。 + +和步骤编辑器的关系(重要,别混): + +| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) | +|---|---|---| +| 配置位置 | 步骤画布,拖出来的 | 任务编辑器里单独一块 | +| 执行方式 | 顺序执行 | **穿插**:每执行完一步(以及长等待的每个分片)看一眼"哪个巡检到点了" | +| 适合什么 | 一段固定流程 | 全程都要盯的守护条件(熄屏就点亮、掉到桌面就通知) | + +为什么用穿插而不是另起线程:不需要并发模型,不会和主流程抢屏幕(否则巡检点屏幕、 +主流程也在点,动作会互相打断)。代价是巡检精度受步长影响——某一步卡 30 秒, +巡检最多晚 30 秒;这也是刻意的取舍(见 doc/TASK_DEV.md §4.5)。 + +配置结构(`params.watchers` 数组,每项): + +```json +{"name": "熄屏点亮", "enabled": true, "interval": 60, + "check": "screen_off", // 见 CHECKS + "selector_type": "xpath", "selector_value": "", // 元素类检查才用 + "action": "screen_on", // 见 ACTIONS + "notify": true, "title": "{device} 熄屏了", + "message": "任务 {job} 在 {time} 发现 {screen},已点亮", + "cooldown": 300} // 命中后多久内不再重复动作/通知(防刷屏) +``` +""" +from core.logger import get_logger +from core.u2_helper import screen_is_on, current_package + +_log = get_logger("task.generic") + +# 检查项:need 说明该项还要填什么(selector=元素选择器 / package=包名) +CHECKS = { + "screen_off": {"label": "屏幕熄灭", "need": ""}, + "screen_on": {"label": "屏幕亮着", "need": ""}, + "element_exists": {"label": "元素存在", "need": "selector"}, + "element_missing": {"label": "元素不存在", "need": "selector"}, + "foreground_is": {"label": "前台是该App", "need": "package"}, + "foreground_not": {"label": "前台不是该App", "need": "package"}, +} + +ACTIONS = { + "none": {"label": "只发通知,不动设备"}, + "screen_on": {"label": "点亮屏幕"}, + "screen_off": {"label": "熄灭屏幕"}, + "stop_self": {"label": "停止本设备任务"}, +} + +# 通知正文里可用的占位符(前端提示、文档、渲染三处共用一份,避免漂移) +PLACEHOLDERS = ("device", "serial", "job", "time", "app", "screen") + + +def _element_exists(d, sel_type, sel_val): + """元素是否存在(不等超时,立即判定)。一次 UI dump 的代价,慢设备上可能要几秒。""" + if sel_type == "xpath": + return bool(d.xpath(sel_val).exists()) + return bool(d(**{sel_type: sel_val}).exists()) + + +def evaluate(d, spec): + """跑一次检查,返回 (是否命中, 一句人话说明)。异常由调用方兜。""" + check = str(spec.get("check") or "") + val = str(spec.get("selector_value") or "").strip() + + if check in ("screen_off", "screen_on"): + state = screen_is_on(d) + if state is None: + return False, "屏幕状态读不到" + hit = (not state) if check == "screen_off" else state + return hit, f"屏幕{'亮着' if state else '熄灭'}" + + if check in ("element_exists", "element_missing"): + if not val: + return False, "没填选择器" + found = _element_exists(d, spec.get("selector_type") or "xpath", val) + return (found if check == "element_exists" else not found), \ + f"元素{'存在' if found else '不存在'}" + + if check in ("foreground_is", "foreground_not"): + if not val: + return False, "没填包名" + cur = current_package(d) + return (cur == val) if check == "foreground_is" else (cur != val), \ + f"前台={cur or '未知'}" + + return False, f"未知检查项 {check}" + + +def act(d, spec, worker=None): + """执行命中后的动作,返回一句人话(没动作返回空串)。""" + action = str(spec.get("action") or "none") + if action == "screen_on": + d.unlock() # 与 screen_on 步骤同语义:唤醒 + 解锁 + return "已点亮屏幕" + if action == "screen_off": + d.screen_off() + return "已熄灭屏幕" + if action == "stop_self": + if worker is not None: + worker.stop() # 置停止位,主流程下一步就退出 + return "已停止本设备任务" + return "" + + +def check_label(check): + return (CHECKS.get(check) or {}).get("label", check) + + +def action_label(action): + return (ACTIONS.get(action) or {}).get("label", action) diff --git a/core/task_manager.py b/core/task_manager.py index e71fdad..c7559ea 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -909,6 +909,18 @@ class TaskManager: job_name=job.name, attempt=attempt, phase="after") return + if worker is not None and worker.stopped(): + # 任务**自己**停的(步骤里的「停止本设备」):不是失败,也不重试。 + # 判据是"worker 置了停止位但 _stop_requested 里没有"——上面那个 + # 分支已经排除了用户/调度停止的情况。 + _log.info(f"{serial} 任务内主动停止本设备 (job={job.name})") + outcome = "stopped" + notifier.notify("task.device.stopped", serial=serial, + device_name=dname, + model=_dev_model(serial, tracker), + job_id=job.id, job_name=job.name, + attempt=attempt, phase="self") + return _log.warning(f"{serial} 任务未成功(status={st})") except DeviceOfflineError as e: # 设备掉线,立即放弃,不重试 diff --git a/core/u2_helper.py b/core/u2_helper.py index 77f8a8c..5668004 100644 --- a/core/u2_helper.py +++ b/core/u2_helper.py @@ -5,6 +5,7 @@ """ import time import random +import re from core.logger import get_logger @@ -65,6 +66,34 @@ def random_sleep(min_s, max_s): time.sleep(random.uniform(min_s, max_s)) +def screen_is_on(d): + """屏幕是否亮着。返回 True/False,取不到返回 None(三态,别当 False 用)。 + + 为什么不用 `d.info`:它在部分设备上一次要**十几秒**(见 doc/backlog/TODO.md); + `dumpsys power` 经 atx-agent 跑只要 ~0.3s,且 `mWakefulness` 字段全机型都有 + (MIUI 的 `dumpsys display` 反而没有 mScreenState)。 + """ + try: + out = d.shell("dumpsys power") + out = getattr(out, "output", None) or str(out) + m = re.search(r"mWakefulness=(\w+)", out) + if m: + return m.group(1).lower() != "asleep" + except Exception as e: + _log.warning(f"读取屏幕状态失败: {e}") + return None + + +def current_package(d): + """当前前台 App 包名;取不到返回空串。""" + try: + cur = d.app_current() or {} + return cur.get("package") or "" + except Exception as e: + _log.warning(f"读取前台 App 失败: {e}") + return "" + + def safe_click(el, timeout=1): """安全点击:元素存在才点,不抛异常。返回是否点击成功。""" try: diff --git a/doc/AI_CONSOLE.md b/doc/AI_CONSOLE.md index 7519b0a..d537ab3 100644 --- a/doc/AI_CONSOLE.md +++ b/doc/AI_CONSOLE.md @@ -184,7 +184,7 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage, 从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束: - **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效) -- 白名单步骤类型(18 种去掉 `click_xy`、`keep_screen`) +- 白名单步骤类型(20 种去掉 `click_xy`、`keep_screen`) - 每类型有必填参数校验(如 `click` 必须有选择器) - 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步 - 保存时服务端**再校验一次**,含坐标的提交直接 400 diff --git a/doc/AI_TASK_GEN.md b/doc/AI_TASK_GEN.md index f65b868..27312da 100644 --- a/doc/AI_TASK_GEN.md +++ b/doc/AI_TASK_GEN.md @@ -9,7 +9,7 @@ 平台已有两套能力,但互不相通: - **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。 -- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…18 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 +- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…20 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。 diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md index 85ba9f2..8eb19f8 100644 --- a/doc/NOTIFY.md +++ b/doc/NOTIFY.md @@ -52,6 +52,7 @@ daemon 线程。所以: | 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` | | 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` | | 业务 | `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` | | 安装 | `apk.install.started` · `.finished` | @@ -91,6 +92,20 @@ daemon 线程。所以: 单设备事件里的设备一律**显示名字**(`cs1`)而不是地址;只有设备没命名时才退回 IP。 `型号` 取自设备池的快照(后台统一采集的那份),比 worker 每次连接时现采的稳。 +**任务自己发的通知**(两种,文案都由任务侧提供): + +| 事件 | 谁发 | 文案从哪来 | +|---|---|---| +| `task.notify.custom` | 步骤「发通知」 | 步骤参数 `title` / `message` | +| `task.patrol.hit` | 任务「公共巡检」命中 | 巡检项 `title` / `message`(留空则用默认:`巡检命中:<巡检名>` + 检查结果) | + +- 两者都支持占位符 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}` + (见 [TASK_DEV.md](TASK_DEV.md) §4.5;`{app}`/`{screen}` 要查设备,不用就别写)。 +- **给了 `title` 就用它做消息标题**(不再拼"任务通知:" 前缀),`level` 字段可点名级别 + (`info`/`success`/`warning`/`error` → 决定 emoji,默认按事件名猜)。 +- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条 + webhook 上勾 `task.notify.custom` / `task.patrol.hit`。 + --- ## 4. 配置 diff --git a/doc/README.md b/doc/README.md index ac2299f..1f8c5c8 100644 --- a/doc/README.md +++ b/doc/README.md @@ -13,7 +13,7 @@ | [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) | | [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 | | [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 | -| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、18 种步骤全表、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 | +| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、20 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 | | [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 | | [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 | | [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 14414f8..3631b67 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -10,8 +10,8 @@ - [1. 核心概念](#1-核心概念) - [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps) -- [3. 18 种步骤全表](#3-18-种步骤全表) -- [4. 容器步骤与公共参数](#4-容器步骤与公共参数) +- [3. 20 种步骤全表](#3-20-种步骤全表) +- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布)) - [5. 选择器与元素定位](#5-选择器与元素定位) - [6. 自定义动作](#6-自定义动作) - [7. 步骤编辑器(前端)](#7-步骤编辑器前端) @@ -108,7 +108,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) --- -## 3. 18 种步骤全表 +## 3. 20 种步骤全表 > 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。 @@ -131,7 +131,9 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 | | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | | 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group | -| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2 | +| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** | +| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 | +| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) | --- @@ -149,7 +151,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) ### 4.2 `if_el` 条件判断 -- 先做元素判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套) +- 先做判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套) +- **`selector_type` 还支持两类非元素条件**(只有条件判断有,别的带选择器的步骤没有): + - `screen` —— 屏幕状态,`selector_value` 填 `off` / `on`(也认 `熄屏`/`灭屏`):读 `dumpsys power` 的 `mWakefulness`; + - `foreground` —— 前台是不是某个包名,`selector_value` 填包名(如 `com.ss.android.ugc.aweme`)。 + - 这两类**不参与「选择器健康」统计**(它们不是选择器,不该攒出「连续未命中」告警),也不吃 `timeout`(只看当前状态,不等待) - `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。 - OCR 模式下 **`timeout` 不生效**(单次截图即判定) - OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定) @@ -170,6 +176,65 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) --- +### 4.5 公共巡检(任务级,独立于步骤画布) + +任务运行期间**要全程盯着的条件**(熄屏就点亮、掉到桌面就通知、刷到广告就停), +配在任务编辑器的「公共巡检」块里,**不进步骤画布**。权威实现在 `core/patrol.py`。 + +| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) | +|---|---|---| +| 配置位置 | 步骤画布,拖出来的 | 任务编辑器单独一块 | +| 执行方式 | 顺序执行 | **穿插**:每执行完一步、以及长等待的每个分片,看一眼"哪个巡检到点了" | +| 适合 | 一段固定流程 | 全程都要盯的守护条件 | + +为什么穿插而不是另起线程:不需要并发模型,也**不会和主流程抢屏幕**(否则巡检点屏幕、 +主流程同时也在点,动作互相打断)。代价是**精度受步长影响**——某一步卡 30 秒, +巡检最多晚 30 秒。 + +配置结构(`params.watchers` 数组,每项): + +```json +{"name": "熄屏点亮", "enabled": true, "interval": 60, + "check": "screen_off", + "selector_type": "xpath", "selector_value": "", + "action": "screen_on", + "notify": true, "title": "{device} 熄屏了", + "message": "任务 {job} 在 {time} 发现 {screen},已点亮", + "cooldown": 300} +``` + +| 字段 | 说明 | +|------|------| +| `interval` | 检查间隔(秒,最小 5)。**首次检查在任务开始时立刻做一次**,之后按间隔 | +| `check` | `screen_off` 屏幕熄灭 / `screen_on` 屏幕亮着 / `element_exists` 元素存在 / `element_missing` 元素不存在 / `foreground_is` 前台是该App / `foreground_not` 前台不是该App | +| `selector_type`/`selector_value` | 元素类检查填选择器;`foreground_*` 填**包名**;屏幕类不用填 | +| `action` | `none` 只发通知 / `screen_on` 点亮屏幕 / `screen_off` 熄灭屏幕 / `stop_self` 停止本设备任务 | +| `notify` | 命中是否推通知(事件 `task.patrol.hit`,见 [NOTIFY.md](NOTIFY.md));`false` 就只做动作 | +| `title`/`message` | 通知标题正文,可用 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`。**文案在动作之前渲染**,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" | +| `cooldown` | 命中后多少秒内不再重复动作/通知(默认 300)。**条件持续成立时(一直熄屏)靠它防刷屏** | + +两个能直接抄的例子: + +```jsonc +// 1) 熄屏就点亮(不吵人):每分钟看一眼,熄了就点亮 +{"name":"熄屏点亮","interval":60,"check":"screen_off","action":"screen_on","notify":false} + +// 2) 掉出抖音就通知 + 停本设备:前台不是抖音 → 通知 → 停下来等你处理 +{"name":"掉出抖音","interval":60,"check":"foreground_not","selector_value":"com.ss.android.ugc.aweme", + "action":"stop_self","notify":true,"title":"{device} 掉出抖音了", + "message":"当前前台 {app},已停止本设备任务"} +``` + +要点与坑: + +- **巡检不是步骤**:不要试图在画布上找它;它也不计入"已执行 N 次操作"的进度。 +- 一次检查要查设备:屏幕 `0.3s`、前台 `0.7s`,**元素检查要 dump UI 树(慢设备上可能几秒)**。 + 所以 `interval` 别设太小(元素检查建议 ≥30s)。 +- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。 +- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。 + +--- + ## 5. 选择器与元素定位 ### 5.1 `selector_type` 支持的值 diff --git a/static/admin/editor.js b/static/admin/editor.js index 257df75..f6a5553 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -19,7 +19,9 @@ var STEP_LIB=[ {type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}}, {type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}}, {type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}}, - {type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}} + {type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}, + {type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}}, + {type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}} ]; var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']]; var _customActions=[]; // 后端加载的自定义动作 @@ -46,19 +48,31 @@ function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;}) // 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用 // withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持) function _selRowHtml(p, extra, withOcr){ - var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']: + // withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件 + var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']: ['xpath','description','text','resourceId','descriptionContains','className']; var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId', - descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'}; + descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别', + screen:'屏幕状态',foreground:'前台App是'}; var opts=types.map(function(d){ return '';}).join(''); - var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':''; + var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':''; var pick=_can('devices')?'':''; var test=_can('devices')?'':''; + var isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground'; + var valLabel=isScreen?'屏幕状态':(isFg?'前台包名':'选择器值'); + var valHtml; + if(isScreen){ + valHtml=''; + }else{ + valHtml=''+(isFg?'':pick+test); + } return '