Merge branch 'feat/gesture-record'——录制手势改为纯轨迹回放(手机 getevent 真手指录制)

This commit is contained in:
2026-09-20 15:28:30 +08:00
11 changed files with 569 additions and 76 deletions
+5 -4
View File
@@ -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) | 部署与运维(含生产容器、备份导入、故障排查) |
+276
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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。
+2
View File
@@ -126,6 +126,8 @@
| DELETE | `/api/custom_actions/<action_id>` | 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 元素树 |
+1 -1
View File
@@ -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 统计 | 使用者、平台开发者 |
+35 -2
View File
@@ -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 步骤默认值与动作录制(任务 → 动作配置)
+52 -16
View File
@@ -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+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
h+=_humanizeRowHtml(p,'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div>');
h+='<div class="form-row"><div class="form-group">'+(_can('devices')?'<button class="btn btn-sm" onclick="_stepEditor.recordGesture(this)">● 录制手势</button>':'')+''
+'<div class="help">在设备画面上按住划一下,按你划的<b>方向/幅度/时长</b>回填这一步(不想手填参数时用)</div></div></div>';
h+='<div class="help">想要"照我划的那一下原样重放",请用操作库里的《录制手势》步骤——那个是纯轨迹回放,不套这里的参数</div>';
}else if(step.type==='click'){
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
}else if(step.type==='long_click'){
@@ -347,7 +347,7 @@ var _stepEditor={
'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div></div>'+
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
_humanizeChkHtml(p)+
(_can('devices')?'<div class="form-group"><button class="btn btn-sm" onclick="_stepEditor.recordGesture(this)">● 录制手势</button></div>':'')+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹</div>');
}else if(step.type==='wait_el'){
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
@@ -376,6 +376,22 @@ var _stepEditor={
h+='<div class="form-group"><label>停止原因(可留空)</label><input type="text" class="form-control" data-key="reason" value="'+esc(p.reason||'')+'" placeholder="如 连续刷到广告"></div>';
h+='<div class="help">只停<b>本设备</b>的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败'
+'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」</div>';
}else if(step.type==='gesture'){
var pts=(p.points||[]);
var dur=pts.length?pts[pts.length-1][2]:0;
h+='<div class="form-row"><div class="form-group" style="flex:2"><label>录制状态</label>'+
'<div style="padding:6px 0;font-size:13px">'+(pts.length
? (pts.length+' 个轨迹点 · 时长 '+(dur/1000).toFixed(2)+' 秒 · 起点 ('+pts[0][0]+','+pts[0][1]+')')
: '<span class="text-muted">还没有录制</span>')+'</div></div>';
if(_can('devices')){
h+='<div class="form-group"><button class="btn btn-sm btn-primary" onclick="_stepEditor.recordInto(this,\'phone\')">● 手机上录</button></div>';
h+='<div class="form-group"><button class="btn btn-sm" onclick="_stepEditor.recordInto(this,\'web\')">● 网页上录</button></div>';
}
h+='<div class="form-group"><label>速度</label><input type="number" class="form-control" data-key="speed" value="'+(p.speed!=null?p.speed:1.0)+'" step="0.1" min="0.1" max="5"></div>';
h+='<div class="form-group"><button class="btn btn-sm btn-danger" onclick="_stepEditor.clearGesture(this)">清空</button></div></div>';
h+='<div class="help">这是<b>纯录制回放</b>:路径与时长照录制原样重放,不做弧线/抖动/手速那套加工(和「滑动」步骤是两回事)。'
+'<b>手机上录</b>=用手指在真机上划(最真实);<b>网页上录</b>=在弹窗画面里按住鼠标拖。'
+'轨迹按屏幕绝对像素存,换分辨率不同的设备可能偏,同型号/同分辨率最稳</div>';
}else if(step.type==='wait'){
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
@@ -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+'": 条件判断的选择器为空,会直接走"未找到"分支。');
+92 -49
View File
@@ -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
? '<button class="btn btn-primary" onclick="recApplyGesture()">用这个手势(共录到 ' + sw.length + ' 个滑动,取最后一个)</button>' +
'<div class="help">把方向/幅度/时长回填到当前滑动步骤</div>'
: '<div class="text-muted" style="font-size:12px">在画面上划一下(按住拖动),然后点这里</div>';
// 回填模式:录到就立刻填回那个步骤,不用再点确认
foot.innerHTML = _rec.phone
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止并填入</button>'
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>' +
'<div class="help">或在左边画面上按住鼠标拖一下——录到就立刻填进那一步</div>';
return;
}
const phoneBtn = _rec.phone
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止手机录制</button>'
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>';
foot.innerHTML =
'<div class="rec-row" style="margin-bottom:6px">' + phoneBtn +
'<span class="help" style="margin:0">' + (_rec.phone
? '正在录:请用手指在手机屏幕上划,划完点「停止」'
: '手机上用手指划 = 录真手指轨迹;在左边画面上拖 = 网页录制') + '</span></div>' +
'<div class="rec-row"><input id="rec-name" class="form-control" placeholder="动作名,如「抖音-进评论」">' +
'<button class="btn btn-primary" onclick="recSaveAsAction()">存成动作</button></div>' +
'<div class="help">存进「自定义动作」库,之后能拖进任何任务的步骤画布</div>';
}
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);
'<div class="help">划出来的每一段都是一条「录制手势」步骤(按原路径与时长回放,不再套滑动逻辑)</div>';
}
async function recSaveAsAction() {
+23 -2
View File
@@ -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", "")
+81
View File
@@ -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。