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.
Files changed (71) hide show
  1. kotonebot/backend/context/context.py +1002 -1002
  2. kotonebot/backend/core.py +6 -49
  3. kotonebot/backend/image.py +36 -5
  4. kotonebot/backend/loop.py +222 -208
  5. kotonebot/backend/ocr.py +7 -1
  6. kotonebot/client/device.py +108 -243
  7. kotonebot/client/host/__init__.py +34 -3
  8. kotonebot/client/host/adb_common.py +7 -9
  9. kotonebot/client/host/custom.py +6 -2
  10. kotonebot/client/host/leidian_host.py +2 -7
  11. kotonebot/client/host/mumu12_host.py +2 -7
  12. kotonebot/client/host/protocol.py +4 -3
  13. kotonebot/client/implements/__init__.py +62 -11
  14. kotonebot/client/implements/adb.py +5 -1
  15. kotonebot/client/implements/nemu_ipc/__init__.py +4 -0
  16. kotonebot/client/implements/uiautomator2.py +6 -2
  17. kotonebot/client/implements/windows.py +7 -3
  18. kotonebot/client/registration.py +1 -1
  19. kotonebot/client/scaler.py +467 -0
  20. kotonebot/config/base_config.py +1 -1
  21. kotonebot/config/config.py +61 -0
  22. kotonebot/core/__init__.py +13 -0
  23. kotonebot/core/entities/base.py +182 -0
  24. kotonebot/core/entities/compound.py +75 -0
  25. kotonebot/core/entities/ocr.py +117 -0
  26. kotonebot/core/entities/template_match.py +198 -0
  27. kotonebot/devtools/__init__.py +42 -0
  28. kotonebot/devtools/cli/__init__.py +6 -0
  29. kotonebot/devtools/cli/main.py +53 -0
  30. kotonebot/devtools/project/project.py +41 -0
  31. kotonebot/devtools/project/scanner.py +202 -0
  32. kotonebot/devtools/project/schema.py +99 -0
  33. kotonebot/devtools/resgen/__init__.py +42 -0
  34. kotonebot/devtools/resgen/codegen.py +331 -0
  35. kotonebot/devtools/resgen/core.py +94 -0
  36. kotonebot/devtools/resgen/parsers.py +360 -0
  37. kotonebot/devtools/resgen/utils.py +158 -0
  38. kotonebot/devtools/resgen/validation.py +115 -0
  39. kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
  40. kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
  41. kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
  42. kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
  43. kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
  44. kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
  45. kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
  46. kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
  47. kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
  48. kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
  49. kotonebot/devtools/web/dist/index.html +25 -0
  50. kotonebot/devtools/web/server/__init__.py +0 -0
  51. kotonebot/devtools/web/server/rest_api.py +217 -0
  52. kotonebot/devtools/web/server/server.py +85 -0
  53. kotonebot/errors.py +7 -2
  54. kotonebot/interop/win/__init__.py +10 -1
  55. kotonebot/interop/win/_mouse.py +311 -0
  56. kotonebot/interop/win/shake_mouse.py +224 -0
  57. kotonebot/primitives/__init__.py +3 -1
  58. kotonebot/primitives/geometry.py +817 -40
  59. kotonebot/primitives/visual.py +81 -1
  60. kotonebot/ui/pushkit/image_host.py +2 -1
  61. kotonebot/ui/pushkit/wxpusher.py +2 -1
  62. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +4 -1
  63. kotonebot-0.6.0.dist-info/RECORD +105 -0
  64. kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
  65. kotonebot/client/implements/adb_raw.py +0 -159
  66. kotonebot-0.4.0.dist-info/RECORD +0 -70
  67. /kotonebot/{tools → devtools}/mirror.py +0 -0
  68. /kotonebot/{tools → devtools/project}/__init__.py +0 -0
  69. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
  70. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +0 -0
  71. {kotonebot-0.4.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,22 @@
1
- import logging
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.target_resolution: tuple[int, int] | None = None
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
- @property
84
- def adb(self) -> AdbUtilsDevice:
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
- adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
103
-
104
- scale_w = adjusted_target_w / real_w
105
- scale_h = adjusted_target_h / real_h
106
-
107
- return int(real_x * scale_w), int(real_y * scale_h)
108
-
109
- def _scale_pos_target_to_real(self, target_x: int, target_y: int) -> tuple[int, int]:
110
- """将目标逻辑坐标缩放到真实屏幕坐标"""
111
- if self.target_resolution is None:
112
- return target_x, target_y # 输入坐标已是真实坐标
113
-
114
- real_w, real_h = self.screen_size
115
- target_w, target_h = self.target_resolution
116
-
117
- # 校验分辨率是否可缩放并获取调整后的目标分辨率
118
- adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
119
-
120
- scale_to_real_w = real_w / adjusted_target_w
121
- scale_to_real_h = real_h / adjusted_target_h
122
-
123
- return int(target_x * scale_to_real_w), int(target_y * scale_to_real_h)
124
-
125
- def __scale_image (self, img: MatLike) -> MatLike:
126
- if self.target_resolution is None:
127
- return img
128
-
129
- target_w, target_h = self.target_resolution
130
- h, w = img.shape[:2]
131
-
132
- # 校验分辨率是否可缩放并获取调整后的目标分辨率
133
- adjusted_target = self.__assert_scalable((w, h), (target_w, target_h))
134
-
135
- return cv2.resize(img, adjusted_target)
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
- if self.target_resolution is not None:
210
- # 输入坐标为逻辑坐标,需要转换为真实坐标
211
- real_x, real_y = self._scale_pos_target_to_real(x, y)
212
- else:
213
- real_x, real_y = x, y
214
- logger.debug(f"Click: {x}, {y}%s", f"(Physical: {real_x}, {real_y})" if self.target_resolution is not None else "")
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
- if self.target_resolution is not None:
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.target_resolution or self.screen_size
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
- if self.target_resolution is not None:
289
- # 输入坐标为逻辑坐标,需要转换为真实坐标
290
- x1, y1 = self._scale_pos_target_to_real(x1, y1)
291
- x2, y2 = self._scale_pos_target_to_real(x2, y2)
292
- self._touch.swipe(x1, y1, x2, y2, duration)
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.target_resolution or self.screen_size
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.__scale_image(img)
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
- from .custom import CustomInstance, create as create_custom
3
- from .mumu12_host import Mumu12Host, Mumu12Instance, Mumu12V5Host, Mumu12V5Instance
4
- from .leidian_host import LeidianHost, LeidianInstance
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
- from adbutils import adb
6
- from adbutils._device import AdbDevice
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', 'adb_raw', 'uiautomator2']
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
@@ -1,6 +1,10 @@
1
1
  import os
2
2
  import subprocess
3
- from psutil import process_iter
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', 'adb_raw', 'uiautomator2']
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', 'adb_raw', 'uiautomator2']
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', 'adb_raw', 'uiautomator2', 'nemu_ipc']
143
+ return ['adb', 'uiautomator2', 'nemu_ipc']
149
144
 
150
145
  class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
151
146
  HostClass: 'type[Mumu12Host]' = Mumu12Host