devhelmkit 0.1.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 (58) hide show
  1. devhelmkit/__init__.py +44 -0
  2. devhelmkit/android/__init__.py +7 -0
  3. devhelmkit/android/driver.py +18 -0
  4. devhelmkit/assets/so/arm64-v8a/agent_v1.so +0 -0
  5. devhelmkit/assets/so/arm64-v8a/agent_v2.so +0 -0
  6. devhelmkit/assets/so/x86_64/agent.so +0 -0
  7. devhelmkit/core/__init__.py +3 -0
  8. devhelmkit/core/base_component.py +214 -0
  9. devhelmkit/core/base_driver.py +414 -0
  10. devhelmkit/core/base_window.py +25 -0
  11. devhelmkit/core/selector_spec.py +102 -0
  12. devhelmkit/entry.py +90 -0
  13. devhelmkit/exceptions.py +56 -0
  14. devhelmkit/harmony/__init__.py +3 -0
  15. devhelmkit/harmony/agent/__init__.py +7 -0
  16. devhelmkit/harmony/agent/so_manager.py +300 -0
  17. devhelmkit/harmony/config.py +124 -0
  18. devhelmkit/harmony/device/__init__.py +6 -0
  19. devhelmkit/harmony/device/hdc.py +430 -0
  20. devhelmkit/harmony/driver.py +1416 -0
  21. devhelmkit/harmony/finder/__init__.py +12 -0
  22. devhelmkit/harmony/finder/component_finder.py +336 -0
  23. devhelmkit/harmony/finder/popup_handler.py +81 -0
  24. devhelmkit/harmony/finder/selector_adapter.py +101 -0
  25. devhelmkit/harmony/finder/xpath_query.py +110 -0
  26. devhelmkit/harmony/rpc/__init__.py +12 -0
  27. devhelmkit/harmony/rpc/client.py +126 -0
  28. devhelmkit/harmony/rpc/proxy_v2.py +106 -0
  29. devhelmkit/harmony/rpc/remote_object.py +48 -0
  30. devhelmkit/harmony/uiobject.py +246 -0
  31. devhelmkit/harmony/uiwindow.py +43 -0
  32. devhelmkit/harmony/webview/__init__.py +17 -0
  33. devhelmkit/harmony/webview/chromedriver_manager.py +251 -0
  34. devhelmkit/harmony/webview/devtools_finder.py +131 -0
  35. devhelmkit/harmony/webview/webview_driver.py +326 -0
  36. devhelmkit/model/__init__.py +3 -0
  37. devhelmkit/model/action.py +147 -0
  38. devhelmkit/model/app_state.py +50 -0
  39. devhelmkit/model/constants.py +22 -0
  40. devhelmkit/model/display.py +32 -0
  41. devhelmkit/model/format_string.py +24 -0
  42. devhelmkit/model/input.py +50 -0
  43. devhelmkit/model/json_base.py +42 -0
  44. devhelmkit/model/keys.py +375 -0
  45. devhelmkit/model/match_pattern.py +15 -0
  46. devhelmkit/model/page.py +13 -0
  47. devhelmkit/model/params.py +42 -0
  48. devhelmkit/model/rect.py +58 -0
  49. devhelmkit/model/runnable.py +18 -0
  50. devhelmkit/utils/__init__.py +3 -0
  51. devhelmkit/utils/logger.py +72 -0
  52. devhelmkit/utils/retry.py +46 -0
  53. devhelmkit/utils/timeout.py +64 -0
  54. devhelmkit-0.1.0.dist-info/METADATA +411 -0
  55. devhelmkit-0.1.0.dist-info/RECORD +58 -0
  56. devhelmkit-0.1.0.dist-info/WHEEL +5 -0
  57. devhelmkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  58. devhelmkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1416 @@
