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 '
'+ - '
'+ - ''+pick+test+ - '
'+(extra||''); + '
'+ + valHtml+'
'+(extra||''); } // 加载自定义动作库 @@ -324,6 +338,20 @@ var _stepEditor={ return ''; }).join('')+''; h+='
返回键用于退出评论/返回上一页,比结束App轻量
'; + }else if(step.type==='notify'){ + h+='
'; + h+='
'; + h+='
'; + h+='
推送到哪条群取决于 webhook 自己的事件订阅(勾了「任务自定义通知」的才收)。' + +'正文可用占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、' + +'{app} 当前前台包名、{screen} 亮屏/熄屏(后两个要查设备,不用就别写)
'; + }else if(step.type==='stop_self'){ + h+='
'; + h+='
只停本设备的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败' + +'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」
'; }else if(step.type==='wait'){ h+='
'; h+='
'; @@ -353,12 +381,17 @@ var _stepEditor={ h+='
动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。
'; h+='
把本组步骤保存到左侧自定义动作库,方便复用
'; }else if(step.type==='if_el'){ + // 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓 + var dyn=(p.selector_type==='screen'||p.selector_type==='foreground'); h+=_selRowHtml(p, - '
'+ + (dyn?'':'
'+ '
'+ - '
'+ - '
超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+ - 'OCR识别可匹配图片/画布里的文字(截屏识别,跨平台)
', true); + '')+ + '
'+(dyn + ? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。屏幕状态读系统 mWakefulness(亮/熄),' + +'前台App是判断当前前台是不是某个包名——都只看当前状态,不等待。' + : '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;' + +'OCR识别可匹配图片/画布里的文字(截屏识别,跨平台)')+'
', true); } // 共享:触发概率(所有步骤通用,0~100) h+='
0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")
'; @@ -626,10 +659,12 @@ var _stepEditor={ }); }, // 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关 - _toggleOcrMode:function(sel){ - var card=sel.closest('.step-card'); - var f=card&&card.querySelector('[data-ocr-field]'); - if(f)f.style.display=sel.value==='ocr'?'block':'none'; + _condTypeChanged:function(sel){ + // 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入), + // 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。 + this._syncFromDom(); + var self=this; + setTimeout(function(){self._renderCanvas();},0); }, // 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组 _arrId:function(arr){ @@ -1220,6 +1255,11 @@ async function saveTask(jobId){ } const mdEl=document.getElementById('f-max_duration'); params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps}; + // 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py) + if(typeof patrolCollect==='function'){ + const ws=patrolCollect(); + if(ws.length)params.watchers=ws; + } }else{ try{params=JSON.parse(document.getElementById('f-params').value||'{}');} catch(e){showToast('参数 JSON 格式错误','error');return;} diff --git a/static/admin/tasks.js b/static/admin/tasks.js index 509c39a..4773044 100644 --- a/static/admin/tasks.js +++ b/static/admin/tasks.js @@ -80,6 +80,128 @@ function _normalizeTaskEnv(env){ }; } +// ================== 公共巡检(任务级,独立于步骤画布) ================== +// 与后端 core/patrol.py 的 CHECKS/ACTIONS 一一对应;选项变动时两边一起改。 +// 存进任务 params.watchers,worker 在步骤之间穿插执行(见 core/patrol.py 模块注释)。 +const _PATROL_CHECKS=[ + ['screen_off','屏幕熄灭',''], ['screen_on','屏幕亮着',''], + ['element_exists','元素存在','selector'], ['element_missing','元素不存在','selector'], + ['foreground_is','前台是该App','package'], ['foreground_not','前台不是该App','package'], +]; +const _PATROL_ACTIONS=[ + ['none','只发通知,不动设备'], ['screen_on','点亮屏幕'], + ['screen_off','熄灭屏幕'], ['stop_self','停止本设备任务'], +]; +let _patrolItems=[]; + +function patrolInit(items){ + _patrolItems=(items||[]).filter(function(x){return x&&typeof x==='object';}) + .map(function(x){return Object.assign({},x);}); + patrolRender(); +} +function patrolAdd(){ + patrolSync(); + _patrolItems.push({enabled:true,name:'',interval:60,check:'screen_off',selector_type:'xpath', + selector_value:'',action:'screen_on',notify:true, + title:'',message:'',cooldown:300}); + patrolRender(); +} +function patrolRemove(i){ + patrolSync(); + _patrolItems.splice(i,1); + patrolRender(); +} +// DOM → 内存(重渲染前必须调,否则刚填的值会被覆盖) +function patrolSync(){ + const list=document.getElementById('patrol-list'); + if(!list)return; + list.querySelectorAll('.patrol-card').forEach(function(card,i){ + const w=_patrolItems[i]; if(!w)return; + const get=function(k){ + const el=card.querySelector('[data-pk="'+k+'"]'); + if(!el)return undefined; + return el.type==='checkbox'?el.checked:el.value; + }; + ['name','check','action','selector_type','selector_value','title','message'].forEach(function(k){ + const v=get(k); if(v!==undefined)w[k]=v; + }); + ['interval','cooldown'].forEach(function(k){ + const v=get(k); if(v!==undefined)w[k]=parseInt(v,10)||0; + }); + ['enabled','notify'].forEach(function(k){ + const v=get(k); if(v!==undefined)w[k]=v; + }); + }); +} +function patrolCollect(){ + patrolSync(); + return _patrolItems.map(function(w){ + return {enabled:w.enabled!==false, name:(w.name||'').trim(), interval:parseInt(w.interval,10)||60, + check:w.check||'screen_off', selector_type:w.selector_type||'xpath', + selector_value:(w.selector_value||'').trim(), action:w.action||'none', + notify:w.notify!==false, title:(w.title||'').trim(), message:(w.message||'').trim(), + cooldown:parseInt(w.cooldown,10)||0}; + }); +} +// 检查项一变,条件行(选择器/包名)跟着换 +function patrolCheckChanged(sel){ + const card=sel.closest('.patrol-card'); + const need=(_PATROL_CHECKS.find(function(c){return c[0]===sel.value;})||[])[2]||''; + const row=card.querySelector('.patrol-cond'); + row.style.display=need?'flex':'none'; + const typeBox=row.querySelector('.patrol-seltype'); + if(typeBox)typeBox.style.display=(need==='selector')?'block':'none'; + const lb=row.querySelector('.patrol-vallabel'); + if(lb)lb.textContent=(need==='package')?'前台包名':'选择器值'; + const inp=row.querySelector('[data-pk="selector_value"]'); + if(inp)inp.placeholder=(need==='package')?'如 com.ss.android.ugc.aweme':'如 点赞'; +} +function patrolRender(){ + const list=document.getElementById('patrol-list'); + if(!list)return; + if(!_patrolItems.length){ + list.innerHTML='
(没有巡检项)
'; + return; + } + list.innerHTML=_patrolItems.map(_patrolCardHtml).join(''); +} +function _patrolCardHtml(w,i){ + const need=(_PATROL_CHECKS.find(function(c){return c[0]===w.check;})||[])[2]||''; + const chkOpts=_PATROL_CHECKS.map(function(c){ + return '';}).join(''); + const actOpts=_PATROL_ACTIONS.map(function(a){ + return '';}).join(''); + const typeOpts=['xpath','description','text','resourceId','descriptionContains','className'] + .map(function(t){return '';}).join(''); + return '
' + +'
' + +'' + +'' + +'' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'
' + +'' + +'
' + +'
' + +'
' + +'
占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、{app} 当前前台包名、' + +'{screen} 亮屏/熄屏(后两个要查设备,不用就别写)。冷却期内重复命中不会重复动作/通知,防刷屏
' + +'
'; +} + // openTaskModal(jobId, prefill) // jobId —— 传 id = 编辑已有任务 // prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对) @@ -179,6 +301,16 @@ function openTaskModal(jobId, prefill){ '
操作库
'+ '
'+ ''+ + ''+ + // 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 core/patrol.py) + '
'+ + '
'+ + '公共巡检可选:任务运行期间按间隔穿插检查,命中就动作 + 通知'+ + ''+ + '
'+ + '
'+ + '
和步骤的区别:步骤是"按顺序做完这一段",巡检是"全程都要盯着的条件"(比如熄屏就点亮、掉到桌面就通知)。'+ + '检查穿插在步骤之间执行,不额外抢设备;某一步耗时很长时,巡检最多晚那一步的时间。
'+ '
'; // AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看 @@ -198,6 +330,7 @@ function openTaskModal(jobId, prefill){ // 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。 const draftKey=jobId||(pf?('ai:'+Date.now()):''); _stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey); + patrolInit(j.params&&j.params.watchers); document.getElementById('modal-footer').innerHTML= ''+ @@ -285,6 +418,8 @@ function onTaskTypeChange(){ const isGeneric=tt==='generic_steps'; document.getElementById('step-editor').classList.toggle('show',isGeneric); document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block'; + const pb=document.getElementById('patrol-box'); + if(pb)pb.style.display=isGeneric?'block':'none'; // 巡检只对通用步骤任务有意义 } // ================== 自定义动作管理 ================== diff --git a/tasks/generic/task.py b/tasks/generic/task.py index b9bbc7d..c0f2f9a 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -26,6 +26,8 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度 wait_el - 等待元素出现(条件等待,替代固定时长 wait) input_text - 输入文字(随机候选/指定文字,可先清空) wait - 等待时长 + notify - 发自定义通知(标题/正文自己写,推送 webhook) + stop_self - 停止本设备的任务(其它设备不受影响) loop - 循环块(含 children 步骤列表 + max_iterations) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) """ @@ -35,9 +37,10 @@ import time from tasks.base import BaseTask, register_task from core.device_worker import BaseWorker, _update_status -from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep +from core.u2_helper import (ensure_app_running, wait_for_app_home, random_sleep, + screen_is_on, current_package) from core.logger import get_logger -from core import notifier, step_log +from core import notifier, step_log, patrol _log = get_logger("task.generic") @@ -107,6 +110,10 @@ STEP_TYPES = [ {"type": "if_el", "label": "条件判断", "icon": "❓", "params": {"selector_type": "xpath", "selector_value": "", "timeout": 3, "then": [], "else": []}}, + {"type": "notify", "label": "发通知", "icon": "🔔", + "params": {"title": "", "message": "", "level": "info"}}, + {"type": "stop_self", "label": "停止本设备", "icon": "⛔", + "params": {"reason": ""}}, ] @@ -136,6 +143,17 @@ class GenericStepsWorker(BaseWorker): self._path = [] self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step) self._step_capped = False + # 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py): + # _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏 + self._watchers = [] + for i, w in enumerate(p.get("watchers") or []): + if not isinstance(w, dict) or not w.get("enabled", True): + continue + w = dict(w) + w["_wid"] = f"w{i}" + self._watchers.append(w) + self._watch_next = {} # wid -> 下次到点(缺省 0 = 任务一开始就先查一次) + self._watch_last = {} # wid -> 上次命中(冷却用) # 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版) _MAX_CONSECUTIVE_MISS = 10 @@ -185,6 +203,7 @@ class GenericStepsWorker(BaseWorker): for i, step in enumerate(steps): if self.stopped() or self.is_time_up(): return + self._maybe_patrol(d) # 穿插:每步之前看一眼有没有巡检到点了 # 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后 # 继续追加,得到 "2.1" 这样的嵌套路径 self._path.append(str(i + 1)) @@ -279,6 +298,71 @@ class GenericStepsWorker(BaseWorker): except Exception: pass # 记录失败绝不能影响任务执行 + # ================== 公共巡检(任务级,穿插执行) ================== + def _maybe_patrol(self, d): + """看一眼有没有巡检到点了;到点就跑一次检查(配置见 core/patrol.py)。 + + 穿插式:不起线程、不抢屏幕,所以巡检精度受主流程步长影响——某一步卡 + 30 秒,巡检最多晚 30 秒(这是刻意取舍,换来的是绝不会和主流程打架)。 + """ + if not self._watchers or self.stopped(): + return + now = time.time() + for w in self._watchers: + wid = w["_wid"] + if now < self._watch_next.get(wid, 0): + continue + # 先排下一次再执行:检查本身可能慢(元素检查要 dump UI 树), + # 否则慢检查会导致"每分钟"变成"每检查一次" + self._watch_next[wid] = now + max(5, int(w.get("interval") or 60)) + try: + self._run_patrol(d, w, now) + except Exception as e: + _log.warning(f"[{self.serial}] 巡检[{w.get('name') or wid}]异常: {e}") + + def _run_patrol(self, d, w, now): + """执行一次巡检:命中 → 过冷却 → 做动作 + 发通知 + 记一条明细。""" + wid, name = w["_wid"], (w.get("name") or patrol.check_label(w.get("check"))) + hit, detail = patrol.evaluate(d, w) + if not hit: + return + # 命中后冷却:条件持续成立(比如一直熄屏)时不要每分钟都动作/发通知 + cooldown = max(0, int(w.get("cooldown") or 0)) + if now - self._watch_last.get(wid, 0) < cooldown: + _log.info(f"[{self.serial}] 巡检[{name}]命中({detail})但在冷却期内,不重复动作") + return + self._watch_last[wid] = now + # 文案在**动作之前**渲染:动作会改变设备状态(点亮后 {screen} 就成了"亮屏"), + # 而用户要看的是"发现时"的样子——"发现熄屏,已点亮"而不是"发现亮屏,已点亮" + title = self._render_vars(d, w.get("title") or "") or f"巡检命中:{name}" + message = self._render_vars(d, w.get("message") or "") or detail + done = patrol.act(d, w, self) + _log.info(f"[{self.serial}] 巡检[{name}]命中:{detail}" + f"{(' → ' + done) if done else '(只通知)'}") + self.set_action(f"巡检[{name}]:{detail}{(',' + done) if done else ''}") + summary = f"{detail}{(' → ' + done) if done else ''}" + try: # 明细:只记命中,不记"没事发生" + step_log.record(run_id=self.ctx.get("run_id", ""), serial=self.serial, + device_name=self.ctx.get("device_name", ""), + job_id=self.ctx.get("job_id", ""), + job_name=self.ctx.get("job_name", ""), + step_label=f"巡检[{name}]", step_type="patrol", + result="hit", detail=summary) + except Exception: + pass + if w.get("notify", True): + notifier.notify("task.patrol.hit", + patrol_name=name, + check_label=patrol.check_label(w.get("check")), + action_label=patrol.action_label(w.get("action")), + detail=detail, + job_id=self.ctx.get("job_id", ""), + job_name=self.ctx.get("job_name", ""), + serial=self.serial, + device_name=self.ctx.get("device_name", ""), + title=title, message=message, + level=w.get("level", "warning")) + # ================== 步骤执行器 ================== def _exec_screen_on(self, d, params, depth=0): """亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。""" @@ -490,7 +574,23 @@ class GenericStepsWorker(BaseWorker): _log.warning(f"[{self.serial}] if_el 缺少选择器,跳过") return None found = False - if sel_type == "ocr": + # 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器, + # 连续未命中不该被记成"选择器失效" + dynamic = sel_type in ("screen", "foreground") + detail = "" + if sel_type == "screen": + # 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文) + want = str(sel_val).strip().lower() + want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏") + state = screen_is_on(d) + found = (state is False) if want_off else (state is True) + detail = (f"屏幕{'熄屏' if state is False else '亮屏' if state else '状态未知'}" + f"(判断{'熄屏' if want_off else '亮屏'})") + elif sel_type == "foreground": + cur = current_package(d) + found = cur == str(sel_val).strip() + detail = f"前台='{cur or '未知'}'(期望 {sel_val})" + elif sel_type == "ocr": # OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到) try: from core.ocr import available as _ocr_available, find_on_screen @@ -523,10 +623,13 @@ class GenericStepsWorker(BaseWorker): except Exception as e: _log.warning(f"[{self.serial}] 条件判断异常: {e}") found = False - self._track_selector_health(sel_val, found) + if dynamic: + _log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}") + else: + self._track_selector_health(sel_val, found) branch = params.get("then" if found else "else") or [] self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)") - _log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步") + _log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步") self._exec_steps(d, branch, depth + 1) return found @@ -547,6 +650,61 @@ class GenericStepsWorker(BaseWorker): elif direction == "right": d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur) + def _exec_notify(self, d, params, depth=0): + """发一条自定义通知(标题/正文由任务自己写,支持占位符)。 + + 事件是 `task.notify.custom`:**谁能收到取决于 webhook 的事件订阅**—— + 这个步骤只负责"把消息交出去",不关心发给谁(与平台其它通知同一套口径)。 + """ + title = self._render_vars(d, params.get("title") or "") + msg = self._render_vars(d, params.get("message") or "") + if not (title or msg): + _log.warning(f"[{self.serial}] notify 步骤既没标题也没内容,跳过") + return None + notifier.notify("task.notify.custom", + job_id=self.ctx.get("job_id", ""), + job_name=self.ctx.get("job_name", ""), + serial=self.serial, + device_name=self.ctx.get("device_name", ""), + title=title or "任务通知", message=msg, + level=params.get("level", "info")) + _log.info(f"[{self.serial}] 已发自定义通知: {title} {msg}") + return True + + def _render_vars(self, d, text): + """替换通知文本里的占位符:{device} {serial} {job} {time} {app} {screen}。 + + `{app}` / `{screen}` 要查设备(0.3~0.7s),**只在文本里真用到时才查**, + 否则每条通知都白付一次设备往返。 + """ + if not text or "{" not in text: + return text + vals = {"device": self.ctx.get("device_name") or self.serial, + "serial": self.serial, + "job": self.ctx.get("job_name", ""), + "time": time.strftime("%Y-%m-%d %H:%M:%S")} + if "{app}" in text: + vals["app"] = current_package(d) or "未知" + if "{screen}" in text: + st = screen_is_on(d) + vals["screen"] = "未知" if st is None else ("亮屏" if st else "熄屏") + for k, v in vals.items(): + text = text.replace("{" + k + "}", str(v)) + return text + + def _exec_stop_self(self, d, params, depth=0): + """停止本设备的任务(其它设备的任务不受影响)。 + + 实现只是置 worker 的停止位:`_exec_steps` 每步前检查 `stopped()`,所以 + 后续步骤不会再跑;TaskManager 侧把它记成"被停止"而不是失败 + (见 `core/task_manager.py` 的 `_run_with_retry` 自停分支)。 + """ + reason = (params.get("reason") or "").strip() + _log.info(f"[{self.serial}] 任务内主动停止本设备({reason or '未填原因'})") + self.set_action(f"停止本设备任务{(':' + reason) if reason else ''}") + self.stop() + return True + def _exec_click(self, d, params, depth=0): sel_type = params.get("selector_type", "xpath") sel_val = params.get("selector_value", "") @@ -624,7 +782,10 @@ class GenericStepsWorker(BaseWorker): while time.time() < end: if self.stopped() or self.is_time_up(): return - time.sleep(min(0.5, end - time.time())) + self._maybe_patrol(d) # 长等待里也看一眼,别让巡检被 wait 拖住 + # max(0, …):巡检本身要花时间(屏幕 0.3s、前台 0.7s、元素检查更久), + # 扣掉这段时间后可能已经过点了,负数 sleep 会直接抛异常 + time.sleep(max(0.0, min(0.5, end - time.time()))) def _exec_loop(self, d, params, depth=0): children = params.get("children", []) diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 29f10f9..3e2e809 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -116,6 +116,18 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */ .log-hit{background:#fbbf2433} /* 关键字命中高亮 */ +/* 「公共巡检」配置块(任务编辑器里,独立于步骤画布) */ +.patrol-box{margin-top:14px;border:1px solid var(--border-strong);border-radius:10px;padding:12px} +.patrol-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} +.patrol-head>span:first-child{font-weight:600;font-size:13px} +.patrol-card{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;background:var(--surface-2,#fafafa)} +.patrol-row{display:flex;gap:10px;align-items:flex-end;margin-bottom:8px;flex-wrap:wrap} +.patrol-row:last-child{margin-bottom:0} +.patrol-row .pg{display:flex;flex-direction:column;gap:2px;min-width:110px} +.patrol-row .pg label{font-size:11px;color:var(--text-light)} +.patrol-row .form-check{margin-bottom:6px;display:flex;align-items:center;gap:4px;font-size:12px} +.patrol-cond{background:rgba(59,130,246,.05);border-radius:6px;padding:6px 8px;margin:0 -2px 8px} + /* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */ .res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */ .res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */