kotonebot 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- kotonebot/backend/context/context.py +1002 -1002
- kotonebot/backend/core.py +6 -49
- kotonebot/backend/image.py +36 -5
- kotonebot/backend/loop.py +222 -208
- kotonebot/backend/ocr.py +7 -1
- kotonebot/client/device.py +108 -243
- kotonebot/client/host/__init__.py +34 -3
- kotonebot/client/host/adb_common.py +7 -9
- kotonebot/client/host/custom.py +6 -2
- kotonebot/client/host/leidian_host.py +2 -7
- kotonebot/client/host/mumu12_host.py +2 -7
- kotonebot/client/host/protocol.py +4 -3
- kotonebot/client/implements/__init__.py +62 -11
- kotonebot/client/implements/adb.py +5 -1
- kotonebot/client/implements/nemu_ipc/__init__.py +4 -0
- kotonebot/client/implements/uiautomator2.py +6 -2
- kotonebot/client/implements/windows.py +7 -3
- kotonebot/client/registration.py +1 -1
- kotonebot/client/scaler.py +467 -0
- kotonebot/config/base_config.py +1 -1
- kotonebot/config/config.py +61 -0
- kotonebot/core/__init__.py +13 -0
- kotonebot/core/entities/base.py +182 -0
- kotonebot/core/entities/compound.py +75 -0
- kotonebot/core/entities/ocr.py +117 -0
- kotonebot/core/entities/template_match.py +198 -0
- kotonebot/devtools/__init__.py +42 -0
- kotonebot/devtools/cli/__init__.py +6 -0
- kotonebot/devtools/cli/main.py +53 -0
- kotonebot/devtools/project/project.py +41 -0
- kotonebot/devtools/project/scanner.py +202 -0
- kotonebot/devtools/project/schema.py +99 -0
- kotonebot/devtools/resgen/__init__.py +42 -0
- kotonebot/devtools/resgen/codegen.py +331 -0
- kotonebot/devtools/resgen/core.py +94 -0
- kotonebot/devtools/resgen/parsers.py +360 -0
- kotonebot/devtools/resgen/utils.py +158 -0
- kotonebot/devtools/resgen/validation.py +115 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
- kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
- kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
- kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
- kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
- kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
- kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
- kotonebot/devtools/web/dist/index.html +25 -0
- kotonebot/devtools/web/server/__init__.py +0 -0
- kotonebot/devtools/web/server/rest_api.py +217 -0
- kotonebot/devtools/web/server/server.py +85 -0
- kotonebot/errors.py +7 -2
- kotonebot/interop/win/__init__.py +10 -1
- kotonebot/interop/win/_mouse.py +311 -0
- kotonebot/interop/win/shake_mouse.py +224 -0
- kotonebot/primitives/__init__.py +3 -1
- kotonebot/primitives/geometry.py +817 -40
- kotonebot/primitives/visual.py +81 -1
- kotonebot/ui/pushkit/image_host.py +2 -1
- kotonebot/ui/pushkit/wxpusher.py +2 -1
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +4 -1
- kotonebot-0.6.0.dist-info/RECORD +105 -0
- kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
- kotonebot/client/implements/adb_raw.py +0 -159
- kotonebot-0.4.0.dist-info/RECORD +0 -70
- /kotonebot/{tools → devtools}/mirror.py +0 -0
- /kotonebot/{tools → devtools/project}/__init__.py +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
kotonebot/client/device.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
from typing_extensions import deprecated
|
|
3
|
-
from typing import Callable, Literal, overload
|
|
1
|
+
from typing import Callable, Literal, overload, TYPE_CHECKING
|
|
4
2
|
|
|
5
3
|
import cv2
|
|
6
4
|
import numpy as np
|
|
7
|
-
from adbutils import adb
|
|
8
5
|
from cv2.typing import MatLike
|
|
9
|
-
from adbutils._device import AdbDevice as AdbUtilsDevice
|
|
10
6
|
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from adbutils._device import AdbDevice as AdbUtilsDevice
|
|
9
|
+
|
|
10
|
+
from kotonebot import logging
|
|
11
11
|
from ..backend.debug import result
|
|
12
|
-
from ..errors import UnscalableResolutionError
|
|
13
|
-
from kotonebot.backend.core import HintBox
|
|
14
12
|
from kotonebot.primitives import Rect, Point, is_point
|
|
15
13
|
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable, AndroidCommandable, WindowsCommandable
|
|
14
|
+
from .scaler import AbstractScaler
|
|
15
|
+
from kotonebot.config.config import conf
|
|
16
|
+
from kotonebot.primitives.geometry import Size
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
19
|
+
LogLevel = Literal['info', 'debug', 'verbose', 'silent']
|
|
18
20
|
|
|
19
21
|
class HookContextManager:
|
|
20
22
|
def __init__(self, device: 'Device', func: Callable[[MatLike], MatLike]):
|
|
@@ -30,7 +32,7 @@ class HookContextManager:
|
|
|
30
32
|
self.device.screenshot_hook_after = self.old_func
|
|
31
33
|
|
|
32
34
|
class Device:
|
|
33
|
-
def __init__(self, platform: str = 'unknown') -> None:
|
|
35
|
+
def __init__(self, platform: str = 'unknown', scaler: AbstractScaler | None = None) -> None:
|
|
34
36
|
self.screenshot_hook_after: Callable[[MatLike], MatLike] | None = None
|
|
35
37
|
"""截图后调用的函数"""
|
|
36
38
|
self.screenshot_hook_before: Callable[[], MatLike | None] | None = None
|
|
@@ -54,88 +56,52 @@ class Device:
|
|
|
54
56
|
"""
|
|
55
57
|
设备平台名称。
|
|
56
58
|
"""
|
|
57
|
-
self.
|
|
58
|
-
"""
|
|
59
|
-
目标分辨率。
|
|
60
|
-
|
|
61
|
-
若设置,则在截图、点击、滑动等时会缩放到目标分辨率。
|
|
62
|
-
仅支持等比例缩放,若无法等比例缩放,则会抛出异常 `UnscalableResolutionError`。
|
|
63
|
-
"""
|
|
64
|
-
self.match_rotation: bool = True
|
|
65
|
-
"""
|
|
66
|
-
分辨率缩放是否自动匹配旋转。
|
|
67
|
-
|
|
68
|
-
当目标与真实分辨率的宽高比不一致时,是否允许通过旋转(交换宽高)后再进行匹配。
|
|
69
|
-
为 True 则忽略方向差异,只要宽高比一致就视为可缩放;False 则必须匹配旋转。
|
|
70
|
-
|
|
71
|
-
例如,当目标分辨率为 1920x1080,而真实分辨率为 1080x1920 时,
|
|
72
|
-
``match_rotation`` 为 True 则认为可以缩放,为 False 则会抛出异常。
|
|
73
|
-
"""
|
|
74
|
-
self.aspect_ratio_tolerance: float = 0.1
|
|
75
|
-
"""
|
|
76
|
-
宽高比容差阈值。
|
|
77
|
-
|
|
78
|
-
判断两分辨率宽高比差异是否接受的阈值。
|
|
79
|
-
该值越小,对比例一致性的要求越严格。
|
|
80
|
-
默认为 0.1(即 10% 容差)。
|
|
81
|
-
"""
|
|
59
|
+
self.log_level: LogLevel = 'debug'
|
|
60
|
+
"""默认日志级别。"""
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if self._adb is None:
|
|
86
|
-
raise ValueError("AdbClient is not connected")
|
|
87
|
-
return self._adb
|
|
88
|
-
|
|
89
|
-
@adb.setter
|
|
90
|
-
def adb(self, value: AdbUtilsDevice) -> None:
|
|
91
|
-
self._adb = value
|
|
92
|
-
|
|
93
|
-
def _scale_pos_real_to_target(self, real_x: int, real_y: int) -> tuple[int, int]:
|
|
94
|
-
"""将真实屏幕坐标缩放到目标逻辑坐标"""
|
|
95
|
-
if self.target_resolution is None:
|
|
96
|
-
return real_x, real_y
|
|
97
|
-
|
|
98
|
-
real_w, real_h = self.screen_size
|
|
99
|
-
target_w, target_h = self.target_resolution
|
|
62
|
+
self._scaler = scaler or conf().device.default_scaler_factory()
|
|
63
|
+
self._scaler_initialized = False
|
|
100
64
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
65
|
+
@property
|
|
66
|
+
def scaler(self) -> AbstractScaler:
|
|
67
|
+
# TODO: 应该要有一种更好的方式,把从延迟初始化的 _screenshot 中获取到屏幕大小,以初始化 scaler 的逻辑放到更合适的位置。
|
|
68
|
+
if not self._scaler.physical_resolution:
|
|
69
|
+
self._scaler.logic_resolution = conf().device.default_logic_resolution
|
|
70
|
+
self._scaler.physical_resolution = Size(*self._screenshot.screen_size)
|
|
71
|
+
self._scaler_initialized = True
|
|
72
|
+
return self._scaler
|
|
73
|
+
|
|
74
|
+
def setup(self,
|
|
75
|
+
*,
|
|
76
|
+
screenshot: Screenshotable,
|
|
77
|
+
touch: Touchable,
|
|
78
|
+
commands: Commandable | None = None,
|
|
79
|
+
scaler: AbstractScaler | None = None,
|
|
80
|
+
):
|
|
81
|
+
self._screenshot = screenshot
|
|
82
|
+
self._touch = touch
|
|
83
|
+
self.commands = commands
|
|
84
|
+
|
|
85
|
+
def __log(self, message: str, level: LogLevel | None = None, *args):
|
|
86
|
+
"""以指定的日志级别输出日志。
|
|
87
|
+
|
|
88
|
+
:param message: 要输出的日志信息。
|
|
89
|
+
:param level: 要使用的日志级别。可以是 'info', 'debug', 'verbose', 'silent' 中的一个,或者是 None。
|
|
90
|
+
如果为 None,则使用实例的 `log_level` 属性。
|
|
91
|
+
"""
|
|
92
|
+
effective_level = level if level is not None else self.log_level
|
|
93
|
+
|
|
94
|
+
if effective_level == 'info':
|
|
95
|
+
logger.info(message, *args)
|
|
96
|
+
elif effective_level == 'debug':
|
|
97
|
+
logger.debug(message, *args)
|
|
98
|
+
elif effective_level == 'verbose':
|
|
99
|
+
logger.verbose(message, *args)
|
|
100
|
+
elif effective_level == 'silent':
|
|
101
|
+
pass # Do nothing
|
|
136
102
|
|
|
137
103
|
@overload
|
|
138
|
-
def click(self) -> None:
|
|
104
|
+
def click(self, *, log: "LogLevel | None" = None) -> None:
|
|
139
105
|
"""
|
|
140
106
|
点击上次 `image` 对象或 `ocr` 对象的寻找结果(仅包括返回单个结果的函数)。
|
|
141
107
|
(不包括 `image.raw()` 和 `ocr.raw()` 的结果。)
|
|
@@ -145,73 +111,76 @@ class Device:
|
|
|
145
111
|
...
|
|
146
112
|
|
|
147
113
|
@overload
|
|
148
|
-
def click(self, x: int, y: int) -> None:
|
|
114
|
+
def click(self, x: int, y: int, *, log: "LogLevel | None" = None) -> None:
|
|
149
115
|
"""
|
|
150
116
|
点击屏幕上的某个点
|
|
151
117
|
"""
|
|
152
118
|
...
|
|
153
119
|
|
|
154
120
|
@overload
|
|
155
|
-
def click(self, point: Point) -> None:
|
|
121
|
+
def click(self, point: Point, *, log: "LogLevel | None" = None) -> None:
|
|
156
122
|
"""
|
|
157
123
|
点击屏幕上的某个点
|
|
158
124
|
"""
|
|
159
125
|
...
|
|
160
126
|
|
|
161
127
|
@overload
|
|
162
|
-
def click(self, rect: Rect) -> None:
|
|
128
|
+
def click(self, rect: Rect, *, log: "LogLevel | None" = None) -> None:
|
|
163
129
|
"""
|
|
164
130
|
从屏幕上的某个矩形区域随机选择一个点并点击
|
|
165
131
|
"""
|
|
166
132
|
...
|
|
167
133
|
|
|
168
134
|
@overload
|
|
169
|
-
def click(self, clickable: ClickableObjectProtocol) -> None:
|
|
135
|
+
def click(self, clickable: ClickableObjectProtocol, *, log: "LogLevel | None" = None) -> None:
|
|
170
136
|
"""
|
|
171
137
|
点击屏幕上的某个可点击对象
|
|
172
138
|
"""
|
|
173
139
|
...
|
|
174
140
|
|
|
175
141
|
def click(self, *args, **kwargs) -> None:
|
|
142
|
+
log: LogLevel | None = kwargs.pop('log', None)
|
|
176
143
|
arg1 = args[0] if len(args) > 0 else None
|
|
177
144
|
arg2 = args[1] if len(args) > 1 else None
|
|
178
145
|
if arg1 is None:
|
|
179
|
-
self.__click_last()
|
|
146
|
+
self.__click_last(log=log)
|
|
180
147
|
elif isinstance(arg1, Rect):
|
|
181
|
-
self.__click_rect(arg1)
|
|
148
|
+
self.__click_rect(arg1, log=log)
|
|
182
149
|
elif is_point(arg1):
|
|
183
|
-
self.__click_point_tuple(arg1)
|
|
150
|
+
self.__click_point_tuple(arg1, log=log)
|
|
184
151
|
elif isinstance(arg1, int) and isinstance(arg2, int):
|
|
185
|
-
self.__click_point(arg1, arg2)
|
|
152
|
+
self.__click_point(arg1, arg2, log=log)
|
|
186
153
|
elif isinstance(arg1, ClickableObjectProtocol):
|
|
187
|
-
self.__click_clickable(arg1)
|
|
154
|
+
self.__click_clickable(arg1, log=log)
|
|
188
155
|
else:
|
|
189
156
|
raise ValueError(f"Invalid arguments: {arg1}, {arg2}")
|
|
190
157
|
|
|
191
|
-
def __click_last(self) -> None:
|
|
158
|
+
def __click_last(self, *, log: "LogLevel | None" = None) -> None:
|
|
192
159
|
if self.last_find is None:
|
|
193
160
|
raise ValueError("No last find result. Make sure you are not calling the 'raw' functions.")
|
|
194
|
-
self.click(self.last_find)
|
|
161
|
+
self.click(self.last_find, log=log)
|
|
195
162
|
|
|
196
|
-
def __click_rect(self, rect: Rect) -> None:
|
|
163
|
+
def __click_rect(self, rect: Rect, *, log: "LogLevel | None" = None) -> None:
|
|
197
164
|
# 从矩形中心的 60% 内部随机选择一点
|
|
198
165
|
x = rect.x1 + rect.w // 2 + np.random.randint(-int(rect.w * 0.3), int(rect.w * 0.3))
|
|
199
166
|
y = rect.y1 + rect.h // 2 + np.random.randint(-int(rect.h * 0.3), int(rect.h * 0.3))
|
|
200
167
|
x = int(x)
|
|
201
168
|
y = int(y)
|
|
202
|
-
self.click(x, y)
|
|
169
|
+
self.click(x, y, log=log)
|
|
203
170
|
|
|
204
|
-
def __click_point(self, x: int, y: int) -> None:
|
|
171
|
+
def __click_point(self, x: int, y: int, *, log: "LogLevel | None" = None) -> None:
|
|
205
172
|
for hook in self.click_hooks_before:
|
|
206
173
|
logger.debug(f"Executing click hook before: ({x}, {y})")
|
|
207
174
|
x, y = hook(x, y)
|
|
208
175
|
logger.debug(f"Click hook before result: ({x}, {y})")
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
176
|
+
|
|
177
|
+
real_pos = self.scaler.logic_to_physical((x, y))
|
|
178
|
+
real_x, real_y = int(real_pos[0]), int(real_pos[1])
|
|
179
|
+
|
|
180
|
+
log_message = f"Click: {x}, {y}%s"
|
|
181
|
+
log_details = f"(Physical: {real_x}, {real_y})"
|
|
182
|
+
self.__log(log_message, log, log_details)
|
|
183
|
+
|
|
215
184
|
from ..backend.context import ContextStackVars
|
|
216
185
|
if ContextStackVars.current() is not None:
|
|
217
186
|
image = ContextStackVars.ensure_current()._screenshot
|
|
@@ -220,18 +189,17 @@ class Device:
|
|
|
220
189
|
if image is not None and image.size > 0:
|
|
221
190
|
cv2.circle(image, (x, y), 10, (0, 0, 255), -1)
|
|
222
191
|
message = f"Point: ({x}, {y})"
|
|
223
|
-
|
|
224
|
-
message += f" physical: ({real_x}, {real_y})"
|
|
192
|
+
message += f" physical: ({real_x}, {real_y})"
|
|
225
193
|
result("device.click", image, message)
|
|
226
194
|
self._touch.click(real_x, real_y)
|
|
227
195
|
|
|
228
|
-
def __click_point_tuple(self, point: Point) -> None:
|
|
229
|
-
self.click(point[0], point[1])
|
|
196
|
+
def __click_point_tuple(self, point: Point, *, log: "LogLevel | None" = None) -> None:
|
|
197
|
+
self.click(point[0], point[1], log=log)
|
|
230
198
|
|
|
231
|
-
def __click_clickable(self, clickable: ClickableObjectProtocol) -> None:
|
|
232
|
-
self.click(clickable.rect)
|
|
199
|
+
def __click_clickable(self, clickable: ClickableObjectProtocol, *, log: "LogLevel | None" = None) -> None:
|
|
200
|
+
self.click(clickable.rect, log=log)
|
|
233
201
|
|
|
234
|
-
def click_center(self) -> None:
|
|
202
|
+
def click_center(self, *, log: "LogLevel | None" = None) -> None:
|
|
235
203
|
"""
|
|
236
204
|
点击屏幕中心。
|
|
237
205
|
|
|
@@ -239,26 +207,26 @@ class Device:
|
|
|
239
207
|
调用前确保 `orientation` 属性与设备方向一致,
|
|
240
208
|
否则点击位置会不正确。
|
|
241
209
|
"""
|
|
242
|
-
size = self.
|
|
210
|
+
size = self.scaler.physical_to_logic(self.screen_size)
|
|
243
211
|
x, y = size[0] // 2, size[1] // 2
|
|
244
|
-
self.click(x, y)
|
|
212
|
+
self.click(x, y, log=log)
|
|
245
213
|
|
|
246
214
|
@overload
|
|
247
|
-
def double_click(self, x: int, y: int, interval: float = 0.4) -> None:
|
|
215
|
+
def double_click(self, x: int, y: int, interval: float = 0.4, *, log: "LogLevel | None" = None) -> None:
|
|
248
216
|
"""
|
|
249
217
|
双击屏幕上的某个点
|
|
250
218
|
"""
|
|
251
219
|
...
|
|
252
220
|
|
|
253
221
|
@overload
|
|
254
|
-
def double_click(self, rect: Rect, interval: float = 0.4) -> None:
|
|
222
|
+
def double_click(self, rect: Rect, interval: float = 0.4, *, log: "LogLevel | None" = None) -> None:
|
|
255
223
|
"""
|
|
256
224
|
双击屏幕上的某个矩形区域
|
|
257
225
|
"""
|
|
258
226
|
...
|
|
259
227
|
|
|
260
228
|
@overload
|
|
261
|
-
def double_click(self, clickable: ClickableObjectProtocol, interval: float = 0.4) -> None:
|
|
229
|
+
def double_click(self, clickable: ClickableObjectProtocol, interval: float = 0.4, *, log: "LogLevel | None" = None) -> None:
|
|
262
230
|
"""
|
|
263
231
|
双击屏幕上的某个可点击对象
|
|
264
232
|
"""
|
|
@@ -267,31 +235,36 @@ class Device:
|
|
|
267
235
|
def double_click(self, *args, **kwargs) -> None:
|
|
268
236
|
from kotonebot import sleep
|
|
269
237
|
arg0 = args[0]
|
|
238
|
+
log = kwargs.get('log', None)
|
|
270
239
|
if isinstance(arg0, Rect) or isinstance(arg0, ClickableObjectProtocol):
|
|
271
240
|
rect = arg0
|
|
272
241
|
interval = kwargs.get('interval', 0.4)
|
|
273
|
-
self.click(rect)
|
|
242
|
+
self.click(rect, log=log)
|
|
274
243
|
sleep(interval)
|
|
275
|
-
self.click(rect)
|
|
244
|
+
self.click(rect, log=log)
|
|
276
245
|
else:
|
|
277
246
|
x = args[0]
|
|
278
247
|
y = args[1]
|
|
279
248
|
interval = kwargs.get('interval', 0.4)
|
|
280
|
-
self.click(x, y)
|
|
249
|
+
self.click(x, y, log=log)
|
|
281
250
|
sleep(interval)
|
|
282
|
-
self.click(x, y)
|
|
251
|
+
self.click(x, y, log=log)
|
|
283
252
|
|
|
284
|
-
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float|None = None) -> None:
|
|
253
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float|None = None, *, log: "LogLevel | None" = None) -> None:
|
|
285
254
|
"""
|
|
286
255
|
滑动屏幕
|
|
287
256
|
"""
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
257
|
+
real_pos1 = self.scaler.logic_to_physical((x1, y1))
|
|
258
|
+
real_x1, real_y1 = int(real_pos1[0]), int(real_pos1[1])
|
|
259
|
+
real_pos2 = self.scaler.logic_to_physical((x2, y2))
|
|
260
|
+
real_x2, real_y2 = int(real_pos2[0]), int(real_pos2[1])
|
|
261
|
+
log_message = f"Swipe: from ({x1}, {y1}) to ({x2}, {y2}) (Physical: from ({real_x1}, {real_y1}) to ({real_x2}, {real_y2}))"
|
|
262
|
+
|
|
263
|
+
self.__log(log_message, log)
|
|
264
|
+
|
|
265
|
+
self._touch.swipe(real_x1, real_y1, real_x2, real_y2, duration)
|
|
293
266
|
|
|
294
|
-
def swipe_scaled(self, x1: float, y1: float, x2: float, y2: float, duration: float|None = None) -> None:
|
|
267
|
+
def swipe_scaled(self, x1: float, y1: float, x2: float, y2: float, duration: float|None = None, *, log: "LogLevel | None" = None) -> None:
|
|
295
268
|
"""
|
|
296
269
|
滑动屏幕,参数为屏幕坐标的百分比。
|
|
297
270
|
|
|
@@ -304,8 +277,8 @@ class Device:
|
|
|
304
277
|
:param y2: 结束点 y 坐标百分比。范围 [0, 1]
|
|
305
278
|
:param duration: 滑动持续时间,单位秒。None 表示使用默认值。
|
|
306
279
|
"""
|
|
307
|
-
w, h = self.
|
|
308
|
-
self.swipe(int(w * x1), int(h * y1), int(w * x2), int(h * y2), duration)
|
|
280
|
+
w, h = self.scaler.physical_to_logic(self.screen_size)
|
|
281
|
+
self.swipe(int(w * x1), int(h * y1), int(w * x2), int(h * y2), duration, log=log)
|
|
309
282
|
|
|
310
283
|
def screenshot(self) -> MatLike:
|
|
311
284
|
"""
|
|
@@ -318,7 +291,7 @@ class Device:
|
|
|
318
291
|
logger.debug("screenshot hook before returned image")
|
|
319
292
|
return img
|
|
320
293
|
img = self.screenshot_raw()
|
|
321
|
-
img = self.
|
|
294
|
+
img = self.scaler.transform_screenshot(img)
|
|
322
295
|
if self.screenshot_hook_after is not None:
|
|
323
296
|
img = self.screenshot_hook_after(img)
|
|
324
297
|
return img
|
|
@@ -365,73 +338,11 @@ class Device:
|
|
|
365
338
|
"""
|
|
366
339
|
return self._screenshot.detect_orientation()
|
|
367
340
|
|
|
368
|
-
def __aspect_ratio_compatible(self, src_size: tuple[int, int], tgt_size: tuple[int, int]) -> bool:
|
|
369
|
-
"""
|
|
370
|
-
判断两个尺寸在宽高比意义上是否兼容
|
|
371
|
-
|
|
372
|
-
若 ``self.match_rotation`` 为 True,忽略方向(长边/短边)进行比较。
|
|
373
|
-
判断标准由 ``self.aspect_ratio_tolerance`` 决定(默认 0.1)。
|
|
374
|
-
"""
|
|
375
|
-
src_w, src_h = src_size
|
|
376
|
-
tgt_w, tgt_h = tgt_size
|
|
377
|
-
|
|
378
|
-
# 尺寸必须为正
|
|
379
|
-
if src_w <= 0 or src_h <= 0:
|
|
380
|
-
raise ValueError(f"Source size dimensions must be positive for scaling: {src_size}")
|
|
381
|
-
if tgt_w <= 0 or tgt_h <= 0:
|
|
382
|
-
raise ValueError(f"Target size dimensions must be positive for scaling: {tgt_size}")
|
|
383
|
-
|
|
384
|
-
tolerant = self.aspect_ratio_tolerance
|
|
385
|
-
|
|
386
|
-
# 直接比较宽高比
|
|
387
|
-
if abs((tgt_w / src_w) - (tgt_h / src_h)) <= tolerant:
|
|
388
|
-
return True
|
|
389
|
-
|
|
390
|
-
# 尝试忽略方向差异
|
|
391
|
-
if self.match_rotation:
|
|
392
|
-
ratio_src = max(src_w, src_h) / min(src_w, src_h)
|
|
393
|
-
ratio_tgt = max(tgt_w, tgt_h) / min(tgt_w, tgt_h)
|
|
394
|
-
return abs(ratio_src - ratio_tgt) <= tolerant
|
|
395
|
-
|
|
396
|
-
return False
|
|
397
|
-
|
|
398
|
-
def __assert_scalable(self, source: tuple[int, int], target: tuple[int, int]) -> tuple[int, int]:
|
|
399
|
-
"""
|
|
400
|
-
校验分辨率是否可缩放,并返回调整后的目标分辨率。
|
|
401
|
-
|
|
402
|
-
当 match_rotation 为 True 且源分辨率与目标分辨率的旋转方向不一致时,
|
|
403
|
-
自动交换目标分辨率的宽高,使其与源分辨率的方向保持一致。
|
|
404
|
-
|
|
405
|
-
:param src_size: 源分辨率 (width, height)
|
|
406
|
-
:param tgt_size: 目标分辨率 (width, height)
|
|
407
|
-
:return: 调整后的目标分辨率 (width, height)
|
|
408
|
-
:raises UnscalableResolutionError: 若宽高比不兼容
|
|
409
|
-
"""
|
|
410
|
-
# 智能调整目标分辨率方向
|
|
411
|
-
adjusted_tgt_size = target
|
|
412
|
-
if self.match_rotation:
|
|
413
|
-
src_w, src_h = source
|
|
414
|
-
tgt_w, tgt_h = target
|
|
415
|
-
|
|
416
|
-
# 判断源分辨率和目标分辨率的方向
|
|
417
|
-
src_is_landscape = src_w > src_h
|
|
418
|
-
tgt_is_landscape = tgt_w > tgt_h
|
|
419
|
-
|
|
420
|
-
# 如果方向不一致,交换目标分辨率的宽高
|
|
421
|
-
if src_is_landscape != tgt_is_landscape:
|
|
422
|
-
adjusted_tgt_size = (tgt_h, tgt_w)
|
|
423
|
-
|
|
424
|
-
# 校验调整后的分辨率是否兼容
|
|
425
|
-
if not self.__aspect_ratio_compatible(source, adjusted_tgt_size):
|
|
426
|
-
raise UnscalableResolutionError(target, source)
|
|
427
|
-
|
|
428
|
-
return adjusted_tgt_size
|
|
429
|
-
|
|
430
341
|
|
|
431
342
|
class AndroidDevice(Device):
|
|
432
|
-
def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None:
|
|
343
|
+
def __init__(self, adb_connection: 'AdbUtilsDevice | None' = None) -> None:
|
|
433
344
|
super().__init__('android')
|
|
434
|
-
self._adb: AdbUtilsDevice | None = adb_connection
|
|
345
|
+
self._adb: 'AdbUtilsDevice | None' = adb_connection
|
|
435
346
|
self.commands: AndroidCommandable
|
|
436
347
|
|
|
437
348
|
def current_package(self) -> str | None:
|
|
@@ -455,50 +366,4 @@ class AndroidDevice(Device):
|
|
|
455
366
|
class WindowsDevice(Device):
|
|
456
367
|
def __init__(self) -> None:
|
|
457
368
|
super().__init__('windows')
|
|
458
|
-
self.commands: WindowsCommandable
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if __name__ == "__main__":
|
|
462
|
-
from kotonebot.client.implements.adb import AdbImpl
|
|
463
|
-
from kotonebot.client.implements.adb_raw import AdbRawImpl
|
|
464
|
-
from .implements.uiautomator2 import UiAutomator2Impl
|
|
465
|
-
print("server version:", adb.server_version())
|
|
466
|
-
adb.connect("127.0.0.1:5555")
|
|
467
|
-
print("devices:", adb.device_list())
|
|
468
|
-
d = adb.device_list()[-1]
|
|
469
|
-
d.shell("dumpsys activity top | grep ACTIVITY | tail -n 1")
|
|
470
|
-
dd = AndroidDevice(d)
|
|
471
|
-
adb_imp = AdbRawImpl(d)
|
|
472
|
-
dd._touch = adb_imp
|
|
473
|
-
dd._screenshot = adb_imp
|
|
474
|
-
dd.commands = adb_imp
|
|
475
|
-
# dd._screenshot = MinicapScreenshotImpl(dd)
|
|
476
|
-
# dd._screenshot = UiAutomator2Impl(dd)
|
|
477
|
-
|
|
478
|
-
# 实时展示画面
|
|
479
|
-
import cv2
|
|
480
|
-
import numpy as np
|
|
481
|
-
import time
|
|
482
|
-
last_time = time.time()
|
|
483
|
-
while True:
|
|
484
|
-
start_time = time.time()
|
|
485
|
-
img = dd.screenshot()
|
|
486
|
-
# 50% 缩放
|
|
487
|
-
img = cv2.resize(img, (img.shape[1] // 2, img.shape[0] // 2))
|
|
488
|
-
|
|
489
|
-
# 计算帧间隔
|
|
490
|
-
interval = start_time - last_time
|
|
491
|
-
fps = 1 / interval if interval > 0 else 0
|
|
492
|
-
last_time = start_time
|
|
493
|
-
|
|
494
|
-
# 获取当前时间和帧率信息
|
|
495
|
-
current_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
|
496
|
-
fps_text = f"FPS: {fps:.1f} {interval*1000:.1f}ms"
|
|
497
|
-
|
|
498
|
-
# 在图像上绘制信息
|
|
499
|
-
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
500
|
-
cv2.putText(img, current_time, (10, 30), font, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
|
501
|
-
cv2.putText(img, fps_text, (10, 60), font, 0.5, (0, 0, 255), 1, cv2.LINE_AA)
|
|
502
|
-
|
|
503
|
-
cv2.imshow("screen", img)
|
|
504
|
-
cv2.waitKey(1)
|
|
369
|
+
self.commands: WindowsCommandable
|
|
@@ -1,7 +1,38 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
1
3
|
from .protocol import HostProtocol, Instance, AdbHostConfig, WindowsHostConfig, RemoteWindowsHostConfig
|
|
2
|
-
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from .custom import CustomInstance, create as create_custom
|
|
6
|
+
from .mumu12_host import Mumu12Host, Mumu12Instance, Mumu12V5Host, Mumu12V5Instance
|
|
7
|
+
from .leidian_host import LeidianHost, LeidianInstance
|
|
8
|
+
|
|
9
|
+
def _require_custom():
|
|
10
|
+
global CustomInstance, create_custom
|
|
11
|
+
from .custom import CustomInstance, create as create_custom
|
|
12
|
+
|
|
13
|
+
def _require_mumu12():
|
|
14
|
+
global Mumu12Host, Mumu12Instance, Mumu12V5Host, Mumu12V5Instance
|
|
15
|
+
from .mumu12_host import Mumu12Host, Mumu12Instance, Mumu12V5Host, Mumu12V5Instance
|
|
16
|
+
|
|
17
|
+
def _require_leidian():
|
|
18
|
+
global LeidianHost, LeidianInstance
|
|
19
|
+
from .leidian_host import LeidianHost, LeidianInstance
|
|
20
|
+
|
|
21
|
+
_IMPORT_NAMES = [
|
|
22
|
+
(_require_custom, ['CustomInstance', 'create_custom']),
|
|
23
|
+
(_require_mumu12, ['Mumu12Host', 'Mumu12Instance', 'Mumu12V5Host', 'Mumu12V5Instance']),
|
|
24
|
+
(_require_leidian, ['LeidianHost', 'LeidianInstance']),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str):
|
|
28
|
+
for item in _IMPORT_NAMES:
|
|
29
|
+
if name in item[1]:
|
|
30
|
+
item[0]()
|
|
31
|
+
break
|
|
32
|
+
try:
|
|
33
|
+
return globals()[name]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise AttributeError(name=name)
|
|
5
36
|
|
|
6
37
|
__all__ = [
|
|
7
38
|
'HostProtocol', 'Instance',
|
|
@@ -2,14 +2,18 @@ from abc import ABC
|
|
|
2
2
|
from typing import Any, Literal, TypeGuard, TypeVar, get_args
|
|
3
3
|
from typing_extensions import assert_never
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
from adbutils
|
|
5
|
+
try:
|
|
6
|
+
from adbutils import adb
|
|
7
|
+
from adbutils._device import AdbDevice
|
|
8
|
+
except ImportError as _e:
|
|
9
|
+
from kotonebot.errors import MissingDependencyError
|
|
10
|
+
raise MissingDependencyError(_e, 'android')
|
|
7
11
|
from kotonebot import logging
|
|
8
12
|
from kotonebot.client.device import AndroidDevice
|
|
9
13
|
from .protocol import Instance, AdbHostConfig, Device
|
|
10
14
|
|
|
11
15
|
logger = logging.getLogger(__name__)
|
|
12
|
-
AdbRecipes = Literal['adb', '
|
|
16
|
+
AdbRecipes = Literal['adb', 'uiautomator2']
|
|
13
17
|
|
|
14
18
|
def is_adb_recipe(recipe: Any) -> TypeGuard[AdbRecipes]:
|
|
15
19
|
return recipe in get_args(AdbRecipes)
|
|
@@ -85,12 +89,6 @@ class CommonAdbCreateDeviceMixin(ABC):
|
|
|
85
89
|
d._screenshot = impl
|
|
86
90
|
d._touch = impl
|
|
87
91
|
d.commands = impl
|
|
88
|
-
case 'adb_raw':
|
|
89
|
-
from kotonebot.client.implements.adb_raw import AdbRawImpl
|
|
90
|
-
impl = AdbRawImpl(connection)
|
|
91
|
-
d._screenshot = impl
|
|
92
|
-
d._touch = impl
|
|
93
|
-
d.commands = impl
|
|
94
92
|
case 'uiautomator2':
|
|
95
93
|
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
|
|
96
94
|
from kotonebot.client.implements.adb import AdbImpl
|
kotonebot/client/host/custom.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import subprocess
|
|
3
|
-
|
|
3
|
+
try:
|
|
4
|
+
from psutil import process_iter
|
|
5
|
+
except ImportError as _e:
|
|
6
|
+
from kotonebot.errors import MissingDependencyError
|
|
7
|
+
raise MissingDependencyError(_e, 'windows')
|
|
4
8
|
from .protocol import Instance, AdbHostConfig, HostProtocol
|
|
5
9
|
from typing import ParamSpec, TypeVar
|
|
6
10
|
from typing_extensions import override
|
|
@@ -104,7 +108,7 @@ class CustomHost(HostProtocol[CustomRecipes]):
|
|
|
104
108
|
|
|
105
109
|
@staticmethod
|
|
106
110
|
def recipes() -> 'list[CustomRecipes]':
|
|
107
|
-
return ['adb', '
|
|
111
|
+
return ['adb', 'uiautomator2']
|
|
108
112
|
|
|
109
113
|
if __name__ == '__main__':
|
|
110
114
|
ins = create(r'C:\Program Files\BlueStacks_nxt\HD-Player.exe', '127.0.0.1', 5555, '**emulator-name**')
|
|
@@ -9,16 +9,11 @@ from kotonebot.client import Device
|
|
|
9
9
|
from kotonebot.util import Countdown, Interval
|
|
10
10
|
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
|
11
11
|
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
|
|
12
|
+
from ...interop.win.reg import read_reg
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
LeidianRecipes = AdbRecipes
|
|
15
16
|
|
|
16
|
-
if os.name == 'nt':
|
|
17
|
-
from ...interop.win.reg import read_reg
|
|
18
|
-
else:
|
|
19
|
-
def read_reg(key, subkey, name, *, default=None, **kwargs):
|
|
20
|
-
"""Stub for read_reg on non-Windows platforms."""
|
|
21
|
-
return default
|
|
22
17
|
|
|
23
18
|
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
|
|
24
19
|
@copy_type(Instance.__init__)
|
|
@@ -186,7 +181,7 @@ class LeidianHost(HostProtocol[LeidianRecipes]):
|
|
|
186
181
|
|
|
187
182
|
@staticmethod
|
|
188
183
|
def recipes() -> 'list[LeidianRecipes]':
|
|
189
|
-
return ['adb', '
|
|
184
|
+
return ['adb', 'uiautomator2']
|
|
190
185
|
|
|
191
186
|
if __name__ == '__main__':
|
|
192
187
|
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
|
@@ -14,13 +14,8 @@ from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
|
|
|
14
14
|
from kotonebot.util import Countdown, Interval
|
|
15
15
|
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
|
16
16
|
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
|
|
17
|
+
from ...interop.win.reg import read_reg
|
|
17
18
|
|
|
18
|
-
if os.name == 'nt':
|
|
19
|
-
from ...interop.win.reg import read_reg
|
|
20
|
-
else:
|
|
21
|
-
def read_reg(key, subkey, name, *, default=None, **kwargs):
|
|
22
|
-
"""Stub for read_reg on non-Windows platforms."""
|
|
23
|
-
return default
|
|
24
19
|
|
|
25
20
|
# Forward declarations for type hints
|
|
26
21
|
if TYPE_CHECKING:
|
|
@@ -145,7 +140,7 @@ class Mumu12Host(HostProtocol[MuMu12Recipes]):
|
|
|
145
140
|
|
|
146
141
|
@staticmethod
|
|
147
142
|
def recipes() -> 'list[MuMu12Recipes]':
|
|
148
|
-
return ['adb', '
|
|
143
|
+
return ['adb', 'uiautomator2', 'nemu_ipc']
|
|
149
144
|
|
|
150
145
|
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
|
|
151
146
|
HostClass: 'type[Mumu12Host]' = Mumu12Host
|