From 73895f723325b8fd60b841af091d929196362286 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 20 Sep 2026 15:04:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BB=BB=E5=8A=A1):=20=E5=BD=95=E5=88=B6?= =?UTF-8?q?=E6=89=8B=E5=8A=BF=E6=94=B9=E6=88=90**=E7=BA=AF=E5=BD=95?= =?UTF-8?q?=E5=88=B6=E5=9B=9E=E6=94=BE**=EF=BC=88=E7=9C=9F=E6=89=8B?= =?UTF-8?q?=E6=8C=87=E8=BD=A8=E8=BF=B9=EF=BC=89+=20=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=20getevent=20=E5=BD=95=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:录制出来的滑动又被套上滑动那套逻辑(方向/幅度/拟人重新生成), "导致滑动还是不顺畅"。录制就该是录制——按录下的路径与时间原样重放。 - 新步骤 `gesture`「录制手势」:存完整轨迹点列 `[[x,y,t_ms],…]`, 回放时原样交给设备(`d.swipe_points(points, duration)`),不做任何加工。 与 `swipe` 是两套东西(滑动是参数化的,录制是点列)。 - core/gesture.py(新): · 手机端录制 `PhoneRecorder`:`getevent` 读**真触屏**设备(自动挑 fts_ts 这类、 排除 uinput/vitural-sar 等合成节点),解析两套协议(BTN_TOUCH / ABS_MT_TRACKING_ID); **合成的注入事件不会出现在真触屏节点上**(实测),所以录到的只有人手的动作; · 点列清洗:按 ≥16ms 抽稀但**末点必留**(快划时不能把收尾丢了); · 回放**一次调用**而不是逐点注入:实测这台设备单次触摸 RPC ≈190ms, 逐点回放 20 点要 3.8 秒——只能让设备自己插值,"时间"由点密度还原。 - 接口:POST /api/gesture/record/{start,stop}(需设备权限;重复开始返回 409)。 - 「录制手势」步骤卡片:录到就回填(手机上录 / 网页上录),显示点数/时长; **滑动步骤上的「录制手势」按钮已移除**(按用户要求,录制不再走滑动逻辑)。 - 录制前自动唤醒设备:**息屏时 u2 抓 UI 树会从 3 秒退化到 65 秒**(实测), 截图也是黑的——这是本次排查最耗时的坑,已写进文档。 自测:假设备单测(点列/时长/抽稀/失败退回直线,10 项); 无头浏览器 + CDP 真模拟拖动 → 录到 14 点/605ms 并按实际轨迹回填; 手机录制接口起停/重复拦截; 真机回放:用录下的轨迹跑任务 → 步骤 `gesture ok`。 ⚠️ 手机端"真手指轨迹"这一段需要人真的去划才能端到端验证(我没有手指)。 文档:TASK_DEV §3.2(两套东西对比 + 三个坑)、步骤表 21 种、API、README。 --- README.md | 9 +- core/gesture.py | 276 +++++++++++++++++++++++++++++++++++++++ doc/AI_CONSOLE.md | 2 +- doc/AI_TASK_GEN.md | 2 +- doc/API.md | 2 + doc/README.md | 2 +- doc/TASK_DEV.md | 37 +++++- static/admin/editor.js | 68 +++++++--- static/admin/recorder.js | 141 +++++++++++++------- tasks/generic/task.py | 25 +++- web/tasks_api.py | 81 ++++++++++++ 11 files changed, 569 insertions(+), 76 deletions(-) create mode 100644 core/gesture.py diff --git a/README.md b/README.md index 140e829..ebde1a0 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ | **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) | | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | -| **步骤编辑器** | 可视化拖拽编排 20 种步骤(含循环/条件/OCR/通知),可打包成"自定义动作"复用 | +| **步骤编辑器** | 可视化拖拽编排 21 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 | | **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) | +| **录制回放** | 「录制手势」步骤 = **纯录制**:**用手指在真机上划**(读 getevent 真触屏)或在网页画面上拖,把轨迹点列原样存下来,回放时**照原路径与时长重放**——不套滑动那套方向/幅度/拟人参数 | | **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 | | **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | @@ -154,7 +155,7 @@ auto_control/ ├── tasks/ # 任务定义层 │ ├── base.py # BaseTask + _TASK_TYPES + register_task │ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型) -│ └── task.py # STEP_TYPES(20 种步骤)+ Worker + 执行器 +│ └── task.py # STEP_TYPES(21 种步骤)+ Worker + 执行器 │ ├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033) │ ├── mcp_server.py # 工具定义 + 平台登录 + 门控 @@ -280,7 +281,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el `generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。 -**20 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): +**21 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): | 类别 | 步骤 | |------|------| @@ -402,7 +403,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) | 任务与步骤开发(20 种步骤、公共巡检、选择器、自定义动作、新增任务类型) | +| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(21 种步骤、公共巡检、录制回放、选择器、自定义动作) | | [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/gesture.py b/core/gesture.py new file mode 100644 index 0000000..613e41f --- /dev/null +++ b/core/gesture.py @@ -0,0 +1,276 @@ +"""录制手势:**纯录制、纯回放**——录下真实手指的轨迹与时间,回放时一模一样地重放。 + +和「滑动」步骤的区别(这是两套东西,别混): + +| | `swipe` 步骤 | `gesture` 步骤(本模块) | +|---|---|---| +| 内容 | 方向 + 幅度 + 时长区间 | **完整轨迹点列** `[[x, y, t_ms], ...]` | +| 回放 | 每次重新生成(弧线/抖动/本设备手速) | **照录制的路径与时间逐点重放**,不加任何修饰 | +| 适合 | 通用滑动,换设备也能用 | 你亲手划的、要求一模一样的手势 | + +两个录制来源: +1. **手机上录**(`PhoneRecorder`):`getevent` 读**真触屏**设备,抓的是你手指的真实轨迹 + ——注意合成的注入事件不会出现在真触屏节点上(实测确认),所以录到的只有人手的动作; +2. **网页上录**:看着设备画面拖鼠标,前端采样路径与时间(见 `static/admin/recorder.js`)。 + +## 回放怎么做(实测定的,别改回去) + +**一次调用把整条轨迹交给设备**(`d.swipe_points(points, duration)`),不是逐点注入。 +原因:实测这台设备上**单次触摸注入 RPC ≈190ms**(`d.touch.move` 25 次平均 190ms), +逐点回放 20 个点要 3.8 秒——比录的手势慢一个数量级,根本不能用。所以只能让设备 +自己去插值:`swipe_points` 内部按 `steps = duration/0.005` 在轨迹上均匀推进, +手势的真实速度由设备端决定。 + +拿到"时间"靠的是**点密度**:手指慢的地方采样点天然更密(单位长度上点更多), +无论设备是按弧长均匀插值还是按段均匀插值,"点密的段落走得更久"都成立 +——所以把**录制的原始点 + 录制总时长**一起交给设备,速度曲线就能大致还原。 +""" +import re +import subprocess +import threading +import time + +from core.adb_helper import ADB_PATH +from core.logger import get_logger + +_log = get_logger("core.gesture") + +# 采样下限:手指在 16ms 内位移极小,比这更密的点只增加注入次数与延迟误差 +MIN_GAP_MS = 16 +MAX_POINTS = 240 # 单条手势的点数上限(约 4s @60Hz,够用且不让步骤 JSON 膨胀) +MAX_REPLAY_POINTS = 40 # 回放时最多发给设备多少个点(实测每点开销大,40 点是画质/耗时折中) +MIN_POINTS = 2 + +# getevent -lt 的行: [ 12345.678901] EV_ABS ABS_MT_POSITION_X 000003e8 +# 第 4 列不能限定十六进制:BTN_TOUCH 的值是 DOWN/UP 这类**键名**(含非十六进制字母), +# 限定 [0-9a-fA-F]+ 会让这些行整个被跳过 → 老协议解析不出任何手势 +_LINE_RE = re.compile(r"\[\s*([\d.]+)\]\s+(\S+)\s+(\S+)\s+(\S+)") + +# "像真触屏"的设备名特征(先挑这些);明显是合成/虚拟的一律排除 +_TOUCH_HINTS = ("ts", "touch", "goodix", "fts", "synaptics", "novatek", "himax", "sec_touch") +_VIRTUAL_HINTS = ("uinput", "virtual", "vitural", "sar", "gpio", "pon", "jack", "button") + + +# ================== 点列处理 ================== + +def clean_points(points, min_gap_ms=MIN_GAP_MS, max_points=MAX_POINTS): + """规范点列:丢掉过密的采样、限制总数,时间归零到第一点。 + + 输入/输出都是 `[[x, y, t_ms], ...]`(t 为相对第一点的毫秒)。点数不足 2 返回 []。 + """ + pts = [] + for p in points or []: + try: + pts.append([int(p[0]), int(p[1]), float(p[2])]) + except (TypeError, ValueError, IndexError): + continue + if len(pts) < MIN_POINTS: + return [] + out = [pts[0]] + for p in pts[1:-1]: + if (p[2] - out[-1][2]) >= min_gap_ms: + out.append(p) + # 末点**必须保留**:它决定手势收尾在哪(快划时中间点稀,终点不能跟着丢) + if pts[-1][:2] != out[-1][:2] or pts[-1][2] > out[-1][2]: + out.append(pts[-1]) + if len(out) < MIN_POINTS: + return [] + if len(out) > max_points: # 均匀抽稀,保住首尾 + step = len(out) / float(max_points) + out = [out[min(len(out) - 1, int(i * step))] for i in range(max_points)] + t0 = out[0][2] + for p in out: + p[2] = int(round(p[2] - t0)) + return out + + +def duration_ms(points): + return int(points[-1][2]) if points else 0 + + +def replay(d, points, speed=1.0): + """按录制的轨迹与时长回放(一次调用交给设备插值)。返回发给设备的参数。 + + 点太多会让设备端逐点开销累积(实测每个点约 1s 量级),所以超过 MAX_REPLAY_POINTS + 就抽稀——路径形状基本不变,但快得多。首尾点一定保留。 + """ + pts = clean_points(points, min_gap_ms=0) # 回放不再按时间丢点 + if len(pts) < MIN_POINTS: + raise ValueError("手势点列无效(少于 2 个点)") + if len(pts) > MAX_REPLAY_POINTS: + n = MAX_REPLAY_POINTS + step = (len(pts) - 1) / float(n - 1) + pts = [pts[min(len(pts) - 1, int(round(i * step)))] for i in range(n)] + speed = max(0.1, float(speed or 1.0)) + dur = max(0.05, duration_ms(pts) / 1000.0 / speed) + xy = [[x, y] for x, y, _ in pts] + try: + d.swipe_points(xy, dur) + except Exception as e: # 有的设备/agent 不支持多点注入 + _log.warning("swipe_points 回放失败(%s),退回直线滑动", e) + d.swipe(xy[0][0], xy[0][1], xy[-1][0], xy[-1][1], dur) + return {"points": len(pts), "duration": round(dur, 3)} + + +# ================== getevent 解析(纯函数,好单测) ================== + +def parse_getevent(lines, max_x, max_y, screen_w, screen_h): + """把 getevent 行流解析成若干条轨迹 `[[x, y, t_ms], ...]`(屏幕坐标)。 + + 兼容两套协议:`BTN_TOUCH DOWN/UP`(老)与 `ABS_MT_TRACKING_ID`(新,-1=抬起)。 + 只认 EV_ABS 的 X/Y;Y 到达时才落一个点(X/Y 是先后到达的两个事件)。 + """ + strokes, cur = [], None + lx = ly = 0 + t0 = None + for ln in lines or []: + m = _LINE_RE.search(ln) + if not m: + continue + ts, kind, code, val = float(m.group(1)), m.group(2), m.group(3), m.group(4) + if kind == "EV_ABS": + if code == "ABS_MT_POSITION_X": + lx = int(val, 16) + elif code == "ABS_MT_POSITION_Y": + ly = int(val, 16) + if cur is not None: + if t0 is None: + t0 = ts + cur.append([lx, ly, (ts - t0) * 1000.0]) + elif code == "ABS_MT_TRACKING_ID": + if val.lower().endswith("ffffffff"): # 抬起 + if cur: + strokes.append(cur) + cur = None + elif cur is None: # 按下 + cur = [] + elif kind == "EV_KEY" and code == "BTN_TOUCH": + if val == "DOWN": + if cur is None: + cur = [] + else: + if cur: + strokes.append(cur) + cur = None + if cur: + strokes.append(cur) + + sx = float(screen_w) / max_x if max_x else 1.0 + sy = float(screen_h) / max_y if max_y else 1.0 + out = [] + for st in strokes: + pts = [[int(round(x * sx)), int(round(y * sy)), t] for x, y, t in st] + pts = clean_points(pts) + if len(pts) >= MIN_POINTS: + out.append(pts) + return out + + +def find_touch_device(serial, screen_w=0, screen_h=0): + """找真触屏设备,返回 (device_path, max_x, max_y);找不到返回 (None, 0, 0)。 + + 设备名里带 uinput/virtual/sar/gpio 的一律排除(那些是合成输入); + 多个候选时优先名字像触屏的,其次挑坐标范围最接近屏幕的那个。 + """ + from core.adb_helper import _adb + out = _adb("-s", serial, "shell", "getevent -pl") or "" + cands, dev, name = [], None, "" + for ln in out.splitlines(): + s = ln.strip() + if s.startswith("add device"): + dev, name = s.split(":", 1)[-1].strip(), "" + elif s.startswith("name:"): + name = s.split(":", 1)[1].strip().strip('"') + elif "ABS_MT_POSITION_X" in s or "ABS_MT_POSITION_Y" in s: + m = re.search(r"max (\d+)", s) + if not (dev and m): + continue + key = "x" if "POSITION_X" in s else "y" + for c in cands: + if c[0] == dev: + c[1 if key == "x" else 2] = int(m.group(1)) + break + else: + mx = int(m.group(1)) if key == "x" else 0 + my = int(m.group(1)) if key == "y" else 0 + cands.append([dev, mx, my, name]) + real = [c for c in cands + if not any(h in (c[3] or "").lower() for h in _VIRTUAL_HINTS)] + pool = real or cands + if not pool: + return None, 0, 0 + + def score(c): + hit = any(h in (c[3] or "").lower() for h in _TOUCH_HINTS) + return (0 if hit else 1, abs(c[1] - (screen_w or c[1])) + abs(c[2] - (screen_h or c[2]))) + + pool.sort(key=score) + best = pool[0] + _log.info(f"[{serial}] 录制用手势设备: {best[0]} ({best[3]}) {best[1]}x{best[2]}" + f",候选 {[c[0] + '/' + (c[3] or '?') for c in cands]}") + return best[0], best[1], best[2] + + +# ================== 手机端录制 ================== + +class PhoneRecorder: + """在**手机真触屏**上录手指轨迹(getevent 流式读取)。""" + + def __init__(self, serial, screen_w=0, screen_h=0): + self.serial = serial + self.screen_w = int(screen_w or 0) + self.screen_h = int(screen_h or 0) + self.device = None + self.max_x = self.max_y = 0 + self._proc = None + self._lines = [] + self._thread = None + self._stop = threading.Event() + self._started = 0.0 + self.error = "" + + def start(self): + """起 getevent 子进程并开始收行。失败抛异常(调用方转成用户可读错误)。""" + self.device, self.max_x, self.max_y = find_touch_device( + self.serial, self.screen_w, self.screen_h) + if not self.device or not self.max_x: + raise RuntimeError("没找到触屏设备节点,无法在手机上录制") + self._proc = subprocess.Popen( + [ADB_PATH, "-s", self.serial, "shell", f"getevent -lt {self.device}"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self._started = time.time() + self._thread = threading.Thread(target=self._pump, daemon=True) + self._thread.start() + _log.info(f"[{self.serial}] 手机录制已开始({self.device})") + + def _pump(self): + try: + for raw in iter(self._proc.stdout.readline, b""): + if self._stop.is_set(): + break + self._lines.append(raw.decode("utf-8", "replace").rstrip()) + if len(self._lines) > 20000: # 防爆:留最近 2 万行足够解析 + del self._lines[:5000] + except Exception as e: + self.error = f"读取事件流失败: {e}" + + def stop(self): + """停止并返回 `[[[x,y,t_ms], ...], ...]`(每条手势一段)。""" + self._stop.set() + try: + if self._proc: + self._proc.terminate() + try: + self._proc.wait(timeout=3) + except Exception: + self._proc.kill() + except Exception: + pass + if self._thread: + self._thread.join(timeout=2) + w = self.screen_w or self.max_x + h = self.screen_h or self.max_y + gestures = parse_getevent(self._lines, self.max_x, self.max_y, w, h) + _log.info(f"[{self.serial}] 手机录制结束:{len(self._lines)} 行事件 → " + f"{len(gestures)} 条手势({sum(len(g) for g in gestures)} 点)") + return gestures diff --git a/doc/AI_CONSOLE.md b/doc/AI_CONSOLE.md index d537ab3..5005238 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` 的步骤不会被沉淀(坐标换个设备/分辨率就失效) -- 白名单步骤类型(20 种去掉 `click_xy`、`keep_screen`) +- 白名单步骤类型(21 种去掉 `click_xy`、`keep_screen`、`gesture`) - 每类型有必填参数校验(如 `click` 必须有选择器) - 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步 - 保存时服务端**再校验一次**,含坐标的提交直接 400 diff --git a/doc/AI_TASK_GEN.md b/doc/AI_TASK_GEN.md index 27312da..424239e 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…20 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 +- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…21 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。 diff --git a/doc/API.md b/doc/API.md index d8fb883..3611d92 100644 --- a/doc/API.md +++ b/doc/API.md @@ -126,6 +126,8 @@ | DELETE | `/api/custom_actions/` | T | 删除自定义动作 | | GET | `/api/step_defaults` | L | 步骤默认值(生效值 + 出厂值 + 字段规格,供「动作配置」页画表单) | | POST | `/api/step_defaults` | T | 保存步骤默认值(只落与出厂值不同的字段) | +| POST | `/api/gesture/record/start` | D | 开始录制手势(手机端 getevent,返回 token) | +| POST | `/api/gesture/record/stop` | D | 停止录制并取回轨迹 `gestures:[[[x,y,t_ms],…]]` | | GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 | | GET | `/api/uiauto/devices` | D | 抓元素可选设备 | | GET | `/api/uiauto/elements` | D | 设备 UI 元素树 | diff --git a/doc/README.md b/doc/README.md index 1f8c5c8..b00bcab 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 概念、20 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 | +| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、21 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 | | [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 1aa2213..dec3826 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -10,7 +10,7 @@ - [1. 核心概念](#1-核心概念) - [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps) -- [3. 20 种步骤全表](#3-20-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置)) +- [3. 21 种步骤全表](#3-21-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置)) - [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布)) - [5. 选择器与元素定位](#5-选择器与元素定位) - [6. 自定义动作](#6-自定义动作) @@ -108,7 +108,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) --- -## 3. 20 种步骤全表 +## 3. 21 种步骤全表 > 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。 @@ -134,6 +134,39 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 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 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) | +| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 | + +### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`) + +「滑动」和「录制手势」是**两套东西**,别混: + +| | `swipe` 步骤 | `gesture` 步骤 | +|---|---|---| +| 存什么 | 方向 + 幅度 + 时长区间 | **完整轨迹点列** `[[x,y,t_ms], …]` | +| 回放时 | 每次重新生成(弧线/抖动/本设备手速) | **照录制的路径与时长**,不做任何修饰 | +| 适合 | 通用滑动,换设备也能用 | 你亲手划的、要求一模一样的手势 | + +**为什么录制的不能走滑动那套**:滑动是"参数化"的——它按方向+幅度重新生成轨迹并叠 +拟人抖动,录下来的路径就被丢掉了。要"完全按我划的重放",就只能存点列、回放点列。 + +**两个录制来源**(都在「录制手势」步骤的卡片上): + +| 来源 | 做法 | 特点 | +|---|---|---| +| ● 手机上录 | 点开始后**用手指在真机上划**,后端读 `getevent` 抓真触屏 | 最真实:录的是人手的原始轨迹。合成注入不会出现在真触屏节点上(实测),所以录到的只有人手 | +| ● 网页上录 | 在弹窗画面里按住鼠标拖,前端按 8ms/3px 采样 | 不用碰手机,但有鼠标的机械感 | + +要点与坑: + +- **录制前必须唤醒设备**:息屏时 u2 抓 UI 树会从 3 秒退化到 **65 秒**(实测),截图还是黑的。 + 前端在选好设备后会自动调一次亮屏(`/api/device/screen_all`)。 +- **回放是一次调用**(`d.swipe_points(points, duration)`),不是逐点注入:实测这台设备 + **单次触摸注入 RPC ≈190ms**,逐点回放 20 个点要 3.8 秒,比录的手势慢一个数量级。 + 所以只能让设备自己插值——"时间"靠**点密度**还原(手指慢的地方采样点更密)。 +- 回放点数**抽稀到 40 个**(`MAX_REPLAY_POINTS`):这台设备每个点开销大,40 是画质/耗时折中; + 首尾点一定保留。 +- 轨迹是**屏幕绝对像素**:换分辨率不同的设备可能偏,同型号/同分辨率最稳。 +- 点列存在步骤 JSON 里(一条 0.5s 的手势约 25 点 / 400 字节),不新建表。 ### 3.1 步骤默认值与动作录制(任务 → 动作配置) diff --git a/static/admin/editor.js b/static/admin/editor.js index ad675f9..aa2d2eb 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -21,7 +21,8 @@ var STEP_LIB=[ {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:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}}, - {type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}} + {type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}}, + {type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}} ]; // 步骤默认值(「任务 → 动作配置」页配置,见 core/step_defaults.py);新建步骤时预填 let _stepDefaults = null; @@ -332,8 +333,7 @@ var _stepEditor={ h+='
'; h+='
'; h+=_humanizeRowHtml(p,'
'); - h+='
'+(_can('devices')?'':'')+'' - +'
在设备画面上按住划一下,按你划的方向/幅度/时长回填这一步(不想手填参数时用)
'; + h+='
想要"照我划的那一下原样重放",请用操作库里的《录制手势》步骤——那个是纯轨迹回放,不套这里的参数
'; }else if(step.type==='click'){ h+=_selRowHtml(p,'
'); }else if(step.type==='long_click'){ @@ -347,7 +347,7 @@ var _stepEditor={ '
'+ '
'+ _humanizeChkHtml(p)+ - (_can('devices')?'
':'')+ + '
上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹
'); }else if(step.type==='wait_el'){ h+=_selRowHtml(p,'
'+ @@ -376,6 +376,22 @@ var _stepEditor={ h+='
'; h+='
只停本设备的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败' +'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」
'; + }else if(step.type==='gesture'){ + var pts=(p.points||[]); + var dur=pts.length?pts[pts.length-1][2]:0; + h+='
'+ + '
'+(pts.length + ? (pts.length+' 个轨迹点 · 时长 '+(dur/1000).toFixed(2)+' 秒 · 起点 ('+pts[0][0]+','+pts[0][1]+')') + : '还没有录制')+'
'; + if(_can('devices')){ + h+='
'; + h+='
'; + } + h+='
'; + h+='
'; + h+='
这是纯录制回放:路径与时长照录制原样重放,不做弧线/抖动/手速那套加工(和「滑动」步骤是两回事)。' + +'手机上录=用手指在真机上划(最真实);网页上录=在弹窗画面里按住鼠标拖。' + +'轨迹按屏幕绝对像素存,换分辨率不同的设备可能偏,同型号/同分辨率最稳
'; }else if(step.type==='wait'){ h+='
'; h+='
'; @@ -518,23 +534,39 @@ var _stepEditor={ if(d)Object.keys(d).forEach(function(k){ if(d[k]!==undefined)params[k]=d[k]; }); return {id:_newStepId(),type:lib.type,label:lib.label,params:params}; }, - // 录制手势:在设备上划一下,按你划的方向/幅度/时长回填这一步 - recordGesture:function(btn){ + // 递归找步骤对象(按 id)——写入录音必须改 _steps,不能只改 DOM + _findStep:function(id,arr){ + var self=this, found=null; + (arr||this._steps).forEach(function(st){ + if(found)return; + if(st.id===id){found=st;return;} + _childArrs(st).forEach(function(a){ if(!found)found=self._findStep(id,a); }); + }); + return found; + }, + // 给某个「录制手势」步骤录制轨迹(src: 'phone' 手机上录 / 'web' 网页上录) + recordInto:function(btn,src){ var card=btn.closest('.step-card'); - if(!card){return;} + var step=card?this._findStep(card.dataset.stepId):null; + if(!step){showToast('找不到这个步骤','error');return;} if(typeof openRecorder!=='function'){showToast('录制器未加载','error');return;} var self=this; - openRecorder({mode:'gesture',onDone:function(p){ - card.querySelectorAll('[data-key]').forEach(function(inp){ - var k=inp.dataset.key; - if(p[k]===undefined)return; - if(inp.type==='checkbox')inp.checked=!!p[k]; - else inp.value=p[k]; - }); - self._syncFromDom(); - showToast('已按你划的手势回填:'+p.direction+' 幅度'+p.distance_ratio,'success'); + openRecorder({mode:'gesture', autoStartPhone: src==='phone', onDone:function(p,from){ + step.params={points:p.points,speed:p.speed||1.0}; + var dur=p.points.length?p.points[p.points.length-1][2]:0; + step.label='录制手势 '+(from==='phone'?'手机':'网页')+' '+p.points.length+'点'; + self._renderCanvas(); + showToast('已录到 '+(p.points.length)+' 个点 / '+(dur/1000).toFixed(2)+' 秒','success'); }}); }, + clearGesture:function(btn){ + var card=btn.closest('.step-card'); + var step=card?this._findStep(card.dataset.stepId):null; + if(!step){return;} + step.params={points:[],speed:1.0}; + this._renderCanvas(); + showToast('已清空录制的轨迹','success'); + }, // 测试此步骤:在选中设备上试执行当前步骤,验证选择器 _testStep:function(btn){ var card=btn.closest('.step-card'); @@ -807,6 +839,10 @@ var _stepEditor={ if(!s.params.text){ warnings.push(name+' "'+label+'": 注入内容为空,步骤会跳过。'); } + }else if(s.type==='gesture'){ + if(!(s.params.points||[]).length){ + warnings.push(name+' "'+label+'": 录制手势还没有轨迹,执行时会被跳过。'); + } }else if(s.type==='if_el'){ if(!s.params.selector_value){ warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。'); diff --git a/static/admin/recorder.js b/static/admin/recorder.js index 35ed3c9..0daf909 100644 --- a/static/admin/recorder.js +++ b/static/admin/recorder.js @@ -1,12 +1,17 @@ // ================== 动作录制器 ================== // 在设备截图上操作(点/划/按键/输入),自动翻译成**步骤**: // · 点 → 命中元素就记「点击元素」(选择器),抓不到就退化成「点击坐标」(百分比) -// · 划 → 记「滑动」:方向 + 幅度(屏占比) + 实际时长 +// · 划 → 记**「录制手势」**:完整轨迹点列 [[x,y,t_ms],…],**纯回放**,不再套滑动逻辑 // · 停顿 → 可选记成「等待」(按真实间隔 ±15%) // -// 两种用法(同一个录窗口): +// 两个录制来源: +// · 网页上录:在这个窗口里按住鼠标拖(采样路径 + 时间) +// · 手机上录:点「手机上录」后**用手指在真机上划**——后端读 getevent 抓真触屏, +// 录到的是人手的真实轨迹(比在网页上模拟更真) +// +// 两种用法(同一个窗口): // mode='action' —— 录一段,存成「自定义动作」(进动作库,能拖进任何任务) -// mode='gesture' —— 只取最后一个手势,回填当前滑动步骤的参数 +// mode='gesture' —— 录到的手势直接回填给调用方(步骤编辑器里的「录制手势」步骤) // // 元素树用 /api/uiauto/snapshot 一次取齐(截图 + 元素),**每次操作后自动刷新**, // 这样下一次点击就能用最新的树翻译选择器(抖音这种没有 id 的界面靠 text/desc)。 @@ -26,6 +31,7 @@ async function openRecorder(opts) { elements: [], devW: 0, devH: 0, imgURL: '', lastTs: 0, dragFrom: null, busy: false, keepPause: true, + samples: [], phone: null, autoPhone: !!opts.autoStartPhone, shots: 0, unstable: false, }; const ov = document.createElement('div'); @@ -66,6 +72,7 @@ async function openRecorder(opts) { document.body.appendChild(ov); await recLoadDevices(); recRender(); + if (_rec.autoPhone && _rec.serial) recStartPhone(); } function closeRecorder() { @@ -88,15 +95,25 @@ async function recLoadDevices() { }); if (_rec.serial) { sel.value = _rec.serial; } else if ((r.items || []).length) { sel.value = r.items[0].serial; _rec.serial = r.items[0].serial; } - if (_rec.serial) recRefresh(); + if (_rec.serial) { await recWake(); recRefresh(); } } -function recPickDevice() { +async function recPickDevice() { const sel = document.getElementById('rec-device'); _rec.serial = sel ? sel.value : ''; _rec.elements = []; recRender(); - if (_rec.serial) recRefresh(); + if (_rec.serial) { await recWake(); recRefresh(); } +} + +// 录制前先唤醒设备:**息屏时 u2 抓 UI 树会退化到几十秒**(实测 65s,唤醒后 3.3s), +// 截图也是一片黑。唤醒是幂等的,多调一次没有副作用。 +async function recWake() { + if (!_rec || !_rec.serial) return; + try { + const r = await apiPost('/api/device/screen_all', { mode: 'on', serials: [_rec.serial] }); + if (r && r.ok) await new Promise(function (z) { setTimeout(z, 800); }); + } catch (e) { /* 唤醒失败也继续,用户自己会看到黑屏 */ } } // 取一张快照:截图 + 元素树(同一个连接背靠背取,避免框错位) @@ -168,6 +185,8 @@ function recDown(e) { const p = _recPos(e); if (!p) return; _rec.dragFrom = p; _rec.dragTo = null; _rec.dragT0 = Date.now(); + // 采样:拖动过程中把每个点连时间一起记下来(这就是"轨迹") + _rec.samples = [[p.x, p.y, 0]]; recPaintOverlay(); } @@ -176,6 +195,11 @@ function recMove(e) { const p = _recPos(e); if (!p) return; _rec.dragTo = p; + const t = Date.now() - _rec.dragT0; + const last = _rec.samples[_rec.samples.length - 1]; + if (!last || t - last[2] >= 8 || Math.abs(p.x - last[0]) + Math.abs(p.y - last[1]) >= 3) { + if (_rec.samples.length < 400) _rec.samples.push([p.x, p.y, t]); + } recPaintOverlay(); } @@ -188,12 +212,60 @@ async function recUp(e) { recPaintOverlay(); const dist = Math.hypot(b.x - a.x, b.y - a.y); if (dist < 12 || durMs < 60) { // 视为点击 + _rec.samples = []; await recTap(a.x, a.y); } else { - await recSwipe(a, b, durMs); + const pts = _rec.samples.slice(); + pts.push([b.x, b.y, durMs]); // 末点补上(决定手势收尾位置) + _rec.samples = []; + recPushGesture(pts, 'web'); + await recRefresh(); } } +// 一条录到的轨迹 → 「录制手势」步骤(**纯回放**:点列+时长原样存下,不再套滑动逻辑) +function recPushGesture(points, src) { + if (!points || points.length < 2) { showToast('没录到有效轨迹(太短了)', 'error'); return; } + if (_rec.mode === 'gesture') { // 回填模式:直接交给调用方,收工 + const cb = _rec.onDone; + closeRecorder(); + if (cb) cb({ points: points, speed: 1.0 }, src); + return; + } + const dur = points[points.length - 1][2] || 0; + _recPush({ type: 'gesture', + label: '录制手势 ' + points.length + '点/' + (dur / 1000).toFixed(1) + 's', + params: { points: points, speed: 1.0 } }); +} + +// ---------- 手机端录制(getevent 抓真手指) ---------- +async function recStartPhone() { + if (!_rec.serial) { showToast('先选设备', 'error'); return; } + const r = await apiPost('/api/gesture/record/start', { serial: _rec.serial }); + if (!r || !r.ok) { showToast((r && r.error) || '开始失败', 'error'); return; } + _rec.phone = { token: r.token, t0: Date.now() }; + showToast('开始录制——现在用手指在手机上划', 'success'); + recRenderFoot(); +} + +async function recStopPhone() { + if (!_rec.phone) return; + const token = _rec.phone.token; + _rec.phone = null; + const r = await apiPost('/api/gesture/record/stop', { token: token }); + if (!r || !r.ok) { showToast((r && r.error) || '停止失败', 'error'); recRenderFoot(); return; } + const gs = r.gestures || []; + if (!gs.length) { + showToast('没录到手势——请在手机屏幕上用指划一下(不是点)', 'error'); + recRenderFoot(); return; + } + if (_rec.mode === 'gesture') { recPushGesture(gs[gs.length - 1], 'phone'); return; } + gs.forEach(function (g) { recPushGesture(g, 'phone'); }); + showToast('录到 ' + gs.length + ' 个手势', 'success'); + recRenderFoot(); + recRefresh(); +} + // ---------- 翻译:点击 → 步骤 ---------- function _recFindElement(x, y) { // 取包含该点的最小元素;优先可点击的(和 /api/screen/tap 的 snap 同一口径) @@ -237,33 +309,6 @@ function _recShort(el) { return s ? ('「' + String(s).slice(0, 12) + '」') : ''; } -// ---------- 翻译:滑动 → 步骤 ---------- -async function recSwipe(a, b, durMs) { - if (!_rec || _rec.busy) return; - _rec.busy = true; - try { - const dx = b.x - a.x, dy = b.y - a.y; - const vertical = Math.abs(dy) >= Math.abs(dx); - const direction = vertical ? (dy < 0 ? 'up' : 'down') : (dx < 0 ? 'left' : 'right'); - const span = Math.abs(vertical ? dy : dx); - const base = vertical ? _rec.devH : _rec.devW; - const ratio = Math.max(0.05, Math.min(0.9, span / base)); - const dur = Math.max(0.08, durMs / 1000); - const step = { type: 'swipe', label: '滑动 ' + direction, - params: { direction: direction, - duration_min: +(dur * 0.9).toFixed(2), - duration_max: +(dur * 1.15).toFixed(2), - distance_ratio: +ratio.toFixed(2), - jitter: 0.15, humanize: true } }; - _recPush(step); - const r = await apiPost('/api/screen/swipe', - { serial: _rec.serial, x1: a.x, y1: a.y, x2: b.x, y2: b.y, - duration: Math.round(durMs) / 1000 }); - if (r && !r.ok) showToast((r.error || '滑动失败'), 'error'); - await recRefresh(); - } finally { _rec.busy = false; } -} - async function recKey(key) { if (!_rec || !_rec.serial) { showToast('先选设备', 'error'); return; } _recPush({ type: 'key_event', label: '按键 ' + key, params: { key: key } }); @@ -338,26 +383,24 @@ function recRenderFoot() { if (!foot) return; const steps = _rec.steps; if (_rec.mode === 'gesture') { - const sw = steps.filter(function (s) { return s.type === 'swipe'; }); - foot.innerHTML = sw.length - ? '' + - '
把方向/幅度/时长回填到当前滑动步骤
' - : '
在画面上划一下(按住拖动),然后点这里
'; + // 回填模式:录到就立刻填回那个步骤,不用再点确认 + foot.innerHTML = _rec.phone + ? '' + : '' + + '
或在左边画面上按住鼠标拖一下——录到就立刻填进那一步
'; return; } + const phoneBtn = _rec.phone + ? '' + : ''; foot.innerHTML = + '
' + phoneBtn + + '' + (_rec.phone + ? '正在录:请用手指在手机屏幕上划,划完点「停止」' + : '手机上用手指划 = 录真手指轨迹;在左边画面上拖 = 网页录制') + '
' + '
' + '
' + - '
存进「自定义动作」库,之后能拖进任何任务的步骤画布
'; -} - -function recApplyGesture() { - const sw = _rec.steps.filter(function (s) { return s.type === 'swipe'; }); - if (!sw.length) return; - const p = sw[sw.length - 1].params; - const cb = _rec.onDone; - closeRecorder(); - if (cb) cb(p); + '
划出来的每一段都是一条「录制手势」步骤(按原路径与时长回放,不再套滑动逻辑)
'; } async function recSaveAsAction() { diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 7bb42ee..8cc7187 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -28,6 +28,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度 wait - 等待时长 notify - 发自定义通知(标题/正文自己写,推送 webhook) stop_self - 停止本设备的任务(其它设备不受影响) + gesture - 回放录制的手势轨迹(纯录制:路径与时长照录制,见 core/gesture.py) loop - 循环块(含 children 步骤列表 + max_iterations) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) """ @@ -40,7 +41,7 @@ from core.device_worker import BaseWorker, _update_status 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, humanize, patrol +from core import notifier, step_log, humanize, patrol, gesture _log = get_logger("task.generic") @@ -117,6 +118,8 @@ STEP_TYPES = [ "params": {"title": "", "message": "", "level": "info"}}, {"type": "stop_self", "label": "停止本设备", "icon": "⛔", "params": {"reason": ""}}, + {"type": "gesture", "label": "录制手势", "icon": "⏺", + "params": {"points": [], "speed": 1.0}}, ] @@ -233,7 +236,11 @@ class GenericStepsWorker(BaseWorker): self._record_step(step, "skip", f"概率 {prob}% 未触发", 0) return self.set_action(f"执行: {label}") - _log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}") + if stype == "gesture": # 点列可能有几十个点,别整段甩进日志 + _log.info(f"[{self.serial}] 步骤: {label}({stype}) " + f"{len(params.get('points') or [])} 个轨迹点") + else: + _log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}") ret = None # 结果归类:异常 > 未命中(handler 返回 False)> 正常 @@ -710,6 +717,20 @@ class GenericStepsWorker(BaseWorker): self.stop() return True + def _exec_gesture(self, d, params, depth=0): + """回放**录制**的手势(路径与时长照录制,不做任何再加工)。 + + 与「滑动」步骤是两套东西:滑动每次重新生成(弧线/抖动/本设备手速), + 这里只把录下来的点列原样交给设备插值(原因见 core/gesture.py 模块头)。 + """ + pts = params.get("points") or [] + if len(pts) < 2: + _log.warning(f"[{self.serial}] gesture 步骤没有有效轨迹(points 为空),跳过") + return None + info = gesture.replay(d, pts, speed=params.get("speed", 1.0)) + _log.info(f"[{self.serial}] 录制回放:{info['points']} 个点 / {info['duration']}s") + return True + def _exec_click(self, d, params, depth=0): sel_type = params.get("selector_type", "xpath") sel_val = params.get("selector_value", "") diff --git a/web/tasks_api.py b/web/tasks_api.py index 5261de6..8e01e05 100644 --- a/web/tasks_api.py +++ b/web/tasks_api.py @@ -215,6 +215,87 @@ def api_groups_delete(name): return jsonify({"ok": False, "error": "分组不存在"}), 404 +# ================== API:录制手势(手机端真手指轨迹) ================== +# +# 用 getevent 读**真触屏**设备抓手指轨迹(见 core/gesture.py)。合成的注入事件不会 +# 出现在真触屏节点上,所以录到的只有人手的动作。 +# 录制器实例存在内存里(单进程;不落库——录完的点列由前端放进步骤/动作再保存)。 + +_gesture_recorders = {} # token -> PhoneRecorder +_gesture_lock = threading.Lock() +_GESTURE_TTL = 300 # 超过 5 分钟自动收摊,避免忘了点"停止" + + +def _gesture_sweep(): + """清掉超时的录制器(停止并释放 getevent 进程)。""" + now = time.time() + with _gesture_lock: + stale = [t for t, r in _gesture_recorders.items() + if now - r._started > _GESTURE_TTL] + for t in stale: + _gesture_recorders.pop(t, None) + for t in stale: + try: + r.stop() + except Exception: + pass + _log.info("录制手势超时自动停止: %s", t) + + +@bp.route("/api/gesture/record/start", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_gesture_record_start(): + """开始录:{serial} → {token}。之后在手机屏幕上用手指划,停止时取回轨迹。""" + from core import gesture + _gesture_sweep() + serial = ((request.json or {}).get("serial") or "").strip() + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + with _gesture_lock: + if _gesture_recorders: + return jsonify({"ok": False, "error": "已有录制在进行中,先停止"}), 409 + try: + w, h = 0, 0 + try: + import uiautomator2 as u2 + from core.u2_helper import screen_size + d = u2.connect(serial) + d.unlock() # 息屏时 u2 抓树会退化到几十秒,先唤醒 + w, h = screen_size(d, serial) + except Exception as e: + _log.warning("录制前取屏幕尺寸失败(按原始坐标范围换算): %s", e) + rec = gesture.PhoneRecorder(serial, w, h) + rec.start() + except Exception as e: + return jsonify({"ok": False, "error": f"开始录制失败: {e}"}), 502 + token = uuid.uuid4().hex[:12] + with _gesture_lock: + _gesture_recorders[token] = rec + _log.info("开始录制手势: %s @ %s(%s)by %s", token, serial, rec.device, + getattr(current_user, "username", "")) + return jsonify({"ok": True, "token": token, "device": rec.device, + "screen": [w, h], + "msg": "现在请用手指在手机上划,划完点「停止」"}) + + +@bp.route("/api/gesture/record/stop", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_gesture_record_stop(): + """停止录制:{token} → {gestures: [[[x,y,t_ms],...], ...]}。""" + token = ((request.json or {}).get("token") or "").strip() + with _gesture_lock: + rec = _gesture_recorders.pop(token, None) + if rec is None: + return jsonify({"ok": False, "error": "录制会话不存在或已超时"}), 404 + try: + gestures = rec.stop() + except Exception as e: + return jsonify({"ok": False, "error": f"停止录制失败: {e}"}), 500 + _log.info("停止录制手势: %s → %s 条", token, len(gestures)) + return jsonify({"ok": True, "gestures": gestures, "count": len(gestures), + "device": rec.device}) + + # ================== API:步骤默认值(「动作配置」页) ================== # # 存 app_meta.step_defaults 一个键(不建表),校验与合并都在 core/step_defaults.py。