dailo-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dailo_sdk-0.1.0/.gitignore +10 -0
- dailo_sdk-0.1.0/LICENSE +21 -0
- dailo_sdk-0.1.0/PKG-INFO +68 -0
- dailo_sdk-0.1.0/README.md +56 -0
- dailo_sdk-0.1.0/pyproject.toml +15 -0
- dailo_sdk-0.1.0/src/dailo/__init__.py +516 -0
- dailo_sdk-0.1.0/src/dailo/_diagnostics.py +96 -0
- dailo_sdk-0.1.0/src/dailo/_observation.py +104 -0
- dailo_sdk-0.1.0/src/dailo/_output.py +30 -0
- dailo_sdk-0.1.0/src/dailo/_runtime.py +61 -0
- dailo_sdk-0.1.0/src/dailo/py.typed +0 -0
dailo_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dailo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dailo_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dailo-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 代劳 Android 自动化脚本 API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: dacite<2,>=1.9
|
|
9
|
+
Requires-Dist: httpx<1,>=0.28
|
|
10
|
+
Requires-Dist: loguru<1,>=0.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# 代劳 Python API
|
|
14
|
+
|
|
15
|
+
`pip install dailo-sdk`,从 `dailo` 导入 API。编辑器可补全、查看文档和跳转源码;脚本在代劳设备中执行。
|
|
16
|
+
|
|
17
|
+
`execution()` 返回当前执行 ID 和 `retry_of_execution_id`。持久化连续失败计数时,只在重跑来源与上次执行 ID 一致时继承计数;手动重启、停止后再启动或更换账号应重新计数。
|
|
18
|
+
|
|
19
|
+
默认诊断记录达到 1 秒的 API 调用或状态驻留,包含近期操作和同帧图文;每次驻留去重,状态改变后重新记录,异常即时记录。单次 `wait_until` 的等待过程最多报告一次耗时,最终调用诊断仍保留。应用安装、权限、启动和停止也纳入耗时记录。
|
|
20
|
+
|
|
21
|
+
统计等待诊断时区分过程与最终异常;两者的 `operation` 都可能是 `wait_until`,`diagnostic` 都是 `slow_api`。按 `reason` 和原执行异常识别终态,不以总条数大于1判节流失败。
|
|
22
|
+
|
|
23
|
+
诊断补采画面不再次执行业务观察守卫,避免同一次异常重复触发延期等状态修改;正常观察和动作仍执行守卫。
|
|
24
|
+
|
|
25
|
+
拖拽轨迹包含起止坐标、移动交付完成、申请松手及松手完成的耗时;移动交付后才开始画面稳定判断,稳定等待失败仍执行松手。系统交付不代表游戏已处理输入,动作结果仍按真实画面确认。
|
|
26
|
+
|
|
27
|
+
守卫实际返回False并阻塞到预算耗尽时才抛出 `ObservationGuardTimeout`,其 `observation` 保留最后拒绝的同帧画面。守卫允许画面但调用预算已耗尽时抛普通`TimeoutError`;不能把普通定位超时记为网络失败或账号延期。
|
|
28
|
+
|
|
29
|
+
定位器的一次调用共用守卫、动作、until结果等待与诊断预算;`wait_until`、画面变化等待与守卫嵌套时继承较早deadline,内部等待只取得剩余时间。复制执行上下文的deadline互相隔离;晚到条件成功仍按超时处理。等待诊断使用本次操作提供的画面及缓存文字;无本次画面时,仅在剩余预算内补采新帧,不额外OCR。采帧/编码截图的时间计入同一预算;耗尽后不再补采,未取得字段为空。日志以`collection=budgeted`和`frameSource=operation / diagnostic / unavailable`说明来源。Python回调和原生调用不会因此被强制中断,不能把这个预算当作硬截止时间。
|
|
30
|
+
|
|
31
|
+
`text` 使用当前画面文字,`region` 对指定区域执行局部文字块识别。数字字段可用 `text(region=区域, line=True)` 读取指定区域的一行文字;同帧区域识别使用 `frame.matches(..., region=区域)`。
|
|
32
|
+
|
|
33
|
+
列表翻页可用 `text(".+", region=名单区域).drag(x1, y1, x2, y2)`,等待名单稳定后松手,避免惯性跨页。`settle=False` 等待系统完成手势,不等待画面稳定;侧视相机或过渡按钮仍需确认可操作状态。`until=目标界面` 按需等待动作结果。
|
|
34
|
+
|
|
35
|
+
`image(path, size=(宽, 高))` 按画面中的尺寸匹配原图,尺寸使用 720p 坐标;缩放和缓存由宿主处理。
|
|
36
|
+
|
|
37
|
+
`color_at(x, y)` 读取 RGB,可判断按钮高亮或可操作区域,不需要图像处理库。
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dailo import app, get_config, node, text
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Settings:
|
|
45
|
+
rounds: int = 5
|
|
46
|
+
|
|
47
|
+
settings = get_config(Settings) # 显式读取 UI 配置,属性可补全
|
|
48
|
+
|
|
49
|
+
app("com.example.game").launch()
|
|
50
|
+
node(text="登录").click() # 默认隐式等待 30 秒
|
|
51
|
+
print(text()) # 读取画面文字,输出显示在设备左下角
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`text("登录")` 定位包含文字的行,`exact=True` 匹配整行;`node`、`image`、`color` 定位控件、图片、颜色,PNG 透明区域不参与匹配。定位结果支持 `click()`、`wait()`、`disappear()`、`is_visible()`;`all()` 立即读取全部目标,需要滚动稳定时显式使用 `settled()`。`入口.click(until=目标界面)` 等目标可操作后点击一次,再等目标界面;`click(offset=(0, 30))` 点击目标中心下方 30 像素。`wait_until(lambda: 条件)` 支持多个合法出口,默认 30 秒。使用 `httpx` 下载文件,`install_app(path)` 安装或更新并保留应用数据。`app(package)` 查询版本、启动、停止及授予权限。`stage(message)` 同步关键阶段;普通 `print` / loguru 日志更新设备,结束时同步结果。`status_position(x, y)` 调整状态行位置(左、下边距,dp)。
|
|
55
|
+
|
|
56
|
+
`settled()`(含 `drag` 的 `settle`)只确认匹配位置稳定,结果可能为空,不保证目标存在或可操作。需要目标存在时先调用 `目标.wait()`;缩放或页面过渡期间,应先等待同帧实际字段或图像出现,再执行手势,并用 `until` 或实际画面条件确认结果。
|
|
57
|
+
|
|
58
|
+
`frame = observe()` 固定一份画面;`frame.text()`、`frame.matches(pattern)`、`frame.images({名称: image(...)})` 和 `frame.pixel(x, y)` 读取同一帧。图像批量匹配可指定 `grayscale=True`,返回边界及相关度。匹配结果保存文字、边界、中心与相关度;取证时帧ID和时间使用所属 `frame.observation_id`、`frame.captured_at_us`。`observe(text=False)` 延后 OCR;`observe(source=path, text=False)` 读取保存的图像。`frame.crop(path, region)` 保存同帧模板,`log(message, image=frame.screenshot)` 保存对应图文日志。
|
|
59
|
+
|
|
60
|
+
数字字段可用 `frame.text(region=区域, line=True, allow_chars="0123456789")`:在原识别模型每步分数上仅选择允许字符与CTC空白,再按原CTC解码;不删除或替换解码后的文字。默认 `allow_chars=None` 不限制字符,空字符串无效。调用方仍须校验完整字段;数字约束不保证业务数值正确。
|
|
61
|
+
|
|
62
|
+
`set_observation_guard(callback)` 在动作前检查同帧画面;返回 False 阻止动作并等画面变化。SDK 默认记录慢调用和超过一秒的状态驻留,包括阶段、实际文字、最近调用及同帧截图。`defer_until(带时区的datetime, reason=原因)` 结束本次任务并表达最早重跑时间。
|
|
63
|
+
|
|
64
|
+
点击、定位器滑动/拖动、填充及按键输入另留一条 `input_action` 文字日志,记录单调时钟微秒的开始/返回时间、目标和 returned/raised;不额外采帧。返回只代表输入 API 完成,业务成效仍需实际后续画面确认。
|
|
65
|
+
|
|
66
|
+
`notifications(after=提交前的time.time(), package_name=包名)` 读取本次提交后的瞬时 Toast / 通知,每项包含文字、应用及 Unix 时间;服务最多保留最近32条,重启清空。提示属于独立事件,不代表当前截图内容。
|
|
67
|
+
|
|
68
|
+
`network_proxy()` 读取 Android `ConnectivityManager` 当前实际生效的代理,返回 host、port、exclusions、pac_url;无代理时返回 `None`。系统设置字段为空不代表内存中的代理已清除。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 代劳 Python API
|
|
2
|
+
|
|
3
|
+
`pip install dailo-sdk`,从 `dailo` 导入 API。编辑器可补全、查看文档和跳转源码;脚本在代劳设备中执行。
|
|
4
|
+
|
|
5
|
+
`execution()` 返回当前执行 ID 和 `retry_of_execution_id`。持久化连续失败计数时,只在重跑来源与上次执行 ID 一致时继承计数;手动重启、停止后再启动或更换账号应重新计数。
|
|
6
|
+
|
|
7
|
+
默认诊断记录达到 1 秒的 API 调用或状态驻留,包含近期操作和同帧图文;每次驻留去重,状态改变后重新记录,异常即时记录。单次 `wait_until` 的等待过程最多报告一次耗时,最终调用诊断仍保留。应用安装、权限、启动和停止也纳入耗时记录。
|
|
8
|
+
|
|
9
|
+
统计等待诊断时区分过程与最终异常;两者的 `operation` 都可能是 `wait_until`,`diagnostic` 都是 `slow_api`。按 `reason` 和原执行异常识别终态,不以总条数大于1判节流失败。
|
|
10
|
+
|
|
11
|
+
诊断补采画面不再次执行业务观察守卫,避免同一次异常重复触发延期等状态修改;正常观察和动作仍执行守卫。
|
|
12
|
+
|
|
13
|
+
拖拽轨迹包含起止坐标、移动交付完成、申请松手及松手完成的耗时;移动交付后才开始画面稳定判断,稳定等待失败仍执行松手。系统交付不代表游戏已处理输入,动作结果仍按真实画面确认。
|
|
14
|
+
|
|
15
|
+
守卫实际返回False并阻塞到预算耗尽时才抛出 `ObservationGuardTimeout`,其 `observation` 保留最后拒绝的同帧画面。守卫允许画面但调用预算已耗尽时抛普通`TimeoutError`;不能把普通定位超时记为网络失败或账号延期。
|
|
16
|
+
|
|
17
|
+
定位器的一次调用共用守卫、动作、until结果等待与诊断预算;`wait_until`、画面变化等待与守卫嵌套时继承较早deadline,内部等待只取得剩余时间。复制执行上下文的deadline互相隔离;晚到条件成功仍按超时处理。等待诊断使用本次操作提供的画面及缓存文字;无本次画面时,仅在剩余预算内补采新帧,不额外OCR。采帧/编码截图的时间计入同一预算;耗尽后不再补采,未取得字段为空。日志以`collection=budgeted`和`frameSource=operation / diagnostic / unavailable`说明来源。Python回调和原生调用不会因此被强制中断,不能把这个预算当作硬截止时间。
|
|
18
|
+
|
|
19
|
+
`text` 使用当前画面文字,`region` 对指定区域执行局部文字块识别。数字字段可用 `text(region=区域, line=True)` 读取指定区域的一行文字;同帧区域识别使用 `frame.matches(..., region=区域)`。
|
|
20
|
+
|
|
21
|
+
列表翻页可用 `text(".+", region=名单区域).drag(x1, y1, x2, y2)`,等待名单稳定后松手,避免惯性跨页。`settle=False` 等待系统完成手势,不等待画面稳定;侧视相机或过渡按钮仍需确认可操作状态。`until=目标界面` 按需等待动作结果。
|
|
22
|
+
|
|
23
|
+
`image(path, size=(宽, 高))` 按画面中的尺寸匹配原图,尺寸使用 720p 坐标;缩放和缓存由宿主处理。
|
|
24
|
+
|
|
25
|
+
`color_at(x, y)` 读取 RGB,可判断按钮高亮或可操作区域,不需要图像处理库。
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from dailo import app, get_config, node, text
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Settings:
|
|
33
|
+
rounds: int = 5
|
|
34
|
+
|
|
35
|
+
settings = get_config(Settings) # 显式读取 UI 配置,属性可补全
|
|
36
|
+
|
|
37
|
+
app("com.example.game").launch()
|
|
38
|
+
node(text="登录").click() # 默认隐式等待 30 秒
|
|
39
|
+
print(text()) # 读取画面文字,输出显示在设备左下角
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`text("登录")` 定位包含文字的行,`exact=True` 匹配整行;`node`、`image`、`color` 定位控件、图片、颜色,PNG 透明区域不参与匹配。定位结果支持 `click()`、`wait()`、`disappear()`、`is_visible()`;`all()` 立即读取全部目标,需要滚动稳定时显式使用 `settled()`。`入口.click(until=目标界面)` 等目标可操作后点击一次,再等目标界面;`click(offset=(0, 30))` 点击目标中心下方 30 像素。`wait_until(lambda: 条件)` 支持多个合法出口,默认 30 秒。使用 `httpx` 下载文件,`install_app(path)` 安装或更新并保留应用数据。`app(package)` 查询版本、启动、停止及授予权限。`stage(message)` 同步关键阶段;普通 `print` / loguru 日志更新设备,结束时同步结果。`status_position(x, y)` 调整状态行位置(左、下边距,dp)。
|
|
43
|
+
|
|
44
|
+
`settled()`(含 `drag` 的 `settle`)只确认匹配位置稳定,结果可能为空,不保证目标存在或可操作。需要目标存在时先调用 `目标.wait()`;缩放或页面过渡期间,应先等待同帧实际字段或图像出现,再执行手势,并用 `until` 或实际画面条件确认结果。
|
|
45
|
+
|
|
46
|
+
`frame = observe()` 固定一份画面;`frame.text()`、`frame.matches(pattern)`、`frame.images({名称: image(...)})` 和 `frame.pixel(x, y)` 读取同一帧。图像批量匹配可指定 `grayscale=True`,返回边界及相关度。匹配结果保存文字、边界、中心与相关度;取证时帧ID和时间使用所属 `frame.observation_id`、`frame.captured_at_us`。`observe(text=False)` 延后 OCR;`observe(source=path, text=False)` 读取保存的图像。`frame.crop(path, region)` 保存同帧模板,`log(message, image=frame.screenshot)` 保存对应图文日志。
|
|
47
|
+
|
|
48
|
+
数字字段可用 `frame.text(region=区域, line=True, allow_chars="0123456789")`:在原识别模型每步分数上仅选择允许字符与CTC空白,再按原CTC解码;不删除或替换解码后的文字。默认 `allow_chars=None` 不限制字符,空字符串无效。调用方仍须校验完整字段;数字约束不保证业务数值正确。
|
|
49
|
+
|
|
50
|
+
`set_observation_guard(callback)` 在动作前检查同帧画面;返回 False 阻止动作并等画面变化。SDK 默认记录慢调用和超过一秒的状态驻留,包括阶段、实际文字、最近调用及同帧截图。`defer_until(带时区的datetime, reason=原因)` 结束本次任务并表达最早重跑时间。
|
|
51
|
+
|
|
52
|
+
点击、定位器滑动/拖动、填充及按键输入另留一条 `input_action` 文字日志,记录单调时钟微秒的开始/返回时间、目标和 returned/raised;不额外采帧。返回只代表输入 API 完成,业务成效仍需实际后续画面确认。
|
|
53
|
+
|
|
54
|
+
`notifications(after=提交前的time.time(), package_name=包名)` 读取本次提交后的瞬时 Toast / 通知,每项包含文字、应用及 Unix 时间;服务最多保留最近32条,重启清空。提示属于独立事件,不代表当前截图内容。
|
|
55
|
+
|
|
56
|
+
`network_proxy()` 读取 Android `ConnectivityManager` 当前实际生效的代理,返回 host、port、exclusions、pac_url;无代理时返回 `None`。系统设置字段为空不代表内存中的代理已清除。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dailo-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "代劳 Android 自动化脚本 API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["httpx>=0.28,<1", "loguru>=0.7,<1", "dacite>=1.9,<2"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/dailo"]
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Android 自动化:显式导入、隐式等待和设备本地进度。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import wraps
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Generic, Literal, NoReturn, Protocol, TypeVar, cast, overload
|
|
12
|
+
|
|
13
|
+
from dacite import from_dict
|
|
14
|
+
|
|
15
|
+
from ._runtime import host, config, state, execution as _execution
|
|
16
|
+
from ._diagnostics import call as _call, record as _record
|
|
17
|
+
from ._observation import Observation, ObservedMatch as ObservedMatch, observe
|
|
18
|
+
|
|
19
|
+
Region = list[int] | tuple[int, int, int, int]
|
|
20
|
+
Key = Literal["back", "home", "recents", "notifications", "quickSettings", "volumeUp", "volumeDown", "mute", "rotate", "screenOn", "screenOff", "powerMenu"]
|
|
21
|
+
_default_timeout = 30.0
|
|
22
|
+
_default_tolerance = 0.05
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
WaitCall = TypeVar("WaitCall", bound=Callable[..., Any])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _wait_budget(action: WaitCall) -> WaitCall:
|
|
29
|
+
"""动作、守卫、结果等待和诊断共用一次调用的预算。"""
|
|
30
|
+
@wraps(action)
|
|
31
|
+
def perform(*args, timeout=None, **kwargs):
|
|
32
|
+
from ._runtime import deadline_scope
|
|
33
|
+
with deadline_scope(monotonic() + (_default_timeout if timeout is None else timeout)):
|
|
34
|
+
_remaining_timeout(timeout)
|
|
35
|
+
return action(*args, timeout=timeout, **kwargs)
|
|
36
|
+
return cast(WaitCall, perform)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _remaining_timeout(timeout: float | None) -> float | None:
|
|
40
|
+
deadline = state().deadline
|
|
41
|
+
if deadline is None:
|
|
42
|
+
return timeout
|
|
43
|
+
remaining = deadline - monotonic()
|
|
44
|
+
if remaining <= 0:
|
|
45
|
+
raise TimeoutError("等待界面条件预算已耗尽")
|
|
46
|
+
return min(remaining, _default_timeout if timeout is None else timeout)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Match(Protocol):
|
|
50
|
+
"""匹配目标的文字和 720p 坐标。"""
|
|
51
|
+
@property
|
|
52
|
+
def text(self) -> str | None: ...
|
|
53
|
+
bounds: tuple[int, int, int, int]
|
|
54
|
+
center: tuple[int, int]
|
|
55
|
+
resource_id: str | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TextMatch(Match, Protocol):
|
|
59
|
+
"""文字定位结果始终含有字符串。"""
|
|
60
|
+
@property
|
|
61
|
+
def text(self) -> str: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Screenshot(Protocol):
|
|
65
|
+
"""当前截图的尺寸和原始 PNG。"""
|
|
66
|
+
width: int
|
|
67
|
+
height: int
|
|
68
|
+
data_base64: str
|
|
69
|
+
content_type: str
|
|
70
|
+
sha256: str
|
|
71
|
+
frame_id: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
MatchType = TypeVar("MatchType", bound=Match)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Locator(Generic[MatchType]):
|
|
78
|
+
"""定位条件。动作自动等待可操作状态,默认 30 秒。"""
|
|
79
|
+
def __init__(self, native: Any, description: Any = "定位目标"):
|
|
80
|
+
self._native = native
|
|
81
|
+
self._description = description
|
|
82
|
+
self._image_query = None
|
|
83
|
+
|
|
84
|
+
@_wait_budget
|
|
85
|
+
def click(self, *, until: Locator | Callable[[], object] | None = None,
|
|
86
|
+
offset: tuple[int, int] | None = None,
|
|
87
|
+
timeout: float | None = None) -> None:
|
|
88
|
+
"""目标唯一且可操作后点击一次;offset 相对中心,until 等待目标界面。"""
|
|
89
|
+
_guard(timeout)
|
|
90
|
+
_call("locator.click", {"locator": self._description, "offset": offset},
|
|
91
|
+
lambda: self._native.click(offset=offset, timeout=_remaining_timeout(timeout)))
|
|
92
|
+
if until is not None:
|
|
93
|
+
wait_until(until.is_visible if isinstance(until, Locator) else until, timeout=timeout)
|
|
94
|
+
|
|
95
|
+
@_wait_budget
|
|
96
|
+
def swipe(self, x1: float, y1: float, x2: float, y2: float, *,
|
|
97
|
+
duration: float = 0.3, timeout: float | None = None) -> None:
|
|
98
|
+
"""等待目标后沿 720p 坐标滑动;duration 是手势时长。"""
|
|
99
|
+
_guard(timeout)
|
|
100
|
+
_call("locator.swipe", {"locator": self._description, "from": [x1, y1], "to": [x2, y2],
|
|
101
|
+
"duration": duration},
|
|
102
|
+
lambda: self._native.swipe(x1, y1, x2, y2, duration=duration, timeout=_remaining_timeout(timeout)))
|
|
103
|
+
|
|
104
|
+
@_wait_budget
|
|
105
|
+
def drag(self, x1: float, y1: float, x2: float, y2: float, *,
|
|
106
|
+
settle: Locator | Literal[False] | None = None,
|
|
107
|
+
until: Locator | Callable[[], object] | None = None,
|
|
108
|
+
timeout: float | None = None) -> tuple[Match, ...]:
|
|
109
|
+
"""拖动并返回稳定后的目标;settle=False 仅完成手势,不等画面稳定。"""
|
|
110
|
+
self.wait(timeout=timeout)
|
|
111
|
+
driver = host()
|
|
112
|
+
target = {"locator": self._description, "from": [x1, y1], "to": [x2, y2],
|
|
113
|
+
"settle": False if settle is False else (settle or self)._description}
|
|
114
|
+
def perform():
|
|
115
|
+
started = monotonic()
|
|
116
|
+
_remaining_timeout(timeout)
|
|
117
|
+
try:
|
|
118
|
+
driver._drag_start(x1, y1, x2, y2)
|
|
119
|
+
target["moveDeliveredMs"] = round((monotonic() - started) * 1000, 1)
|
|
120
|
+
matches = () if settle is False else (settle or self).settled(timeout=timeout)
|
|
121
|
+
finally:
|
|
122
|
+
target["releaseRequestedMs"] = round((monotonic() - started) * 1000, 1)
|
|
123
|
+
driver._drag_end(x2, y2)
|
|
124
|
+
target["releasedMs"] = round((monotonic() - started) * 1000, 1)
|
|
125
|
+
if until is not None:
|
|
126
|
+
wait_until(until.is_visible if isinstance(until, Locator) else until, timeout=timeout)
|
|
127
|
+
return matches
|
|
128
|
+
return _call("locator.drag", target, perform)
|
|
129
|
+
|
|
130
|
+
@_wait_budget
|
|
131
|
+
def wait(self, *, timeout: float | None = None) -> None:
|
|
132
|
+
"""等待目标出现。"""
|
|
133
|
+
_guard(timeout)
|
|
134
|
+
_call("locator.wait", self._description, lambda: self._native.wait(timeout=_remaining_timeout(timeout)))
|
|
135
|
+
|
|
136
|
+
@_wait_budget
|
|
137
|
+
def disappear(self, *, timeout: float | None = None) -> None:
|
|
138
|
+
"""等待目标消失;不需要记忆字符串状态名。"""
|
|
139
|
+
_guard(timeout)
|
|
140
|
+
_call("locator.disappear", self._description, lambda: self._native.wait("hidden", timeout=_remaining_timeout(timeout)))
|
|
141
|
+
|
|
142
|
+
def is_visible(self) -> bool:
|
|
143
|
+
"""立即判断是否存在匹配目标,不等待。"""
|
|
144
|
+
_guard()
|
|
145
|
+
return _call("locator.visible", self._description, self._native.is_visible)
|
|
146
|
+
|
|
147
|
+
@_wait_budget
|
|
148
|
+
def snapshot(self, *, timeout: float | None = None) -> MatchType:
|
|
149
|
+
"""等待并读取匹配目标的文字、边界及中心坐标。"""
|
|
150
|
+
_guard(timeout)
|
|
151
|
+
return _call("locator.snapshot", self._description, lambda: self._native.snapshot(timeout=_remaining_timeout(timeout)))
|
|
152
|
+
|
|
153
|
+
def all(self) -> tuple[MatchType, ...]:
|
|
154
|
+
"""立即读取当前全部目标;空列表返回空元组。"""
|
|
155
|
+
_guard()
|
|
156
|
+
return _call("locator.all", self._description, self._native.all)
|
|
157
|
+
|
|
158
|
+
@_wait_budget
|
|
159
|
+
def settled(self, *, timeout: float | None = None) -> tuple[MatchType, ...]:
|
|
160
|
+
"""返回位置稳定的匹配,可能为空;需要目标存在时先wait()或等待同帧条件。"""
|
|
161
|
+
_guard(timeout)
|
|
162
|
+
return _call("locator.settled", self._description, lambda: self._native.settled(timeout=_remaining_timeout(timeout)))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class NodeLocator(Locator[Match]):
|
|
166
|
+
"""Android 无障碍控件定位。"""
|
|
167
|
+
@_wait_budget
|
|
168
|
+
def fill(self, value: str, *, timeout: float | None = None) -> None:
|
|
169
|
+
"""等待可编辑控件并替换文字。"""
|
|
170
|
+
_guard(timeout)
|
|
171
|
+
_call("node.fill", self._description, lambda: self._native.fill(value, timeout=_remaining_timeout(timeout)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def node(*, text: str | None = None, resource_id: str | None = None,
|
|
175
|
+
description: str | None = None, class_name: str | None = None,
|
|
176
|
+
package_name: str | None = None, hint: str | None = None,
|
|
177
|
+
clickable: bool | None = None, editable: bool | None = None,
|
|
178
|
+
enabled: bool | None = None, checkable: bool | None = None,
|
|
179
|
+
checked: bool | None = None) -> NodeLocator:
|
|
180
|
+
"""按 Android 控件属性定位;字符串为完整匹配的正则表达式。"""
|
|
181
|
+
params = locals()
|
|
182
|
+
return NodeLocator(host().node(**params), {"node": params})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@overload
|
|
186
|
+
def text(pattern: None = None, *, region: Region | None = None, exact: bool = False,
|
|
187
|
+
line: bool = False) -> str: ...
|
|
188
|
+
@overload
|
|
189
|
+
def text(pattern: str, *, region: Region | None = None, exact: bool = False,
|
|
190
|
+
line: bool = False) -> Locator[TextMatch]: ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def text(pattern: str | None = None, *, region: Region | None = None, exact: bool = False,
|
|
194
|
+
line: bool = False) -> str | Locator[TextMatch]:
|
|
195
|
+
"""无参数读取文字,有参数定位文字。region 指定局部识别区域,exact 匹配整行。
|
|
196
|
+
|
|
197
|
+
line=True 将给定区域作为单行读取,适合计数、余额等数字字段。
|
|
198
|
+
"""
|
|
199
|
+
if pattern is None:
|
|
200
|
+
_guard()
|
|
201
|
+
return _call("text", {"region": region, "line": line}, lambda: host().text(".+", region=region, line=line).read_text())
|
|
202
|
+
return Locator(host().text(pattern, region=region, exact=exact, line=line), {"text": pattern, "region": region})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def image(path: str | Path, *, region: Region | None = None,
|
|
206
|
+
tolerance: float | None = None, size: tuple[int, int] | None = None) -> Locator[Match]:
|
|
207
|
+
"""定位图片;size 指定模板在 720p 画面中的宽、高,PNG 透明区域不参与匹配。"""
|
|
208
|
+
resolved = str(Path(path).resolve())
|
|
209
|
+
locator = Locator(host().image(resolved, region=region, tolerance=tolerance, size=size), {"image": resolved, "region": region})
|
|
210
|
+
locator._image_query = {"path": resolved, "region": region, "tolerance": _default_tolerance if tolerance is None else tolerance, "size": size}
|
|
211
|
+
return locator
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def color(points: str, *, region: Region | None = None,
|
|
215
|
+
tolerance: float | None = None) -> Locator[Match]:
|
|
216
|
+
"""定位颜色点组合,坐标使用设备等比例 720p 画面。"""
|
|
217
|
+
return Locator(host().color(points, region=region, tolerance=tolerance), {"color": points, "region": region})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def color_at(x: int, y: int) -> tuple[int, int, int]:
|
|
221
|
+
"""读取 720p 坐标处的 RGB(0–255),可判断高亮、灰色和可操作区域。"""
|
|
222
|
+
result = host().pixel(x, y)
|
|
223
|
+
if not result.found:
|
|
224
|
+
raise ValueError("取色坐标不在当前画面内")
|
|
225
|
+
return result.color.r, result.color.g, result.color.b
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def screenshot() -> Screenshot:
|
|
229
|
+
"""取得当前等比例 720p 画面(width、height、data_base64)。"""
|
|
230
|
+
result = host().screenshot()
|
|
231
|
+
if not result.ok:
|
|
232
|
+
raise RuntimeError(result.error or "截图失败")
|
|
233
|
+
return cast(Screenshot, result)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
ConfigType = TypeVar("ConfigType")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@overload
|
|
240
|
+
def get_config(schema: type[ConfigType]) -> ConfigType: ...
|
|
241
|
+
@overload
|
|
242
|
+
def get_config(schema: None = None) -> dict[str, Any]: ...
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_config(schema: type[ConfigType] | None = None) -> ConfigType | dict[str, Any]:
|
|
246
|
+
"""读取 UI 设置;传入 dataclass 类型即可获得属性补全、默认值和嵌套配置。
|
|
247
|
+
|
|
248
|
+
例如:settings = get_config(Settings)。配置不会注入脚本全局变量。
|
|
249
|
+
"""
|
|
250
|
+
values = config()
|
|
251
|
+
return from_dict(schema, values) if schema is not None else values.copy()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class AppInfo:
|
|
256
|
+
"""已安装应用的版本;未安装时 installed=False。"""
|
|
257
|
+
package_name: str
|
|
258
|
+
installed: bool
|
|
259
|
+
version_name: str = ""
|
|
260
|
+
version_code: int = 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _app_call(operation: str, **params: Any) -> dict[str, Any]:
|
|
264
|
+
def invoke():
|
|
265
|
+
result = json.loads(host()._app_operation(json.dumps({"operation": operation, **params})))
|
|
266
|
+
if not result.get("ok"):
|
|
267
|
+
raise RuntimeError(result.get("error", "应用操作失败"))
|
|
268
|
+
return result
|
|
269
|
+
return _call(f"app.{operation}", params, invoke)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class NetworkProxy:
|
|
274
|
+
"""Android 当前网络实际生效的 HTTP / PAC 代理。"""
|
|
275
|
+
host: str
|
|
276
|
+
port: int
|
|
277
|
+
exclusions: tuple[str, ...]
|
|
278
|
+
pac_url: str
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def network_proxy() -> NetworkProxy | None:
|
|
282
|
+
"""读取 ConnectivityManager 的实际代理;无代理时返回 None。"""
|
|
283
|
+
value = _app_call("network_proxy")["proxy"]
|
|
284
|
+
return None if value is None else NetworkProxy(value["host"], value["port"],
|
|
285
|
+
tuple(value["exclusions"]), value["pacUrl"])
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class Notification:
|
|
290
|
+
"""Android 无障碍收到的 Toast / 通知;timestamp 为 Unix 秒。"""
|
|
291
|
+
package_name: str
|
|
292
|
+
text: str
|
|
293
|
+
timestamp: float
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def notifications(*, after: float = 0.0, package_name: str | None = None) -> tuple[Notification, ...]:
|
|
297
|
+
"""读取服务本次启动以来最近 32 条提示;after 排除该时间及更早事件。"""
|
|
298
|
+
result = _app_call("notifications", after=after, package=package_name or "")
|
|
299
|
+
return tuple(Notification(event["packageName"], event["text"], event["timestamp"])
|
|
300
|
+
for event in result["notifications"])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class App:
|
|
304
|
+
"""宿主管理应用;安装、停止和权限自动选择 root 或无障碍方式。"""
|
|
305
|
+
def __init__(self, package_name: str):
|
|
306
|
+
self.package_name = package_name
|
|
307
|
+
|
|
308
|
+
def info(self) -> AppInfo:
|
|
309
|
+
"""查询安装状态、versionName 和 versionCode。"""
|
|
310
|
+
value = _app_call("info", package=self.package_name)
|
|
311
|
+
return AppInfo(self.package_name, value["installed"], value.get("versionName", ""), value.get("versionCode", 0))
|
|
312
|
+
|
|
313
|
+
def launch(self) -> None:
|
|
314
|
+
"""启动应用。"""
|
|
315
|
+
result = _call("app.launch", self.package_name, lambda: host().launch_app(self.package_name))
|
|
316
|
+
if not result.ok:
|
|
317
|
+
raise RuntimeError(result.error or "应用启动失败")
|
|
318
|
+
|
|
319
|
+
def stop(self) -> None:
|
|
320
|
+
"""停止应用,保留数据;无 root 时在系统设置中强行停止。"""
|
|
321
|
+
_app_call("stop", package=self.package_name)
|
|
322
|
+
|
|
323
|
+
def grant_permissions(self) -> None:
|
|
324
|
+
"""授予应用声明的运行时权限,宿主处理 Android 系统界面。"""
|
|
325
|
+
_app_call("permissions", package=self.package_name)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def app(package_name: str) -> App:
|
|
329
|
+
"""管理指定 Android 包名的应用。"""
|
|
330
|
+
return App(package_name)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def install_app(path: str | Path) -> AppInfo:
|
|
334
|
+
"""安装或更新本地 APK,保留应用数据并等待 Android 安装完成。"""
|
|
335
|
+
result = _app_call("install", path=str(Path(path).resolve()))
|
|
336
|
+
return app(result["package"]).info()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def foreground_app() -> str:
|
|
340
|
+
"""返回当前前台应用包名,区别于安装信息 app(package).info()。"""
|
|
341
|
+
result = host().current_app()
|
|
342
|
+
if not result.ok:
|
|
343
|
+
raise RuntimeError(result.error or "前台应用读取失败")
|
|
344
|
+
return result.package_name
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def stage(message: str) -> None:
|
|
348
|
+
"""显示并同步关键任务阶段;高频进度使用 print。"""
|
|
349
|
+
state().expected = message
|
|
350
|
+
print(message, flush=True)
|
|
351
|
+
host()._progress(message, None, "{}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def status_position(x: int = 12, y: int = 12) -> None:
|
|
355
|
+
"""设置状态行的左、下边距(dp);默认左下角。"""
|
|
356
|
+
host()._status_position(x, y)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class DailoScriptFail(Exception):
|
|
360
|
+
"""携带宿主可识别的任务失败信息。"""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class DailoTaskDeferred(Exception):
|
|
364
|
+
"""任务到指定时间才允许重新执行;宿主持久化此约束。"""
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True)
|
|
368
|
+
class ExecutionInfo:
|
|
369
|
+
execution_id: str | None
|
|
370
|
+
retry_of_execution_id: str | None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def execution() -> ExecutionInfo:
|
|
374
|
+
"""当前执行与重跑来源,用于区分连续延期链和重新开始的任务。"""
|
|
375
|
+
return ExecutionInfo(**_execution())
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def defer_until(when: datetime, *, reason: str = "") -> NoReturn:
|
|
379
|
+
"""结束本次执行并设置任务最早重跑时间,when 必须包含时区。"""
|
|
380
|
+
if when.tzinfo is None:
|
|
381
|
+
raise ValueError("defer_until 时间必须包含时区")
|
|
382
|
+
raise DailoTaskDeferred(json.dumps({"message": reason, "notBeforeAtMs": int(when.astimezone(timezone.utc).timestamp() * 1000)}, ensure_ascii=False))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def log(message: str, *, image: Screenshot | None = None) -> None:
|
|
386
|
+
"""完整保存文字与提供的同帧PNG;单条文字为1—65536个UTF-8字节。"""
|
|
387
|
+
payload = None if image is None else json.dumps({"contentType": image.content_type,
|
|
388
|
+
"dataBase64": image.data_base64, "sha256": image.sha256, "width": image.width, "height": image.height})
|
|
389
|
+
host()._log(message, payload)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def set_observation_guard(callback: Callable[[Observation], bool | None] | None) -> None:
|
|
393
|
+
"""注册动作前的全局观察;返回 False 阻止动作,继续等待画面变化。"""
|
|
394
|
+
state().guard = callback
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ObservationGuardTimeout(TimeoutError):
|
|
398
|
+
"""全局观察阻塞超时;observation 是阻塞结束前的最后同帧证据。"""
|
|
399
|
+
def __init__(self, observation: Observation):
|
|
400
|
+
super().__init__("全局观察阻止动作,等待界面恢复超时")
|
|
401
|
+
self.observation = observation
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _guard(timeout: float | None = None) -> Observation | None:
|
|
405
|
+
execution = state()
|
|
406
|
+
if execution.guard is None or execution.guarding:
|
|
407
|
+
return
|
|
408
|
+
execution.guarding = True
|
|
409
|
+
deadline = monotonic() + (_default_timeout if timeout is None else timeout)
|
|
410
|
+
try:
|
|
411
|
+
from ._runtime import deadline_scope
|
|
412
|
+
with deadline_scope(deadline) as deadline:
|
|
413
|
+
blocked = None
|
|
414
|
+
while True:
|
|
415
|
+
if monotonic() >= deadline:
|
|
416
|
+
if blocked is not None:
|
|
417
|
+
raise ObservationGuardTimeout(blocked)
|
|
418
|
+
raise TimeoutError("等待界面条件预算已耗尽")
|
|
419
|
+
frame = observe(text=False)
|
|
420
|
+
permitted = execution.guard(frame) is not False
|
|
421
|
+
remaining = deadline - monotonic()
|
|
422
|
+
if permitted:
|
|
423
|
+
if remaining <= 0:
|
|
424
|
+
raise TimeoutError("观察守卫允许当前画面,但等待预算已耗尽")
|
|
425
|
+
return frame
|
|
426
|
+
blocked = frame
|
|
427
|
+
if remaining <= 0:
|
|
428
|
+
raise ObservationGuardTimeout(frame)
|
|
429
|
+
host().wait_for_frame(min(remaining, 1))
|
|
430
|
+
finally:
|
|
431
|
+
execution.guarding = False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def fail(message: str, *, code: str = "script_failed",
|
|
435
|
+
failure_kind: Literal["product", "user"] = "product") -> NoReturn:
|
|
436
|
+
"""以明确原因结束任务;user 表示用户配置或账号需要处理。"""
|
|
437
|
+
raise DailoScriptFail(json.dumps({"message": message, "code": code, "failureKind": failure_kind}, ensure_ascii=False))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def wait_for_frame() -> None:
|
|
441
|
+
"""隐式等待画面变化,避免状态机忙循环。"""
|
|
442
|
+
from ._runtime import deadline_scope
|
|
443
|
+
with deadline_scope(monotonic() + 30):
|
|
444
|
+
def wait():
|
|
445
|
+
if not host().wait_for_frame(_remaining_timeout(30)):
|
|
446
|
+
raise TimeoutError("等待画面变化超时")
|
|
447
|
+
_call("wait_for_frame", "画面变化", wait)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
WaitResult = TypeVar("WaitResult")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def wait_until(condition: Callable[[], WaitResult | None], *, timeout: float | None = None) -> WaitResult:
|
|
454
|
+
"""隐式等待条件返回真值并返回该值;用于界面有多个合法出口的状态机。"""
|
|
455
|
+
started = monotonic()
|
|
456
|
+
deadline = started + (_default_timeout if timeout is None else timeout)
|
|
457
|
+
def wait():
|
|
458
|
+
slow_reported = False
|
|
459
|
+
while True:
|
|
460
|
+
result = condition()
|
|
461
|
+
if monotonic() >= deadline:
|
|
462
|
+
raise TimeoutError("等待界面条件超时")
|
|
463
|
+
if result:
|
|
464
|
+
return result
|
|
465
|
+
if not slow_reported and monotonic() - started >= 1:
|
|
466
|
+
_record("wait_until", "界面条件", started)
|
|
467
|
+
slow_reported = True
|
|
468
|
+
remaining = deadline - monotonic()
|
|
469
|
+
if remaining <= 0:
|
|
470
|
+
raise TimeoutError("等待界面条件超时")
|
|
471
|
+
host().wait_for_frame(min(remaining, 1))
|
|
472
|
+
from ._runtime import deadline_scope
|
|
473
|
+
with deadline_scope(deadline) as deadline:
|
|
474
|
+
return _call("wait_until", "界面条件", wait)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@_wait_budget
|
|
478
|
+
def click(x: float, y: float, *, until: Locator | Callable[[], object] | None = None,
|
|
479
|
+
timeout: float | None = None) -> None:
|
|
480
|
+
"""点击 720p 坐标一次;可用 until 等待目标界面,优先使用定位器。"""
|
|
481
|
+
_guard(timeout)
|
|
482
|
+
_call("click", [x, y], lambda: host().click(x, y))
|
|
483
|
+
if until is not None:
|
|
484
|
+
wait_until(until.is_visible if isinstance(until, Locator) else until, timeout=timeout)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def press_key(key: Key | int) -> None:
|
|
488
|
+
"""发送系统动作(如 'back'、'home')或 Android 整数键码。"""
|
|
489
|
+
_guard()
|
|
490
|
+
_call("press_key", key, lambda: host().press_key(key))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def swipe(x1: float, y1: float, x2: float, y2: float, *, duration: float = 0.3) -> None:
|
|
494
|
+
"""沿 720p 坐标滑动;优先使用定位器的 swipe。"""
|
|
495
|
+
_guard()
|
|
496
|
+
_call("swipe", [x1, y1, x2, y2], lambda: host().touch_swipe(x1, y1, x2, y2, duration))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def type_text(value: str) -> None:
|
|
500
|
+
"""向当前输入控件输入文字;优先使用 node(...).fill。"""
|
|
501
|
+
_guard()
|
|
502
|
+
_call("type_text", "当前控件", lambda: host().type_text(value))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def set_default_tolerance(value: float) -> None:
|
|
506
|
+
"""调整图片和颜色定位的默认容差。"""
|
|
507
|
+
host().set_default_tolerance(value)
|
|
508
|
+
global _default_tolerance
|
|
509
|
+
_default_tolerance = value
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def set_default_timeout(seconds: float = 30) -> None:
|
|
513
|
+
"""调整定位动作的默认隐式等待上限。"""
|
|
514
|
+
host().set_default_timeout(seconds)
|
|
515
|
+
global _default_timeout
|
|
516
|
+
_default_timeout = seconds
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""诊断区分本次操作帧与预算内补采帧,不复用之前操作的画面。"""
|
|
2
|
+
from collections import deque
|
|
3
|
+
from time import monotonic
|
|
4
|
+
|
|
5
|
+
from ._runtime import state
|
|
6
|
+
|
|
7
|
+
_INPUT_OPERATIONS = frozenset({
|
|
8
|
+
"click", "locator.click", "swipe", "locator.swipe", "locator.drag",
|
|
9
|
+
"node.fill", "press_key", "type_text",
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Trace:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.recent = deque(maxlen=24)
|
|
16
|
+
self.actual = None
|
|
17
|
+
self.since = monotonic()
|
|
18
|
+
self.reported = False
|
|
19
|
+
self.reporting = False
|
|
20
|
+
|
|
21
|
+
def record(self, operation, target, elapsed, actual=None, frame=None, error=None, *, started=None):
|
|
22
|
+
if self.reporting:
|
|
23
|
+
return
|
|
24
|
+
now = monotonic()
|
|
25
|
+
self.recent.append({"operation": operation, "target": target, "ms": round(elapsed * 1000, 1)})
|
|
26
|
+
if actual is not None and actual != self.actual:
|
|
27
|
+
self.actual, self.since, self.reported = actual, now, False
|
|
28
|
+
dwell = now - self.since
|
|
29
|
+
diagnostic = error is not None or not (self.reported or (elapsed < 1 and dwell < 1))
|
|
30
|
+
input_action = operation in _INPUT_OPERATIONS
|
|
31
|
+
if not input_action and not diagnostic:
|
|
32
|
+
return
|
|
33
|
+
self.reporting = True
|
|
34
|
+
try:
|
|
35
|
+
from . import log
|
|
36
|
+
import json
|
|
37
|
+
if input_action:
|
|
38
|
+
log(json.dumps({"diagnostic": "input_action", "operation": operation,
|
|
39
|
+
"target": target, "startedAtUs": round((now - elapsed if started is None else started) * 1000000),
|
|
40
|
+
"finishedAtUs": round(now * 1000000), "elapsedMs": round(elapsed * 1000, 1),
|
|
41
|
+
"outcome": "raised" if error is not None else "returned", "error": error}, ensure_ascii=False))
|
|
42
|
+
if not diagnostic:
|
|
43
|
+
return
|
|
44
|
+
self.reported = True
|
|
45
|
+
from ._observation import Observation
|
|
46
|
+
from ._runtime import host
|
|
47
|
+
# 诊断取图不重跑业务守卫,避免同一次异常再次触发延期等状态修改。
|
|
48
|
+
budgeted = state().deadline is not None
|
|
49
|
+
if budgeted:
|
|
50
|
+
# 只复用本次操作帧;无帧时在剩余预算内补采,不额外OCR。
|
|
51
|
+
captured = frame
|
|
52
|
+
if captured is None and monotonic() < state().deadline:
|
|
53
|
+
captured = Observation(host()._observe(False, None), None)
|
|
54
|
+
lines = getattr(captured, '_lines', None)
|
|
55
|
+
current = None if lines is None else "\n".join(item['text'] for item in lines)
|
|
56
|
+
screenshot = getattr(captured, '_screenshot', None)
|
|
57
|
+
if captured is not None and screenshot is None and monotonic() < state().deadline:
|
|
58
|
+
screenshot = captured.screenshot
|
|
59
|
+
else:
|
|
60
|
+
captured = frame if frame is not None else Observation(host()._observe(False, None), None)
|
|
61
|
+
# 未提供本次观察时,读取新观察的文字,不拼接旧文字与新图。
|
|
62
|
+
current = captured.text()
|
|
63
|
+
screenshot = captured.screenshot
|
|
64
|
+
log(json.dumps({"diagnostic": "slow_api" if elapsed >= 1 else "state_residency",
|
|
65
|
+
"expected": state().expected, "actual": current,
|
|
66
|
+
"observationId": captured.observation_id if captured is not None else None,
|
|
67
|
+
"capturedAtUs": captured.captured_at_us if captured is not None else None,
|
|
68
|
+
"sourcePath": str(captured.source_path) if captured is not None and captured.source_path else None,
|
|
69
|
+
"collection": "budgeted" if budgeted else "captured",
|
|
70
|
+
"frameSource": "operation" if frame is not None else "diagnostic" if captured is not None else "unavailable",
|
|
71
|
+
"remainingMs": max(0, round((state().deadline - monotonic()) * 1000, 1)) if budgeted else None,
|
|
72
|
+
"operation": operation, "target": target, "elapsedMs": round(elapsed * 1000, 1),
|
|
73
|
+
"residencyMs": round(dwell * 1000, 1), "reason": error or ("观察耗时" if elapsed >= 1 else "等待状态进展"),
|
|
74
|
+
"recent": list(self.recent)}, ensure_ascii=False), image=screenshot)
|
|
75
|
+
except Exception as diagnostic_error:
|
|
76
|
+
print(f"诊断画面读取失败:{diagnostic_error}", flush=True)
|
|
77
|
+
finally:
|
|
78
|
+
self.reporting = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def record(operation, target, started, actual=None, frame=None, error=None):
|
|
82
|
+
execution = state()
|
|
83
|
+
if execution.trace is None:
|
|
84
|
+
execution.trace = Trace()
|
|
85
|
+
execution.trace.record(operation, target, monotonic() - started, actual, frame, error, started=started)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def call(operation, target, action, *, actual=None):
|
|
89
|
+
started = monotonic()
|
|
90
|
+
try:
|
|
91
|
+
result = action()
|
|
92
|
+
except Exception as error:
|
|
93
|
+
record(operation, target, started, error=str(error))
|
|
94
|
+
raise
|
|
95
|
+
record(operation, target, started, actual(result) if actual else None)
|
|
96
|
+
return result
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""文字、图像与像素共享一份不可变画面。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from time import monotonic
|
|
9
|
+
|
|
10
|
+
from ._runtime import host
|
|
11
|
+
from ._diagnostics import record
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ObservedMatch:
|
|
16
|
+
bounds: tuple[int, int, int, int]
|
|
17
|
+
text: str | None = None
|
|
18
|
+
score: float = 1.0
|
|
19
|
+
resource_id: str | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def center(self) -> tuple[int, int]:
|
|
23
|
+
left, top, right, bottom = self.bounds
|
|
24
|
+
return (left + right) // 2, (top + bottom) // 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Observation:
|
|
28
|
+
"""同帧读取;source_path 非空表示保存的图像,不代表当前设备画面。"""
|
|
29
|
+
def __init__(self, native, source_path: Path | None):
|
|
30
|
+
self._native = native
|
|
31
|
+
self.source_path = source_path
|
|
32
|
+
self._screenshot = None
|
|
33
|
+
self._lines = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def observation_id(self) -> int:
|
|
37
|
+
return self._native.frame_id
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def captured_at_us(self) -> int:
|
|
41
|
+
return self._native.captured_at_us
|
|
42
|
+
|
|
43
|
+
def _read(self, region=None, line=False, allow_chars=None):
|
|
44
|
+
started = monotonic()
|
|
45
|
+
value = json.loads(self._native.text_json(region, line, allow_chars))
|
|
46
|
+
if not value.get("ok"):
|
|
47
|
+
raise RuntimeError(value.get("error", "同帧文字读取失败"))
|
|
48
|
+
lines = value.get("lines", [])
|
|
49
|
+
if not line and region is None and allow_chars is None:
|
|
50
|
+
self._lines = lines
|
|
51
|
+
actual = "\n".join(item["text"] for item in lines)
|
|
52
|
+
record("observation.text", {"region": region, "line": line, "allow_chars": allow_chars}, started,
|
|
53
|
+
actual=actual if region is None and allow_chars is None else None, frame=self)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
def text(self, *, region=None, line=False, allow_chars: str | None = None) -> str:
|
|
57
|
+
"""allow_chars 在原CTC分数上约束字符候选;默认不限制字符。"""
|
|
58
|
+
value = self._read(region, line, allow_chars)
|
|
59
|
+
return value.get("text", "") if line else "\n".join(item["text"] for item in value.get("lines", []))
|
|
60
|
+
|
|
61
|
+
def matches(self, pattern: str, *, region=None, exact=False) -> tuple[ObservedMatch, ...]:
|
|
62
|
+
expression = re.compile(pattern)
|
|
63
|
+
found = []
|
|
64
|
+
for item in self._read(region).get("lines", []):
|
|
65
|
+
if (expression.fullmatch if exact else expression.search)(item["text"]):
|
|
66
|
+
area = item["rect"]
|
|
67
|
+
found.append(ObservedMatch(tuple(area[key] for key in ("left", "top", "right", "bottom")), item["text"]))
|
|
68
|
+
return tuple(found)
|
|
69
|
+
|
|
70
|
+
def images(self, queries: dict, *, grayscale=False) -> dict[str, tuple[ObservedMatch, ...]]:
|
|
71
|
+
started = monotonic()
|
|
72
|
+
request = {name: locator._image_query for name, locator in queries.items()}
|
|
73
|
+
values = json.loads(self._native.images_json(json.dumps(request), grayscale))
|
|
74
|
+
result = {name: tuple(ObservedMatch(tuple(item["bounds"]), score=item["score"]) for item in matches)
|
|
75
|
+
for name, matches in values.items()}
|
|
76
|
+
record("observation.images", list(queries), started, frame=self)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def pixel(self, x: int, y: int) -> tuple[int, int, int]:
|
|
80
|
+
return tuple(self._native.pixel(x, y))
|
|
81
|
+
|
|
82
|
+
def crop(self, path: str | Path, region) -> Path:
|
|
83
|
+
target = Path(path).resolve()
|
|
84
|
+
self._native.crop(str(target), list(region))
|
|
85
|
+
return target
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def screenshot(self):
|
|
89
|
+
if self._screenshot is None:
|
|
90
|
+
self._screenshot = self._native.snapshot()
|
|
91
|
+
if not self._screenshot.ok:
|
|
92
|
+
raise RuntimeError(self._screenshot.error or "同帧截图失败")
|
|
93
|
+
return self._screenshot
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def observe(*, text=True, source: str | Path | None = None) -> Observation:
|
|
97
|
+
started = monotonic()
|
|
98
|
+
path = Path(source).resolve() if source is not None else None
|
|
99
|
+
from . import _guard
|
|
100
|
+
guarded = _guard() if path is None else None
|
|
101
|
+
value = guarded or Observation(host()._observe(text, str(path) if path else None), path)
|
|
102
|
+
actual = value.text() if text else None
|
|
103
|
+
record("observe", {"text": text, "source": str(path) if path else None}, started, actual=actual, frame=value)
|
|
104
|
+
return value
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Capture print/loguru output without streaming each line to the controller."""
|
|
2
|
+
from ._runtime import host
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Output:
|
|
6
|
+
encoding = "utf-8"
|
|
7
|
+
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._value = ""
|
|
10
|
+
self._line = ""
|
|
11
|
+
|
|
12
|
+
def write(self, value: str) -> int:
|
|
13
|
+
self._value = (self._value + value)[-65536:]
|
|
14
|
+
self._line = (self._line + value)[-65536:]
|
|
15
|
+
while "\n" in self._line or "\r" in self._line:
|
|
16
|
+
line, separator, rest = self._line.replace("\r", "\n").partition("\n")
|
|
17
|
+
self._line = rest
|
|
18
|
+
if line.strip():
|
|
19
|
+
host()._status(line)
|
|
20
|
+
return len(value)
|
|
21
|
+
|
|
22
|
+
def flush(self) -> None:
|
|
23
|
+
if self._line.strip():
|
|
24
|
+
host()._status(self._line)
|
|
25
|
+
|
|
26
|
+
def getvalue(self) -> str:
|
|
27
|
+
return self._value
|
|
28
|
+
|
|
29
|
+
def isatty(self) -> bool:
|
|
30
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""由设备宿主绑定执行上下文,编辑器和构建无需设备。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_context: ContextVar[tuple[Any, dict[str, Any]]] = ContextVar("dailo_execution")
|
|
9
|
+
_state: ContextVar[State] = ContextVar("dailo_execution_state")
|
|
10
|
+
_execution: ContextVar[dict[str, Any]] = ContextVar("dailo_execution_info")
|
|
11
|
+
_deadline: ContextVar[float | None] = ContextVar("dailo_wait_deadline", default=None)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class State:
|
|
16
|
+
guard: Any = None
|
|
17
|
+
guarding: bool = False
|
|
18
|
+
trace: Any = None
|
|
19
|
+
expected: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def deadline(self) -> float | None:
|
|
23
|
+
return _deadline.get()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def deadline_scope(deadline: float):
|
|
28
|
+
"""嵌套等待共用较早的截止时间,诊断也读取同一预算。"""
|
|
29
|
+
previous = _deadline.get()
|
|
30
|
+
token = _deadline.set(min(previous, deadline) if previous is not None else deadline)
|
|
31
|
+
try:
|
|
32
|
+
yield _deadline.get()
|
|
33
|
+
finally:
|
|
34
|
+
_deadline.reset(token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bind(host: Any, config: dict[str, Any], execution: dict[str, Any] | None = None) -> None:
|
|
38
|
+
_context.set((host, config))
|
|
39
|
+
_state.set(State())
|
|
40
|
+
_deadline.set(None)
|
|
41
|
+
if execution is not None:
|
|
42
|
+
_execution.set(execution)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def host() -> Any:
|
|
46
|
+
try:
|
|
47
|
+
return _context.get()[0]
|
|
48
|
+
except LookupError:
|
|
49
|
+
raise RuntimeError("请在代劳设备中运行脚本,或使用 dailo-dev 调试。") from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def config() -> dict[str, Any]:
|
|
53
|
+
return _context.get()[1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def state() -> State:
|
|
57
|
+
return _state.get()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def execution() -> dict[str, Any]:
|
|
61
|
+
return _execution.get()
|
|
File without changes
|