1
+ # !/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """HarmonyDriver:鸿蒙平台驱动门面。
4
+
5
+ 编排 HdcDevice(设备通道)、RpcClient(RPC 通信)、ComponentFinder
6
+ (控件查找)三层能力,对用户暴露 U2 风格 API。
7
+
8
+ 仅支持 HarmonyOS 5.0.0+(API 12+),设备端 uitest 服务统一使用
9
+ api9+ 命名(Driver/Component/On)。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import shlex
16
+ import time
17
+ from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
18
+
19
+ from devhelmkit.core.base_driver import BaseDriver
20
+ from devhelmkit.core.selector_spec import SelectorSpec, build_selector
21
+ from devhelmkit.exceptions import DevhelmError, DevhelmTimeoutError
22
+ from devhelmkit.harmony.config import HarmonyDriverConfig
23
+ from devhelmkit.harmony.device.hdc import HdcDevice
24
+ from devhelmkit.harmony.finder.component_finder import ComponentFinder, DRIVER_REF
25
+ from devhelmkit.harmony.rpc.client import RpcClient
26
+ from devhelmkit.harmony.uiobject import UiObject
27
+ from devhelmkit.harmony.uiwindow import UiWindow
28
+ from devhelmkit.model.input import GestureAction, InputDevice
29
+ from devhelmkit.model.keys import KeyCode
30
+ from devhelmkit.utils import logger
31
+
32
+ if TYPE_CHECKING:
33
+ from PIL.Image import Image
34
+ from devhelmkit.core.base_component import BaseComponent
35
+ from devhelmkit.core.base_window import BaseWindow
36
+
37
+ # 语义按键 → KeyCode 映射
38
+ _KEY_MAP = {
39
+ 'back': KeyCode.BACK,
40
+ 'home': KeyCode.HOME,
41
+ 'power': KeyCode.POWER,
42
+ 'volume_up': KeyCode.VOLUME_UP,
43
+ 'volume_down': KeyCode.VOLUME_DOWN,
44
+ 'menu': KeyCode.MENU,
45
+ 'enter': KeyCode.ENTER,
46
+ 'escape': KeyCode.ESCAPE,
47
+ 'delete': KeyCode.DEL,
48
+ 'tab': KeyCode.TAB,
49
+ 'space': KeyCode.SPACE,
50
+ }
51
+
52
+ # 不支持的 uitest 版本
53
+ _UNSUPPORTED_UITEST_VERSIONS = {"4.1.3.2"}
54
+
55
+ # 控件属性名 → 设备端 RPC 方法名映射
56
+ _PROPERTY_METHOD_MAP = {
57
+ "id": "getId",
58
+ "text": "getText",
59
+ "key": "getKey",
60
+ "type": "getType",
61
+ "enabled": "isEnabled",
62
+ "focused": "isFocused",
63
+ "clickable": "isClickable",
64
+ "scrollable": "isScrollable",
65
+ "checked": "isChecked",
66
+ "checkable": "isCheckable",
67
+ "description": "getDescription",
68
+ "selected": "isSelected",
69
+ "bounds": "getBounds",
70
+ }
71
+
72
+
73
+ class HarmonyDriver(BaseDriver):
74
+ """鸿蒙平台驱动门面。"""
75
+
76
+ def __init__(self, serial: str,
77
+ config: Optional[HarmonyDriverConfig] = None,
78
+ **config_kwargs):
79
+ self._config = HarmonyDriverConfig.from_input(config, config_kwargs)
80
+ # 应用 hdc 路径配置(全局生效,影响所有后续 HdcDevice 实例)
81
+ if self._config.hdc_path and self._config.hdc_path != "hdc":
82
+ HdcDevice.set_hdc_path(self._config.hdc_path)
83
+ self._device = HdcDevice(serial)
84
+ self._setup_device()
85
+ self._rpc = RpcClient(self._device)
86
+ self._finder = ComponentFinder(self._rpc, self._config)
87
+ self._implicit_wait: float = 10.0
88
+ self._closed = False
89
+ self._toast_observer: Optional[str] = None
90
+ self._toast_listening: bool = False
91
+ self._ui_event_observer: Optional[str] = None
92
+ self._ui_event_listening: bool = False
93
+
94
+ # ============================================================
95
+ # 生命周期
96
+ # ============================================================
97
+
98
+ def close(self, stop_daemon: Optional[bool] = None) -> None:
99
+ """关闭驱动,释放 RPC 对象、socket 与端口转发。
100
+
101
+ Args:
102
+ stop_daemon: 是否停止设备端 uitest 守护进程。
103
+ None 时取 config.stop_daemon_on_close(默认 False)。
104
+ True 强制停止;False 强制保留。
105
+ """
106
+ if self._closed:
107
+ return
108
+ self._closed = True
109
+ try:
110
+ self._rpc.remote_objects.release_all()
111
+ except Exception:
112
+ pass
113
+ if stop_daemon is None:
114
+ stop_daemon = self._config.stop_daemon_on_close
115
+ self._device.close(stop_daemon=stop_daemon)
116
+
117
+ @property
118
+ def device_sn(self) -> str:
119
+ return self._device.serial
120
+
121
+ @property
122
+ def platform(self) -> str:
123
+ return "harmony"
124
+
125
+ # ============================================================
126
+ # 设备信息
127
+ # ============================================================
128
+
129
+ @property
130
+ def info(self) -> Dict[str, Any]:
131
+ """设备基本信息(品牌、型号、系统版本等)。"""
132
+ return {
133
+ "serial": self.device_sn,
134
+ "platform": self.platform,
135
+ "device_type": self.get_device_type(),
136
+ "os_type": self.get_os_type(),
137
+ "display_size": self.get_display_size(),
138
+ "display_rotation": self.get_display_rotation(),
139
+ }
140
+
141
+ def get_display_size(self) -> Tuple[int, int]:
142
+ result = self._rpc.call("Driver.getDisplaySize", DRIVER_REF, [])
143
+ if isinstance(result, dict):
144
+ return (int(result.get('x', 0)), int(result.get('y', 0)))
145
+ if isinstance(result, (list, tuple)) and len(result) >= 2:
146
+ return (int(result[0]), int(result[1]))
147
+ return (0, 0)
148
+
149
+ def get_display_rotation(self) -> int:
150
+ result = self._rpc.call("Driver.getDisplayRotation", DRIVER_REF, [])
151
+ return int(result) if result is not None else 0
152
+
153
+ def set_display_rotation(self, rotation: int) -> None:
154
+ self._rpc.call("Driver.setDisplayRotation", DRIVER_REF, [rotation])
155
+
156
+ def set_display_rotation_enabled(self, enabled: bool) -> None:
157
+ self._rpc.call(
158
+ "Driver.setDisplayRotationEnabled", DRIVER_REF, [enabled]
159
+ )
160
+
161
+ def get_device_type(self) -> str:
162
+ return self.shell("param get const.product.devicetype").strip()
163
+
164
+ def get_os_type(self) -> str:
165
+ return "harmony"
166
+
167
+ # ============================================================
168
+ # 屏幕操作
169
+ # ============================================================
170
+
171
+ def screen_on(self) -> None:
172
+ logger.debug("亮屏 (serial=%s)", self.device_sn)
173
+ self.shell("power-shell wakeup")
174
+
175
+ def screen_off(self) -> None:
176
+ logger.debug("熄屏 (serial=%s)", self.device_sn)
177
+ self.shell("power-shell suspend")
178
+
179
+ def is_screen_on(self) -> bool:
180
+ output = self.shell("hidumper -s PowerManagerService -a '-a'")
181
+ return "Current State: AWAKE" in output
182
+
183
+ def is_screen_locked(self) -> bool:
184
+ output = self.shell("hidumper -s ScreenlockService -a -all")
185
+ for line in output.splitlines():
186
+ if "screenLocked" in line:
187
+ return "false" not in line
188
+ return not self.is_screen_on()
189
+
190
+ def unlock(self) -> None:
191
+ logger.debug("解锁设备 (serial=%s)", self.device_sn)
192
+ w, h = self.get_display_size()
193
+ self.screen_on()
194
+ self.wait(1)
195
+ if not self.is_screen_locked():
196
+ return
197
+ if self.get_device_type() == "2in1":
198
+ self.press("enter")
199
+ else:
200
+ self.swipe(
201
+ int(0.5 * w), int(0.99 * h),
202
+ int(0.5 * w), int(0.7 * h),
203
+ duration=0.1,
204
+ )
205
+
206
+ def set_sleep_time(self, seconds: float) -> None:
207
+ if not self.is_screen_on():
208
+ self.screen_on()
209
+ self.shell("power-shell timeout -o %d" % int(seconds * 1000))
210
+
211
+ def restore_sleep_time(self) -> None:
212
+ self.shell("power-shell timeout -r")
213
+
214
+ def wake_up_display(self) -> None:
215
+ """唤醒屏幕(语义化别名,等价于 screen_on)。"""
216
+ self.screen_on()
217
+
218
+ def close_display(self) -> None:
219
+ """关闭屏幕(语义化别名,等价于 screen_off)。"""
220
+ self.screen_off()
221
+
222
+ # ============================================================
223
+ # 选择器入口(U2 风格)
224
+ # ============================================================
225
+
226
+ def __call__(self, **kwargs) -> 'UiObject':
227
+ selector = build_selector(**kwargs)
228
+ return UiObject(self, selector)
229
+
230
+ def xpath(self, xpath: str) -> 'UiObject':
231
+ selector = build_selector(xpath=xpath)
232
+ return UiObject(self, selector)
233
+
234
+ # ============================================================
235
+ # 应用管理
236
+ # ============================================================
237
+
238
+ def app_start(self, package: str, activity: Optional[str] = None,
239
+ params: str = "", wait_time: float = 1) -> None:
240
+ # ability 名需全路径(包名.Ability名),短名自动拼接
241
+ if activity is None:
242
+ ability = package + ".MainAbility"
243
+ elif "." not in activity:
244
+ ability = package + "." + activity
245
+ else:
246
+ ability = activity
247
+ cmd = "aa start -a %s -b %s" % (ability, package)
248
+ if params:
249
+ cmd += " " + params
250
+ logger.debug("启动应用 %s/%s (serial=%s)", package, ability, self.device_sn)
251
+ self.shell(cmd)
252
+ if wait_time:
253
+ self.wait(wait_time)
254
+
255
+ def app_stop(self, package: str, wait_time: float = 0.5) -> None:
256
+ logger.debug("停止应用 %s (serial=%s)", package, self.device_sn)
257
+ self.shell("aa force-stop %s" % package)
258
+ if wait_time:
259
+ self.wait(wait_time)
260
+
261
+ def app_current(self) -> Tuple[str, str]:
262
+ """获取当前前台应用包名与 Ability 名。
263
+
264
+ 通过 hidumper 解析焦点窗口 ID 与 mission 列表匹配。
265
+ 位于桌面或读取失败时返回 (None, None)。
266
+
267
+ Returns:
268
+ (package_name, ability_name)
269
+ """
270
+ # 焦点窗口 ID
271
+ win_echo = self.shell("hidumper -s WindowManagerService -a '-a'")
272
+ focus_match = re.search(r"Focus window: (\d+)", win_echo)
273
+ if not focus_match:
274
+ return (None, None)
275
+ focus_window = focus_match.group(1)
276
+
277
+ # mission 列表
278
+ mission_echo = self.shell("hidumper -s AbilityManagerService -a -l")
279
+ missions = re.findall(
280
+ r"Mission ID #(\d+)\s+mission name #\[(.*?)\]", mission_echo
281
+ )
282
+ for mission_id, mission_name in missions:
283
+ if mission_id == focus_window:
284
+ pkg = mission_name.split(":")[0].replace("#", "")
285
+ ability = mission_name.split(":")[-1]
286
+ return (pkg, ability)
287
+
288
+ # 焦点窗口未命中 mission,回退在窗口回显中查找
289
+ missions = re.findall(
290
+ r"Mission ID #(\d+)\s+mission name #\[(.*?)\]", win_echo
291
+ )
292
+ for mission_id, mission_name in missions:
293
+ if mission_id == focus_window:
294
+ pkg = mission_name.split(":")[0].replace("#", "")
295
+ ability = mission_name.split(":")[-1]
296
+ return (pkg, ability)
297
+
298
+ return (None, None)
299
+
300
+ def app_list(self) -> List[str]:
301
+ output = self.shell("bm dump -a")
302
+ packages = []
303
+ for line in output.splitlines():
304
+ line = line.strip()
305
+ if line and not line.startswith("ID") and "." in line:
306
+ packages.append(line)
307
+ return packages
308
+
309
+ def has_app(self, package: str) -> bool:
310
+ output = self.shell("bm dump -n %s" % shlex.quote(package))
311
+ return package in output
312
+
313
+ def clear_app_data(self, package: str) -> None:
314
+ self.shell("bm clean -n %s -d" % shlex.quote(package))
315
+
316
+ def app_install(self, path: str, options: str = "") -> None:
317
+ """安装应用。
318
+
319
+ Args:
320
+ path: 安装包在设备端的路径(hap/hsp 包)
321
+ options: bm install 额外参数(如 "-r" 覆盖安装)
322
+ """
323
+ cmd = "bm install -p %s" % shlex.quote(path)
324
+ if options:
325
+ cmd += " " + options
326
+ logger.debug("安装应用 %s (serial=%s)", path, self.device_sn)
327
+ self.shell(cmd)
328
+
329
+ def app_uninstall(self, package: str) -> None:
330
+ """卸载应用。"""
331
+ logger.debug("卸载应用 %s (serial=%s)", package, self.device_sn)
332
+ self.shell("bm uninstall -n %s" % shlex.quote(package))
333
+
334
+ # ============================================================
335
+ # 坐标操作
336
+ # ============================================================
337
+
338
+ def click(self, x: int, y: int) -> None:
339
+ self._rpc.call("Driver.click", DRIVER_REF, [x, y])
340
+
341
+ def long_click(self, x: int, y: int, duration: float = 0.5) -> None:
342
+ # 设备端 longClickAt 要求 duration >= 1500ms,低于阈值用 longClick(设备默认约1秒)
343
+ duration_ms = int(duration * 1000)
344
+ if duration_ms >= 1500:
345
+ self._rpc.call(
346
+ "Driver.longClickAt", DRIVER_REF, [{"x": x, "y": y}, duration_ms]
347
+ )
348
+ else:
349
+ self._rpc.call("Driver.longClick", DRIVER_REF, [x, y])
350
+
351
+ def double_click(self, x: int, y: int) -> None:
352
+ self._rpc.call("Driver.doubleClick", DRIVER_REF, [x, y])
353
+
354
+ def swipe(self, x1: int, y1: int, x2: int, y2: int,
355
+ duration: float = 0.5) -> None:
356
+ distance = max(abs(x2 - x1), abs(y2 - y1), 1)
357
+ speed = int(distance / duration) if duration > 0 else 600
358
+ self._rpc.call(
359
+ "Driver.swipe", DRIVER_REF, [x1, y1, x2, y2, speed]
360
+ )
361
+
362
+ def swipe_dir(self, direction: str, distance: int = 60,
363
+ area=None, speed=None) -> None:
364
+ w, h = self.get_display_size()
365
+ cx, cy = w // 2, h // 2
366
+ direction = direction.upper()
367
+ if direction == 'UP':
368
+ x1, y1, x2, y2 = cx, cy + distance, cx, cy - distance
369
+ elif direction == 'DOWN':
370
+ x1, y1, x2, y2 = cx, cy - distance, cx, cy + distance
371
+ elif direction == 'LEFT':
372
+ x1, y1, x2, y2 = cx + distance, cy, cx - distance, cy
373
+ elif direction == 'RIGHT':
374
+ x1, y1, x2, y2 = cx - distance, cy, cx + distance, cy
375
+ else:
376
+ raise DevhelmError("未知滑动方向: %s" % direction)
377
+ self.swipe(x1, y1, x2, y2)
378
+
379
+ def drag(self, x1: int, y1: int, x2: int, y2: int,
380
+ duration: float = 0.5) -> None:
381
+ distance = max(abs(x2 - x1), abs(y2 - y1), 1)
382
+ speed = int(distance / duration) if duration > 0 else 600
383
+ self._rpc.call(
384
+ "Driver.drag", DRIVER_REF, [x1, y1, x2, y2, speed]
385
+ )
386
+
387
+ def fling(self, direction: str, distance: int = 50,
388
+ area=None, speed: str = "fast") -> None:
389
+ speed_map = {"fast": 1000, "normal": 600, "slow": 300}
390
+ swipe_speed = speed_map.get(speed, 600)
391
+ w, h = self.get_display_size()
392
+ cx, cy = w // 2, h // 2
393
+ direction = direction.upper()
394
+ if direction == 'UP':
395
+ x1, y1, x2, y2 = cx, cy + distance, cx, cy - distance
396
+ elif direction == 'DOWN':
397
+ x1, y1, x2, y2 = cx, cy - distance, cx, cy + distance
398
+ elif direction == 'LEFT':
399
+ x1, y1, x2, y2 = cx + distance, cy, cx - distance, cy
400
+ elif direction == 'RIGHT':
401
+ x1, y1, x2, y2 = cx - distance, cy, cx + distance, cy
402
+ else:
403
+ raise DevhelmError("未知滑动方向: %s" % direction)
404
+ self._rpc.call(
405
+ "Driver.swipe", DRIVER_REF, [x1, y1, x2, y2, swipe_speed]
406
+ )
407
+
408
+ # ============================================================
409
+ # 实时触控(Gestures 模块,支持 down/move/up 序列)
410
+ # ============================================================
411
+
412
+ def touch_down(self, x: int, y: int) -> None:
413
+ """按下触控点。"""
414
+ from devhelmkit.harmony.rpc.proxy_v2 import rpc_gestures
415
+ logger.debug("Gestures -> touchDown (%d, %d)", x, y)
416
+ rpc_gestures(self._device, "touchDown", {"x": x, "y": y})
417
+
418
+ def touch_move(self, x: int, y: int) -> None:
419
+ """移动触控点(需先 touch_down)。"""
420
+ from devhelmkit.harmony.rpc.proxy_v2 import rpc_gestures
421
+ logger.debug("Gestures -> touchMove (%d, %d)", x, y)
422
+ rpc_gestures(self._device, "touchMove", {"x": x, "y": y})
423
+
424
+ def touch_up(self, x: int, y: int) -> None:
425
+ """抬起触控点(结束触控序列)。"""
426
+ from devhelmkit.harmony.rpc.proxy_v2 import rpc_gestures
427
+ logger.debug("Gestures -> touchUp (%d, %d)", x, y)
428
+ rpc_gestures(self._device, "touchUp", {"x": x, "y": y})
429
+
430
+ # ============================================================
431
+ # 按键
432
+ # ============================================================
433
+
434
+ def press(self, key: str) -> None:
435
+ keycode = _KEY_MAP.get(key.lower())
436
+ if keycode is None:
437
+ raise DevhelmError("未知按键: %s" % key)
438
+ self.press_keycode(int(keycode))
439
+
440
+ def press_keycode(self, keycode: int) -> None:
441
+ if isinstance(keycode, KeyCode):
442
+ keycode = int(keycode)
443
+ # Driver.pressKey 设备端不存在,改用 Driver.triggerKey
444
+ self._rpc.call("Driver.triggerKey", DRIVER_REF, [keycode])
445
+
446
+ def go_home(self) -> None:
447
+ # 专用 API,比 pressKey 更稳定
448
+ self._rpc.call("Driver.pressHome", DRIVER_REF, [])
449
+
450
+ def go_back(self) -> None:
451
+ # 专用 API,比 pressKey 更稳定
452
+ self._rpc.call("Driver.pressBack", DRIVER_REF, [])
453
+
454
+ def press_power(self) -> None:
455
+ self.press_keycode(int(KeyCode.POWER))
456
+
457
+ def press_combination_key(self, key1: int, key2: int,
458
+ key3: Optional[int] = None) -> None:
459
+ keys = []
460
+ for item in (key1, key2, key3):
461
+ if item is None:
462
+ continue
463
+ if isinstance(item, KeyCode):
464
+ item = int(item)
465
+ keys.append(item)
466
+ self._rpc.call("Driver.triggerCombineKeys", DRIVER_REF, keys)
467
+
468
+ # ============================================================
469
+ # Shell
470
+ # ============================================================
471
+
472
+ def shell(self, cmd: str, timeout: float = 60) -> str:
473
+ return self._device.shell(cmd, timeout=timeout)
474
+
475
+ # ============================================================
476
+ # 截图
477
+ # ============================================================
478
+
479
+ def screenshot(self, filename: Optional[str] = None,
480
+ area=None) -> Union['Image', str, None]:
481
+ # area 暂未支持,预留接口
482
+ import os
483
+ import tempfile
484
+ from PIL import Image
485
+ # 设备端 snapshot_display 强制要求后缀为 .jpeg
486
+ remote_path = "/data/local/tmp/devhelm_screenshot.jpeg"
487
+ self.shell("snapshot_display -f %s" % remote_path)
488
+ # 先 pull 到本地临时 jpeg 文件
489
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpeg", delete=False)
490
+ tmp_path = tmp.name
491
+ tmp.close()
492
+ try:
493
+ self._device.pull(remote_path, tmp_path)
494
+ img = Image.open(tmp_path)
495
+ img.load()
496
+ if filename:
497
+ # 按用户指定路径扩展名保存(png/jpg/...)
498
+ img.save(filename)
499
+ return filename
500
+ return img
501
+ finally:
502
+ if os.path.exists(tmp_path):
503
+ os.remove(tmp_path)
504
+
505
+ def dump_hierarchy(self, source: str = "rpc",
506
+ filename: Optional[str] = None) -> Union[dict, str, None]:
507
+ """导出控件树。
508
+
509
+ - source="rpc": 走 uitest RPC(Captures.captureLayout),直接返回控件树 JSON,无需文件中转
510
+ - source="hdc": 走 hdc shell(uitest dumpLayout -p),独立于 RPC 守护进程状态
511
+ - filename 为 None:返回解析后的 dict
512
+ - filename 指定:保存 JSON 到文件,返回文件路径 str
513
+ """
514
+ import json
515
+ import os
516
+
517
+ if source not in ("rpc", "hdc"):
518
+ raise ValueError("source 仅支持 'rpc' 或 'hdc',当前: %s" % source)
519
+
520
+ if source == "rpc":
521
+ # 走 RPC 通道:Captures.captureLayout 直接返回控件树 JSON
522
+ from devhelmkit.harmony.rpc.proxy_v2 import rpc_captures
523
+ logger.debug("RPC(Captures) -> captureLayout")
524
+ reply = rpc_captures(self._device, "captureLayout", {})
525
+ try:
526
+ data = json.loads(reply)
527
+ except json.JSONDecodeError as e:
528
+ logger.error("控件树响应解析失败: %s", e)
529
+ return None
530
+ # 检测错误
531
+ if isinstance(data, dict):
532
+ if data.get('error'):
533
+ logger.warn("captureLayout 调用失败: %s", data['error'])
534
+ return None
535
+ if data.get('exception'):
536
+ msg = data['exception'].get('message', str(data['exception']))
537
+ logger.warn("captureLayout 调用异常: %s", msg)
538
+ return None
539
+ # 响应可能是 {"result": {...}} 或直接是控件树
540
+ if 'result' in data:
541
+ data = data['result']
542
+ logger.debug("RPC(Captures) <- captureLayout ok")
543
+ else:
544
+ # 走 hdc shell:uitest dumpLayout -p 命令独立于 RPC 守护进程
545
+ import tempfile
546
+ remote_path = "/data/local/tmp/devhelm_layout.json"
547
+ self.shell("uitest dumpLayout -p %s" % remote_path)
548
+ tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
549
+ tmp_path = tmp.name
550
+ tmp.close()
551
+ try:
552
+ self._device.pull(remote_path, tmp_path)
553
+ with open(tmp_path, "r", encoding="utf-8") as f:
554
+ data = json.load(f)
555
+ except Exception as e:
556
+ logger.error("控件树解析失败 (source=hdc): %s", e)
557
+ return None
558
+ finally:
559
+ if os.path.exists(tmp_path):
560
+ os.remove(tmp_path)
561
+
562
+ if not isinstance(data, dict):
563
+ logger.error("控件树数据格式异常: %s", type(data))
564
+ return None
565
+
566
+ if filename:
567
+ with open(filename, "w", encoding="utf-8") as f:
568
+ json.dump(data, f, ensure_ascii=False, indent=2)
569
+ return filename
570
+ return data
571
+
572
+ # ============================================================
573
+ # 等待
574
+ # ============================================================
575
+
576
+ def wait(self, seconds: float) -> None:
577
+ time.sleep(seconds)
578
+
579
+ def wait_for_idle(self, idle_time: float = 0.7,
580
+ timeout: float = 10) -> None:
581
+ # TODO: 通过 RPC 轮询 UI 空闲状态
582
+ self.wait(idle_time)
583
+
584
+ def implicitly_wait(self, seconds: float) -> None:
585
+ self._implicit_wait = seconds
586
+
587
+ # ============================================================
588
+ # 窗口
589
+ # ============================================================
590
+
591
+ @property
592
+ def window(self) -> 'UiWindow':
593
+ return UiWindow(self, self._rpc)
594
+
595
+ def get_windows(self) -> List['UiWindow']:
596
+ """获取所有窗口(当前不支持,返回空列表)。"""
597
+ return []
598
+
599
+ # ============================================================
600
+ # webview 自动化
601
+ # ============================================================
602
+
603
+ def webview(self, bundle_name: str,
604
+ chromedriver_search_path: str = "",
605
+ chromedriver_exe_path: str = "",
606
+ remote_devtools_port: Optional[int] = None,
607
+ connection_timeout: int = 60,
608
+ options=None) -> "WebViewDriver":
609
+ """连接指定应用的 webview,返回 selenium webdriver 封装。
610
+
611
+ Args:
612
+ bundle_name: 目标应用包名
613
+ chromedriver_search_path: chromedriver 存放目录(多版本结构)
614
+ chromedriver_exe_path: 直接指定 chromedriver 路径(优先)
615
+ remote_devtools_port: 自定义 webview 内核的 devtools 端口;
616
+ 系统 web 内核无需指定
617
+ connection_timeout: 连接超时(秒)
618
+ options: 传递给 selenium webdriver 的 options
619
+
620
+ Returns:
621
+ WebViewDriver 实例,可通过 .driver 访问 selenium webdriver
622
+
623
+ Example:
624
+ wv = d.webview("com.huawei.hmos.browser")
625
+ wv.driver.get("https://www.baidu.com")
626
+ wv.close()
627
+ """
628
+ from devhelmkit.harmony.webview import WebViewDriver
629
+ wv = WebViewDriver(
630
+ self._device,
631
+ chromedriver_search_path=chromedriver_search_path,
632
+ chromedriver_exe_path=chromedriver_exe_path,
633
+ )
634
+ wv.connect(
635
+ bundle_name,
636
+ remote_devtools_port=remote_devtools_port,
637
+ connection_timeout=connection_timeout,
638
+ options=options,
639
+ )
640
+ return wv
641
+
642
+ # ============================================================
643
+ # 文件操作
644
+ # ============================================================
645
+
646
+ def push_file(self, local_path: str, remote_path: str,
647
+ timeout: int = 60) -> None:
648
+ self._device.push(local_path, remote_path, timeout=timeout)
649
+
650
+ def pull_file(self, remote_path: str,
651
+ local_path: Optional[str] = None,
652
+ timeout: int = 60) -> None:
653
+ if local_path is None:
654
+ import tempfile
655
+ tmp = tempfile.NamedTemporaryFile(delete=False)
656
+ local_path = tmp.name
657
+ tmp.close()
658
+ self._device.pull(remote_path, local_path, timeout=timeout)
659
+
660
+ def has_file(self, path: str) -> bool:
661
+ output = self.shell("test -f %s && echo yes || echo no" % shlex.quote(path))
662
+ return "yes" in output
663
+
664
+ # ============================================================
665
+ # 控件查找(UiObject 门面方法)
666
+ # ============================================================
667
+
668
+ def call_component(self, selector: SelectorSpec, method: str,
669
+ args: Optional[list] = None,
670
+ timeout: Optional[float] = None) -> Any:
671
+ return self._finder.call_component(
672
+ selector, method, args or [],
673
+ timeout if timeout is not None else self._implicit_wait
674
+ )
675
+
676
+ def component_exists(self, selector: SelectorSpec) -> bool:
677
+ selector = _to_selector(selector)
678
+ if selector is None:
679
+ return False
680
+ return self._finder.component_exists(selector)
681
+
682
+ def wait_component(self, selector: SelectorSpec,
683
+ timeout: float) -> bool:
684
+ selector = _to_selector(selector)
685
+ if selector is None:
686
+ return False
687
+ return self._finder.wait_component(selector, timeout)
688
+
689
+ def wait_component_gone(self, selector: SelectorSpec,
690
+ timeout: float) -> bool:
691
+ selector = _to_selector(selector)
692
+ if selector is None:
693
+ return True
694
+ return self._finder.wait_component_gone(selector, timeout)
695
+
696
+ def scroll_search(self, selector: SelectorSpec, target,
697
+ vertical: bool = True,
698
+ offset: Optional[int] = None) -> Optional['UiObject']:
699
+ # TODO: 滚动查找实现
700
+ raise NotImplementedError("scroll_search 待实现")
701
+
702
+ # ============================================================
703
+ # 控件查找(BaseDriver 契约)
704
+ # ============================================================
705
+
706
+ def find_component(self, target,
707
+ scroll_target=None) -> Optional['BaseComponent']:
708
+ selector = _to_selector(target)
709
+ if selector is None:
710
+ return None
711
+ return UiObject(self, selector)
712
+
713
+ def find_all_components(self, target) -> List['BaseComponent']:
714
+ selector = _to_selector(target)
715
+ if selector is None:
716
+ return []
717
+ refs = self._finder.find_components(selector)
718
+ # 每个 ref 包装为 UiObject,持有相同 selector
719
+ # TODO: 后续支持按 index 区分多个匹配控件
720
+ if not refs:
721
+ return []
722
+ return [UiObject(self, selector)]
723
+
724
+ def get_component_bound(self, target) -> Optional[Any]:
725
+ selector = _to_selector(target)
726
+ if selector is None:
727
+ return None
728
+ try:
729
+ return UiObject(self, selector).bounds
730
+ except DevhelmError:
731
+ return None
732
+
733
+ def get_component_property(self, target, name: str) -> Any:
734
+ selector = _to_selector(target)
735
+ if selector is None:
736
+ raise DevhelmError("无效的控件目标: %r" % (target,))
737
+ method = _PROPERTY_METHOD_MAP.get(name)
738
+ if method is None:
739
+ raise DevhelmError(
740
+ "不支持的属性: %s,支持: %s" % (name, list(_PROPERTY_METHOD_MAP.keys()))
741
+ )
742
+ return self.call_component(selector, method)
743
+
744
+ def switch_component_status(self, target, checked: bool) -> None:
745
+ """切换控件状态(如 Checkbox/RadioButton)。
746
+
747
+ 读取控件当前 isChecked,仅当与目标状态不一致时点击切换,
748
+ 避免重复点击导致状态回退。
749
+
750
+ Args:
751
+ target: 控件目标(SelectorSpec / UiObject / 选择器 kwargs)
752
+ checked: 目标状态 True=选中 / False=取消选中
753
+ """
754
+ selector = _to_selector(target)
755
+ if selector is None:
756
+ raise DevhelmError("无效的控件目标: %r" % (target,))
757
+ current = self.call_component(selector, "isChecked")
758
+ if bool(current) != bool(checked):
759
+ self.call_component(selector, "click")
760
+
761
+ # ============================================================
762
+ # 图片识别
763
+ # ============================================================
764
+
765
+ def find_image(self, image_path: str,
766
+ mode: str = "sift") -> Optional[Any]:
767
+ # TODO: 图像识别能力后续阶段实现
768
+ raise NotImplementedError("find_image 待实现")
769
+
770
+ def touch_image(self, image_path: str, mode: str = "normal",
771
+ similarity: float = 0.95) -> None:
772
+ # TODO: 图像识别能力后续阶段实现
773
+ raise NotImplementedError("touch_image 待实现")
774
+
775
+ # ============================================================
776
+ # 文本输入辅助
777
+ # ============================================================
778
+
779
+ def hide_keyboard(self) -> None:
780
+ self.press("back")
781
+
782
+ def input_text_on_cursor(self, text: str) -> None:
783
+ import shlex
784
+ self.shell("uitest uiInput text %s" % shlex.quote(text))
785
+
786
+ def clear_text_on_cursor(self) -> None:
787
+ """清空当前获焦输入框文本。
788
+
789
+ 通过 uitest uiInput clear 命令清空当前光标所在输入框,
790
+ 不依赖控件定位。
791
+ """
792
+ self.shell("uitest uiInput clear")
793
+
794
+ def move_cursor(self, direction: str, times: int = 1) -> None:
795
+ key_map = {
796
+ "LEFT": KeyCode.DPAD_LEFT,
797
+ "RIGHT": KeyCode.DPAD_RIGHT,
798
+ "UP": KeyCode.DPAD_UP,
799
+ "DOWN": KeyCode.DPAD_DOWN,
800
+ "BEGIN": KeyCode.MOVE_HOME,
801
+ "END": KeyCode.MOVE_END,
802
+ }
803
+ keycode = key_map.get(direction.upper())
804
+ if keycode is None:
805
+ raise DevhelmError("未知光标方向: %s" % direction)
806
+ for _ in range(times):
807
+ self.press_keycode(int(keycode))
808
+
809
+ # ============================================================
810
+ # 手势扩展
811
+ # ============================================================
812
+
813
+ def _build_pointer_matrix(self,
814
+ gestures: List[GestureAction]) -> str:
815
+ """将 GestureAction 列表转为设备端 PointerMatrix 远程对象。
816
+
817
+ 每个 GestureAction 代表一指的轨迹,步骤数取最大值,
818
+ 不足的用末尾坐标填充以保持位置静止。
819
+
820
+ Returns:
821
+ PointerMatrix 对象引用(如 "PointerMatrix#0")
822
+ """
823
+ fingers = len(gestures)
824
+ if fingers == 0:
825
+ raise DevhelmError("gestures 不能为空")
826
+ max_steps = max(len(g.steps) for g in gestures)
827
+ if max_steps == 0:
828
+ raise DevhelmError("GestureAction 无步骤")
829
+ ref = self._rpc.call(
830
+ "PointerMatrix.create", "PointerMatrix#seed", [fingers, max_steps]
831
+ )
832
+ if not isinstance(ref, str):
833
+ raise DevhelmError("PointerMatrix 创建失败: %r" % (ref,))
834
+ for finger_idx, gesture in enumerate(gestures):
835
+ steps = gesture.steps
836
+ if not steps:
837
+ continue
838
+ last = steps[-1]
839
+ for step_idx in range(max_steps):
840
+ step = steps[step_idx] if step_idx < len(steps) else last
841
+ self._rpc.call(
842
+ "PointerMatrix.setPoint", ref,
843
+ [finger_idx, step_idx, {"x": step.x, "y": step.y}]
844
+ )
845
+ return ref
846
+
847
+ def inject_gesture(self, gesture: GestureAction,
848
+ speed: int = 2000) -> None:
849
+ """单指自定义手势。"""
850
+ ref = self._build_pointer_matrix([gesture])
851
+ self._rpc.call("Driver.injectMultiPointerAction", DRIVER_REF, [ref, speed])
852
+
853
+ def inject_multi_finger_gesture(self, gestures: List[GestureAction],
854
+ speed: int = 2000) -> None:
855
+ """多指手势,每个 GestureAction 代表一指轨迹。"""
856
+ ref = self._build_pointer_matrix(gestures)
857
+ self._rpc.call("Driver.injectMultiPointerAction", DRIVER_REF, [ref, speed])
858
+
859
+ def two_finger_swipe(self, s1: tuple, e1: tuple,
860
+ s2: tuple, e2: tuple,
861
+ duration: float = 0.5) -> None:
862
+ """双指滑动。
863
+
864
+ Args:
865
+ s1/e1: 第一指起止坐标
866
+ s2/e2: 第二指起止坐标
867
+ duration: 滑动时长(秒),用于计算速度
868
+ """
869
+ g1 = GestureAction()
870
+ g1.add_step("move", s1[0], s1[1])
871
+ g1.add_step("move", e1[0], e1[1])
872
+ g2 = GestureAction()
873
+ g2.add_step("move", s2[0], s2[1])
874
+ g2.add_step("move", e2[0], e2[1])
875
+ dist = max(abs(e1[0] - s1[0]), abs(e1[1] - s1[1]), 1)
876
+ speed = int(dist / duration) if duration > 0 else 600
877
+ self.inject_multi_finger_gesture([g1, g2], speed)
878
+
879
+ def multi_finger_touch(self, points: List[tuple],
880
+ duration: float = 0.1) -> None:
881
+ """多指同时点击。
882
+
883
+ Args:
884
+ points: 各指按下坐标 [(x, y), ...]
885
+ duration: 按住时长(秒),用于计算速度
886
+ """
887
+ gestures = []
888
+ for pt in points:
889
+ g = GestureAction()
890
+ g.add_step("down", pt[0], pt[1])
891
+ g.add_step("up", pt[0], pt[1])
892
+ gestures.append(g)
893
+ speed = int(100 / duration) if duration > 0 else 1000
894
+ self.inject_multi_finger_gesture(gestures, speed)
895
+
896
+ # ============================================================
897
+ # 鼠标操作
898
+ # ============================================================
899
+
900
+ def mouse_click(self, pos, button_id: int = 0,
901
+ key1: Optional[int] = None,
902
+ key2: Optional[int] = None) -> None:
903
+ """鼠标点击。
904
+
905
+ Args:
906
+ pos: 点击位置 (x, y) 或 {x, y}
907
+ button_id: 0=左键 1=右键 2=中键
908
+ key1: 组合键1(如 Ctrl=2072)
909
+ key2: 组合键2
910
+ """
911
+ args = [_to_point(pos), button_id]
912
+ if key1 is not None:
913
+ args.append(key1)
914
+ if key2 is not None:
915
+ args.append(key2)
916
+ self._rpc.call("Driver.mouseClick", DRIVER_REF, args)
917
+
918
+ def mouse_double_click(self, pos, button_id: int = 0) -> None:
919
+ """鼠标双击。"""
920
+ self._rpc.call(
921
+ "Driver.mouseDoubleClick", DRIVER_REF,
922
+ [_to_point(pos), button_id]
923
+ )
924
+
925
+ def mouse_long_click(self, pos, button_id: int = 0,
926
+ press_time: float = 1.5) -> None:
927
+ """鼠标长按。
928
+
929
+ Args:
930
+ press_time: 按住时长(秒),转为毫秒传入设备端
931
+ """
932
+ duration_ms = int(press_time * 1000)
933
+ self._rpc.call(
934
+ "Driver.mouseLongClick", DRIVER_REF,
935
+ [_to_point(pos), button_id, 0, 0, duration_ms]
936
+ )
937
+
938
+ def mouse_scroll(self, pos, direction: str, steps: int,
939
+ key1: Optional[int] = None,
940
+ key2: Optional[int] = None) -> None:
941
+ """鼠标滚轮滚动。
942
+
943
+ Args:
944
+ direction: 'up' 向上 / 'down' 向下
945
+ steps: 滚动步数
946
+ """
947
+ down = direction.lower() == "down"
948
+ args = [_to_point(pos), down, steps]
949
+ if key1 is not None:
950
+ args.append(key1)
951
+ if key2 is not None:
952
+ args.append(key2)
953
+ self._rpc.call("Driver.mouseScroll", DRIVER_REF, args)
954
+
955
+ def mouse_move_to(self, pos) -> None:
956
+ """鼠标光标瞬移到指定位置。"""
957
+ self._rpc.call("Driver.mouseMoveTo", DRIVER_REF, [_to_point(pos)])
958
+
959
+ def mouse_move(self, start, end, speed: int = 3000) -> None:
960
+ """鼠标沿轨迹从起点移动到终点。"""
961
+ self._rpc.call(
962
+ "Driver.mouseMoveWithTrack", DRIVER_REF,
963
+ [_to_point(start), _to_point(end), speed]
964
+ )
965
+
966
+ def mouse_drag(self, start, end, speed: int = 3000) -> None:
967
+ """鼠标拖拽。"""
968
+ self._rpc.call(
969
+ "Driver.mouseDrag", DRIVER_REF,
970
+ [_to_point(start), _to_point(end), speed]
971
+ )
972
+
973
+ # ============================================================
974
+ # 触控笔操作
975
+ # ============================================================
976
+
977
+ def pen_click(self, target, offset=None) -> None:
978
+ """触控笔点击。
979
+
980
+ Args:
981
+ target: 坐标 (x, y) 或 UiObject(取 center)
982
+ offset: 相对偏移 (dx, dy)
983
+ """
984
+ point = self._resolve_target_point(target, offset)
985
+ self._rpc.call("Driver.penClick", DRIVER_REF, [point])
986
+
987
+ def pen_double_click(self, target, offset=None) -> None:
988
+ """触控笔双击。"""
989
+ point = self._resolve_target_point(target, offset)
990
+ self._rpc.call("Driver.penDoubleClick", DRIVER_REF, [point])
991
+
992
+ def pen_long_click(self, target, offset=None,
993
+ pressure: Optional[float] = None) -> None:
994
+ """触控笔长按。
995
+
996
+ Args:
997
+ pressure: 笔压力值 0.0-1.0
998
+ """
999
+ point = self._resolve_target_point(target, offset)
1000
+ args = [point]
1001
+ if pressure is not None:
1002
+ args.append(pressure)
1003
+ self._rpc.call("Driver.penLongClick", DRIVER_REF, args)
1004
+
1005
+ def pen_swipe(self, direction: str, distance: int = 60,
1006
+ area=None, pressure: Optional[float] = None,
1007
+ duration: float = 0.3) -> None:
1008
+ """触控笔方向滑动。
1009
+
1010
+ Args:
1011
+ direction: 'UP'/'DOWN'/'LEFT'/'RIGHT'
1012
+ distance: 滑动距离(像素)
1013
+ duration: 滑动时长(秒),用于计算速度
1014
+ """
1015
+ w, h = self.get_display_size()
1016
+ cx, cy = w // 2, h // 2
1017
+ direction = direction.upper()
1018
+ if direction == 'UP':
1019
+ start, end = (cx, cy + distance), (cx, cy - distance)
1020
+ elif direction == 'DOWN':
1021
+ start, end = (cx, cy - distance), (cx, cy + distance)
1022
+ elif direction == 'LEFT':
1023
+ start, end = (cx + distance, cy), (cx - distance, cy)
1024
+ elif direction == 'RIGHT':
1025
+ start, end = (cx - distance, cy), (cx + distance, cy)
1026
+ else:
1027
+ raise DevhelmError("未知滑动方向: %s" % direction)
1028
+ self.pen_slide(start, end, area, pressure, duration)
1029
+
1030
+ def pen_slide(self, start, end, area=None,
1031
+ pressure: Optional[float] = None,
1032
+ duration: float = 0.3) -> None:
1033
+ """触控笔精确滑动。
1034
+
1035
+ Args:
1036
+ start: 起点 (x, y)
1037
+ end: 终点 (x, y)
1038
+ pressure: 笔压力值
1039
+ duration: 滑动时长(秒),用于计算速度
1040
+ """
1041
+ start_pt = _to_point(start)
1042
+ end_pt = _to_point(end)
1043
+ dist = max(abs(end_pt['x'] - start_pt['x']),
1044
+ abs(end_pt['y'] - start_pt['y']), 1)
1045
+ speed = int(dist / duration) if duration > 0 else 600
1046
+ args = [start_pt, end_pt, speed]
1047
+ if pressure is not None:
1048
+ args.append(pressure)
1049
+ self._rpc.call("Driver.penSwipe", DRIVER_REF, args)
1050
+
1051
+ def pen_drag(self, start, end, area=None,
1052
+ pressure: Optional[float] = None,
1053
+ press_time: float = 1.5,
1054
+ duration: float = 0.5) -> None:
1055
+ """触控笔拖拽(按住后移动)。
1056
+
1057
+ Args:
1058
+ start: 起点 (x, y)
1059
+ end: 终点 (x, y)
1060
+ press_time: 起点按住时长(秒)
1061
+ duration: 移动到终点的时长(秒)
1062
+ pressure: 笔压力值
1063
+ """
1064
+ g = GestureAction(input_device=InputDevice.PEN)
1065
+ g.add_step("down", start[0], start[1], press_time)
1066
+ g.add_step("move", end[0], end[1], duration)
1067
+ ref = self._build_pointer_matrix([g])
1068
+ dist = max(abs(end[0] - start[0]), abs(end[1] - start[1]), 1)
1069
+ speed = int(dist / duration) if duration > 0 else 600
1070
+ args = [ref, speed]
1071
+ if pressure is not None:
1072
+ args.append(pressure)
1073
+ self._rpc.call("Driver.injectPenPointerAction", DRIVER_REF, args)
1074
+
1075
+ def pen_inject_gesture(self, gesture: GestureAction,
1076
+ pressure: Optional[float] = None,
1077
+ speed: int = 2000) -> None:
1078
+ """触控笔自定义手势。"""
1079
+ ref = self._build_pointer_matrix([gesture])
1080
+ args = [ref, speed]
1081
+ if pressure is not None:
1082
+ args.append(pressure)
1083
+ self._rpc.call("Driver.injectPenPointerAction", DRIVER_REF, args)
1084
+
1085
+ # ============================================================
1086
+ # 指关节操作
1087
+ # ============================================================
1088
+
1089
+ def knuckle_knock(self, targets: list, times: int = 2) -> None:
1090
+ """指关节敲击(常用于截屏等快捷操作)。
1091
+
1092
+ Args:
1093
+ targets: 敲击位置列表,1-2 个点 [(x, y), ...]
1094
+ times: 敲击次数
1095
+ """
1096
+ points = [_to_point(t) for t in targets]
1097
+ # 设备端底层参数非数组:单点 [point, times],双点 [p0, p1, times]
1098
+ args = points + [times]
1099
+ self._rpc.call("Driver.knuckleKnock", DRIVER_REF, args)
1100
+
1101
+ def inject_knuckle_gesture(self, gesture: GestureAction,
1102
+ speed: int = 2000) -> None:
1103
+ """指关节自定义手势。"""
1104
+ ref = self._build_pointer_matrix([gesture])
1105
+ self._rpc.call(
1106
+ "Driver.injectKnucklePointerAction", DRIVER_REF, [ref, speed]
1107
+ )
1108
+
1109
+ # ============================================================
1110
+ # 手势导航
1111
+ # ============================================================
1112
+
1113
+ def swipe_to_home(self, times: int = 1) -> None:
1114
+ logger.debug("上滑回桌面 times=%d (serial=%s)", times, self.device_sn)
1115
+ w, h = self.get_display_size()
1116
+ start_x = w // 2
1117
+ start_y = h - 10
1118
+ end_y = h - h // 3
1119
+ end_x = int(start_x * 1.2)
1120
+ for i in range(times):
1121
+ self.swipe(start_x, start_y, end_x, end_y, duration=0.3)
1122
+ if i < times - 1:
1123
+ self.wait(0.5)
1124
+ self.wait(1)
1125
+
1126
+ def swipe_to_back(self, side: str = "right", times: int = 1,
1127
+ height: float = 0.5) -> None:
1128
+ logger.debug("侧滑返回 side=%s times=%d (serial=%s)",
1129
+ side, times, self.device_sn)
1130
+ side = side.upper()
1131
+ if side == "RIGHT":
1132
+ start = self.to_abs_pos(0.99, height)
1133
+ end = self.to_abs_pos(0.6, height * 1.2)
1134
+ elif side == "LEFT":
1135
+ start = self.to_abs_pos(0.01, height)
1136
+ end = self.to_abs_pos(0.4, height * 1.2)
1137
+ else:
1138
+ raise DevhelmError("未知侧滑方向: %s" % side)
1139
+ cmd = "uinput -T -m %d %d %d %d %d" % (
1140
+ start[0], start[1], end[0], end[1], 200
1141
+ )
1142
+ for i in range(times):
1143
+ self.shell(cmd)
1144
+ if i < times - 1:
1145
+ self.wait(0.5)
1146
+
1147
+ def swipe_to_recent_task(self) -> None:
1148
+ logger.debug("上滑进入多任务 (serial=%s)", self.device_sn)
1149
+ w, h = self.get_display_size()
1150
+ start_x = w // 2
1151
+ start_y = h - 10
1152
+ end_y = h - h // 4
1153
+ end_x = int(start_x * 1.2)
1154
+ self.swipe(start_x, start_y, end_x, end_y, duration=0.3)
1155
+ self.wait(1)
1156
+
1157
+ # ============================================================
1158
+ # 坐标转换
1159
+ # ============================================================
1160
+
1161
+ def to_abs_pos(self, x: float, y: float) -> Tuple[int, int]:
1162
+ # 每轴独立判断:[-1,1] 按比例转换,否则按绝对值
1163
+ w, h = self.get_display_size()
1164
+ abs_x = int(x * w) if -1.0 <= x <= 1.0 else int(x)
1165
+ abs_y = int(y * h) if -1.0 <= y <= 1.0 else int(y)
1166
+ return (abs_x, abs_y)
1167
+
1168
+ # ============================================================
1169
+ # 设备初始化
1170
+ # ============================================================
1171
+
1172
+ def _setup_device(self) -> None:
1173
+ """设备初始化:检查 uitest 版本。"""
1174
+ uitest_version = self._get_uitest_version()
1175
+ if uitest_version in _UNSUPPORTED_UITEST_VERSIONS:
1176
+ raise DevhelmError(
1177
+ "不支持的 uitest 版本: %s" % uitest_version
1178
+ )
1179
+
1180
+ def _get_uitest_version(self) -> str:
1181
+ """获取 uitest 版本。"""
1182
+ output = self._device.shell("uitest --version")
1183
+ return output.strip()
1184
+
1185
+ # ============================================================
1186
+ # 穿戴设备扩展
1187
+ # ============================================================
1188
+
1189
+ def rotate_crown(self, steps: int,
1190
+ speed: Optional[int] = None) -> None:
1191
+ """旋转表冠(穿戴设备专用)。
1192
+
1193
+ Args:
1194
+ steps: 旋转角度(正数顺时针,负数逆时针)
1195
+ speed: 旋转速度(可选)
1196
+ """
1197
+ args = [steps]
1198
+ if speed is not None:
1199
+ args.append(speed)
1200
+ self._rpc.call("Driver.crownRotate", DRIVER_REF, args)
1201
+
1202
+ # ============================================================
1203
+ # 触控板操作
1204
+ # ============================================================
1205
+
1206
+ def touchpad_swipe(self, direction: str, fingers: int = 3,
1207
+ speed: Optional[int] = None) -> None:
1208
+ """触控板多指滑动。
1209
+
1210
+ Args:
1211
+ direction: 'UP'/'DOWN'/'LEFT'/'RIGHT'
1212
+ fingers: 手指数(2-4)
1213
+ speed: 滑动速度(可选)
1214
+ """
1215
+ direction_code = _UIDIRECTION_MAP.get(direction.upper())
1216
+ if direction_code is None:
1217
+ raise DevhelmError("未知滑动方向: %s" % direction)
1218
+ args = [fingers, direction_code]
1219
+ if speed is not None:
1220
+ args.append({"speed": speed})
1221
+ self._rpc.call("Driver.touchPadMultiFingerSwipe", DRIVER_REF, args)
1222
+
1223
+ def touchpad_swipe_and_hold(self, direction: str, fingers: int = 3,
1224
+ speed: Optional[int] = None) -> None:
1225
+ """触控板多指滑动后停顿(stay=true)。
1226
+
1227
+ Args:
1228
+ direction: 'UP'/'DOWN'/'LEFT'/'RIGHT'
1229
+ fingers: 手指数(2-4)
1230
+ speed: 滑动速度(可选)
1231
+ """
1232
+ direction_code = _UIDIRECTION_MAP.get(direction.upper())
1233
+ if direction_code is None:
1234
+ raise DevhelmError("未知滑动方向: %s" % direction)
1235
+ options: Dict[str, Any] = {"stay": True}
1236
+ if speed is not None:
1237
+ options["speed"] = speed
1238
+ self._rpc.call(
1239
+ "Driver.touchPadMultiFingerSwipe", DRIVER_REF,
1240
+ [fingers, direction_code, options]
1241
+ )
1242
+
1243
+ # ============================================================
1244
+ # 事件监听
1245
+ # ============================================================
1246
+
1247
+ def start_listen_toast(self) -> None:
1248
+ """开始 Toast 监听(once 模式,触发一次后自动移除)。"""
1249
+ if self._toast_observer is None:
1250
+ self._toast_observer = self._rpc.call(
1251
+ "Driver.createUIEventObserver", DRIVER_REF, []
1252
+ )
1253
+ self._rpc.call(
1254
+ "UIEventObserver.once", self._toast_observer,
1255
+ ["toastShow", "callback#0"]
1256
+ )
1257
+ self._toast_listening = True
1258
+
1259
+ def get_latest_toast(self, timeout: float = 3.0) -> str:
1260
+ """获取最新 Toast 文本。
1261
+
1262
+ 阻塞等待设备端 Toast 事件回调推送,超时抛 DevhelmTimeoutError。
1263
+ 需先调用 start_listen_toast。
1264
+ """
1265
+ if not self._toast_listening:
1266
+ raise DevhelmError("未启动 toast 监听,请先 start_listen_toast")
1267
+ push = self._device.wait_push(timeout)
1268
+ self._toast_listening = False
1269
+ if push is None:
1270
+ raise DevhelmTimeoutError("等待 toast 超时(%ss)" % timeout)
1271
+ return self._parse_event_text(push)
1272
+
1273
+ def check_toast(self, text: str, fuzzy: str = "equal",
1274
+ timeout: float = 3.0) -> bool:
1275
+ """检查 Toast 是否包含指定文本。
1276
+
1277
+ Args:
1278
+ text: 期望文本
1279
+ fuzzy: 匹配方式 'equal'/'contains'/'startswith'
1280
+ timeout: 等待超时(秒)
1281
+ """
1282
+ try:
1283
+ actual = self.get_latest_toast(timeout)
1284
+ except DevhelmTimeoutError:
1285
+ return False
1286
+ if fuzzy == "contains":
1287
+ return text in actual
1288
+ if fuzzy == "startswith":
1289
+ return actual.startswith(text)
1290
+ return actual == text
1291
+
1292
+ def start_listen_ui_event(self, event_type: str) -> None:
1293
+ """开始 UI 事件监听(once 模式)。
1294
+
1295
+ Args:
1296
+ event_type: 'dialogShow' / 'windowChange' / 'componentEventOccur'
1297
+ """
1298
+ if self._ui_event_observer is None:
1299
+ self._ui_event_observer = self._rpc.call(
1300
+ "Driver.createUIEventObserver", DRIVER_REF, []
1301
+ )
1302
+ if event_type in ("windowChange", "componentEventOccur"):
1303
+ args = [event_type, 1, {"timeout": 5000}, "callback#0"]
1304
+ else:
1305
+ args = [event_type, "callback#0"]
1306
+ self._rpc.call(
1307
+ "UIEventObserver.once", self._ui_event_observer, args
1308
+ )
1309
+ self._ui_event_listening = True
1310
+
1311
+ def get_latest_ui_event(self, timeout: float = 3.0) -> Optional[dict]:
1312
+ """获取最新 UI 事件数据。
1313
+
1314
+ 阻塞等待设备端事件回调推送,超时返回 None。
1315
+ 需先调用 start_listen_ui_event。
1316
+ """
1317
+ if not self._ui_event_listening:
1318
+ raise DevhelmError("未启动 ui_event 监听,请先 start_listen_ui_event")
1319
+ push = self._device.wait_push(timeout)
1320
+ self._ui_event_listening = False
1321
+ if push is None:
1322
+ return None
1323
+ return self._parse_event_dict(push)
1324
+
1325
+ @staticmethod
1326
+ def _parse_event_text(push: str) -> str:
1327
+ """从回调推送帧中提取文本字段(容错解析)。"""
1328
+ try:
1329
+ data = json.loads(push)
1330
+ except (json.JSONDecodeError, TypeError):
1331
+ return push
1332
+ if not isinstance(data, dict):
1333
+ return str(data)
1334
+ for key in ('args', 'params'):
1335
+ val = data.get(key)
1336
+ if isinstance(val, list) and val:
1337
+ info = val[0]
1338
+ if isinstance(info, dict):
1339
+ return info.get('text') or info.get('message') or ''
1340
+ info = data.get('result') or data.get('info') or data
1341
+ if isinstance(info, dict):
1342
+ return info.get('text') or info.get('message') or ''
1343
+ return str(info)
1344
+
1345
+ @staticmethod
1346
+ def _parse_event_dict(push: str) -> Optional[dict]:
1347
+ """从回调推送帧中提取事件数据字典(容错解析)。"""
1348
+ try:
1349
+ data = json.loads(push)
1350
+ except (json.JSONDecodeError, TypeError):
1351
+ return None
1352
+ if not isinstance(data, dict):
1353
+ return None
1354
+ for key in ('args', 'params'):
1355
+ val = data.get(key)
1356
+ if isinstance(val, list) and val and isinstance(val[0], dict):
1357
+ return val[0]
1358
+ info = data.get('result') or data.get('info')
1359
+ if isinstance(info, dict):
1360
+ return info
1361
+ return data
1362
+
1363
+ # ============================================================
1364
+ # 配置访问
1365
+ # ============================================================
1366
+
1367
+ @property
1368
+ def config(self) -> HarmonyDriverConfig:
1369
+ return self._config
1370
+
1371
+ def update_config(self, **kwargs) -> None:
1372
+ self._config.update_from_dict(kwargs)
1373
+
1374
+ def _resolve_target_point(self, target, offset=None) -> dict:
1375
+ """将 target 解析为 {x, y} 坐标点。
1376
+
1377
+ Args:
1378
+ target: 坐标 (x, y) / {x, y} / UiObject(取 center)
1379
+ offset: 相对偏移 (dx, dy)
1380
+ """
1381
+ if hasattr(target, 'center'):
1382
+ x, y = target.center()
1383
+ else:
1384
+ pt = _to_point(target)
1385
+ x, y = pt['x'], pt['y']
1386
+ if offset:
1387
+ x += int(offset[0])
1388
+ y += int(offset[1])
1389
+ return {"x": x, "y": y}
1390
+
1391
+
1392
+ def _to_point(pos) -> dict:
1393
+ """将坐标转为 {x, y} 字典。
1394
+
1395
+ 支持 (x, y) tuple/list 和 {x, y} dict。
1396
+ """
1397
+ if isinstance(pos, dict):
1398
+ return {"x": int(pos['x']), "y": int(pos['y'])}
1399
+ if isinstance(pos, (list, tuple)) and len(pos) >= 2:
1400
+ return {"x": int(pos[0]), "y": int(pos[1])}
1401
+ raise DevhelmError("无效的坐标: %r" % (pos,))
1402
+
1403
+
1404
+ # UiDirection 枚举值(设备端数字编码)
1405
+ _UIDIRECTION_MAP = {"LEFT": 0, "RIGHT": 1, "UP": 2, "DOWN": 3}
1406
+
1407
+
1408
+ def _to_selector(target) -> Optional[SelectorSpec]:
1409
+ """将 target 转为 SelectorSpec。"""
1410
+ if isinstance(target, SelectorSpec):
1411
+ return target
1412
+ if isinstance(target, dict):
1413
+ return build_selector(**target)
1414
+ if isinstance(target, str):
1415
+ return build_selector(text=target)
1416
+ return None