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,430 @@
1
+ # !/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """HdcDevice:hdc 命令封装与 bin 模式 RPC 通道。
4
+
5
+ 替代 xdevice DeviceNode,仅依赖 hdc 命令行工具。
6
+ RPC 通道统一使用 bin 模式(端口 8012),懒建立并复用长连接。
7
+
8
+ 状态机:CLOSED / CONNECTING / READY / BROKEN
9
+ - 懒连接:首次 rpc_call 时建立 hdc fport + socket,不在构造阶段创建
10
+ - 长连接复用:READY 状态下 socket 持续复用,降低延迟
11
+ - 断线重建:连接阶段失败按指数退避重试;通信阶段失败不重试
12
+ """
13
+ import json
14
+ import random
15
+ import select
16
+ import socket
17
+ import struct
18
+ import subprocess
19
+ import time
20
+ from datetime import datetime
21
+ from typing import List, Optional
22
+
23
+ from devhelmkit.exceptions import DeviceConnectError
24
+ from devhelmkit.harmony.agent.so_manager import AgentManager
25
+ from devhelmkit.utils import logger
26
+
27
+ # bin 模式 RPC 端口(v1 协议设备端监听端口;v2 协议使用 localabstract)
28
+ RPC_PORT = 8012
29
+
30
+ # UITest RPC 协议帧头尾
31
+ RPC_HEAD = b"_uitestkit_rpc_message_head_"
32
+ RPC_TAIL = b"_uitestkit_rpc_message_tail_"
33
+
34
+ # v2 协议设备端 localabstract socket 名称
35
+ UITEST_SOCKET_NAME = "uitest_socket"
36
+
37
+ # 连接重试参数
38
+ MAX_CONNECT_RETRIES = 3
39
+ CONNECT_RETRY_BASE_INTERVAL = 1.0
40
+
41
+ # socket 通信超时(秒)
42
+ SOCKET_TIMEOUT = 30
43
+
44
+ # 接收缓冲上限(字节),防止异常回显导致内存膨胀
45
+ RECV_BUFFER_LIMIT = 16 * 1024 * 1024
46
+
47
+ # 状态机
48
+ STATE_CLOSED = "CLOSED"
49
+ STATE_CONNECTING = "CONNECTING"
50
+ STATE_READY = "READY"
51
+ STATE_BROKEN = "BROKEN"
52
+
53
+
54
+ class HdcDevice:
55
+ """hdc 命令封装,提供 shell、文件传输、RPC 通道能力。"""
56
+
57
+ # hdc 可执行文件路径,默认从 PATH 查找;可通过 set_hdc_path 覆盖
58
+ _hdc_path: str = "hdc"
59
+
60
+ @classmethod
61
+ def set_hdc_path(cls, path: str) -> None:
62
+ """设置 hdc 可执行文件路径。
63
+
64
+ 当 hdc 未加入系统 PATH,或需指定特定版本时使用。
65
+ 设置后对所有后续创建的 HdcDevice 实例生效。
66
+
67
+ Args:
68
+ path: hdc 可执行文件绝对路径
69
+ """
70
+ cls._hdc_path = path
71
+
72
+ def __init__(self, serial: str):
73
+ self.serial = serial
74
+ self._socket: Optional[socket.socket] = None
75
+ self._state = STATE_CLOSED
76
+ self._fport_established = False
77
+ self._agent = AgentManager(self)
78
+
79
+ # ============================================================
80
+ # hdc 命令封装
81
+ # ============================================================
82
+
83
+ def _hdc_cmd(self, *args: str) -> List[str]:
84
+ """构造 hdc 命令列表,统一使用 _hdc_path 与设备序列号。"""
85
+ return [self._hdc_path, "-t", self.serial, *args]
86
+
87
+ def shell(self, cmd: str, timeout: float = 60) -> str:
88
+ """执行 shell 命令,返回回显。"""
89
+ result = subprocess.run(
90
+ self._hdc_cmd("shell", cmd),
91
+ capture_output=True, text=True, encoding="utf-8",
92
+ timeout=timeout
93
+ )
94
+ if result.returncode != 0:
95
+ raise DeviceConnectError(
96
+ "shell 命令失败 [%s]: %s" % (cmd, result.stderr)
97
+ )
98
+ return result.stdout
99
+
100
+ def push(self, local_path: str, remote_path: str, timeout: int = 60) -> None:
101
+ """推送文件到设备。"""
102
+ subprocess.run(
103
+ self._hdc_cmd("file", "send", local_path, remote_path),
104
+ check=True, timeout=timeout
105
+ )
106
+
107
+ def pull(self, remote_path: str, local_path: str, timeout: int = 60) -> None:
108
+ """从设备拉取文件。"""
109
+ subprocess.run(
110
+ self._hdc_cmd("file", "recv", remote_path, local_path),
111
+ check=True, timeout=timeout
112
+ )
113
+
114
+ def install(self, hap_path: str, options: str = "", timeout: int = 120) -> None:
115
+ """安装应用。"""
116
+ cmd = self._hdc_cmd("install")
117
+ if options:
118
+ cmd.extend(options.split())
119
+ cmd.append(hap_path)
120
+ subprocess.run(cmd, check=True, timeout=timeout)
121
+
122
+ def uninstall(self, package: str, timeout: int = 60) -> None:
123
+ """卸载应用。"""
124
+ subprocess.run(
125
+ self._hdc_cmd("uninstall", package),
126
+ check=True, timeout=timeout
127
+ )
128
+
129
+ @classmethod
130
+ def list_targets(cls) -> List[str]:
131
+ """列出所有连接的设备序列号。"""
132
+ result = subprocess.run(
133
+ [cls._hdc_path, "list", "targets"],
134
+ capture_output=True, text=True, encoding="utf-8"
135
+ )
136
+ targets = []
137
+ for line in result.stdout.splitlines():
138
+ line = line.strip()
139
+ if line and line != "[Empty]":
140
+ targets.append(line)
141
+ return targets
142
+
143
+ # ============================================================
144
+ # RPC 通道(bin 模式,端口 8012)
145
+ # ============================================================
146
+
147
+ def rpc_call(self, msg: str) -> str:
148
+ """RPC 通信入口。
149
+
150
+ 首次调用懒建立 hdc fport + socket 通道;
151
+ READY 状态复用长连接;BROKEN 状态重建。
152
+
153
+ 连接阶段失败按指数退避重试;通信阶段失败直接抛异常,
154
+ 避免对有副作用操作(点击、输入等)重复执行。
155
+ """
156
+ if self._state == STATE_READY:
157
+ try:
158
+ return self._send_and_recv(msg)
159
+ except (BrokenPipeError, ConnectionResetError,
160
+ socket.timeout, OSError, DeviceConnectError) as e:
161
+ logger.warn("RPC 通道异常,转入 BROKEN: %s", e)
162
+ self._set_broken()
163
+ raise DeviceConnectError("RPC 通道断开: %s" % e) from e
164
+
165
+ self._connect_with_retry()
166
+ return self._send_and_recv(msg)
167
+
168
+ def wait_push(self, timeout: float = 3.0) -> Optional[str]:
169
+ """等待并接收设备端异步推送帧(如事件回调)。
170
+
171
+ 用 select 检测 socket 可读,可读时读取完整帧;
172
+ 不可读(超时)返回 None,不消费任何字节,避免协议错位。
173
+
174
+ 与 rpc_call 共享同一 socket,不可并发调用(单线程顺序使用)。
175
+
176
+ Args:
177
+ timeout: 等待超时(秒)
178
+
179
+ Returns:
180
+ 推送帧 body JSON 字符串;超时无数据返回 None
181
+ """
182
+ if self._state != STATE_READY:
183
+ self._connect_with_retry()
184
+ if self._socket is None:
185
+ raise DeviceConnectError("socket 未建立")
186
+ rlist, _, _ = select.select([self._socket], [], [], timeout)
187
+ if not rlist:
188
+ return None
189
+ old_timeout = self._socket.gettimeout()
190
+ self._socket.settimeout(5.0)
191
+ try:
192
+ return self._recv_frame()
193
+ except (socket.timeout, OSError, DeviceConnectError) as e:
194
+ logger.warn("wait_push 接收异常,转入 BROKEN: %s", e)
195
+ self._set_broken()
196
+ return None
197
+ finally:
198
+ self._socket.settimeout(old_timeout)
199
+
200
+ def _connect_with_retry(self) -> None:
201
+ """建立连接,失败时指数退避重试。"""
202
+ last_error: Optional[Exception] = None
203
+ for attempt in range(MAX_CONNECT_RETRIES):
204
+ try:
205
+ self._connect()
206
+ return
207
+ except DeviceConnectError as e:
208
+ last_error = e
209
+ if attempt < MAX_CONNECT_RETRIES - 1:
210
+ wait = CONNECT_RETRY_BASE_INTERVAL * (2 ** attempt)
211
+ logger.info("连接失败,%.1fs 后重试 (%d/%d)",
212
+ wait, attempt + 1, MAX_CONNECT_RETRIES)
213
+ time.sleep(wait)
214
+ if last_error is None:
215
+ raise DeviceConnectError("连接重试未产生异常(不应发生)")
216
+ raise last_error
217
+
218
+ def _connect(self) -> None:
219
+ """建立 uitest 守护进程 + hdc fport + socket 连接,并发送探活。"""
220
+ self._state = STATE_CONNECTING
221
+ try:
222
+ self._agent.ensure_daemon_running()
223
+ self._setup_fport()
224
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
225
+ sock.settimeout(SOCKET_TIMEOUT)
226
+ sock.connect(("127.0.0.1", RPC_PORT))
227
+ self._socket = sock
228
+ self._probe_agent()
229
+ self._state = STATE_READY
230
+ logger.info("RPC 通道已建立 (serial=%s)", self.serial)
231
+ except Exception as e:
232
+ logger.warn("RPC 通道建立失败 (serial=%s): %s", self.serial, e)
233
+ self._close_socket()
234
+ self._cleanup_fport()
235
+ self._state = STATE_CLOSED
236
+ raise DeviceConnectError("无法连接到 Agent: %s" % e) from e
237
+
238
+ def _send_and_recv(self, msg: str) -> str:
239
+ """发送 RPC 请求并接收响应。
240
+
241
+ 使用 UITest 协议帧:HEAD + sessionId(4) + length(4) + body + TAIL。
242
+ """
243
+ if self._socket is None:
244
+ raise DeviceConnectError("socket 未建立")
245
+ frame = self._build_frame(msg)
246
+ self._socket.sendall(frame)
247
+ return self._recv_frame()
248
+
249
+ @staticmethod
250
+ def _build_frame(msg: str) -> bytes:
251
+ """构造 UITest RPC 协议帧。
252
+
253
+ 格式: HEAD + sessionId(4字节大端) + length(4字节大端) + body + TAIL
254
+ """
255
+ body = msg.encode("utf-8")
256
+ session_id = random.randint(0, 0xFFFFFFFF)
257
+ return RPC_HEAD + struct.pack(">II", session_id, len(body)) + body + RPC_TAIL
258
+
259
+ def _recv_frame(self) -> str:
260
+ """接收并解析 UITest RPC 协议帧,返回 body JSON 字符串。
261
+
262
+ 设备端异常时可能返回裸 JSON(无协议帧包装),
263
+ 此时尝试读取完整 JSON 交给上层处理。
264
+ """
265
+ if self._socket is None:
266
+ raise DeviceConnectError("socket 未建立")
267
+ head = self._recv_exact(len(RPC_HEAD))
268
+ if head != RPC_HEAD:
269
+ # HEAD 不匹配,可能是设备端返回了裸 JSON 异常
270
+ if head[:1] == b'{':
271
+ return self._recv_raw_json(head)
272
+ raise DeviceConnectError("协议帧 HEAD 不匹配: %r" % head[:32])
273
+ header = self._recv_exact(8)
274
+ _session_id, length = struct.unpack(">II", header)
275
+ if length > RECV_BUFFER_LIMIT:
276
+ raise DeviceConnectError("协议帧 body 过大: %d 字节" % length)
277
+ body = self._recv_exact(length)
278
+ tail = self._recv_exact(len(RPC_TAIL))
279
+ if tail != RPC_TAIL:
280
+ raise DeviceConnectError("协议帧 TAIL 不匹配")
281
+ return body.decode("utf-8")
282
+
283
+ def _recv_raw_json(self, first_bytes: bytes) -> str:
284
+ """读取裸 JSON 响应(设备端异常时可能不按协议帧格式返回)。
285
+
286
+ 以短超时读取剩余数据,尝试解析为完整 JSON。
287
+ """
288
+ buf = bytearray(first_bytes)
289
+ old_timeout = self._socket.gettimeout()
290
+ self._socket.settimeout(2.0)
291
+ try:
292
+ while True:
293
+ try:
294
+ chunk = self._socket.recv(4096)
295
+ except socket.timeout:
296
+ break
297
+ if not chunk:
298
+ break
299
+ buf.extend(chunk)
300
+ # 尝试解析为完整 JSON
301
+ try:
302
+ text = buf.decode("utf-8")
303
+ json.loads(text)
304
+ return text
305
+ except (json.JSONDecodeError, UnicodeDecodeError):
306
+ continue
307
+ finally:
308
+ self._socket.settimeout(old_timeout)
309
+ raise DeviceConnectError("协议帧 HEAD 不匹配: %r" % bytes(buf[:32]))
310
+
311
+ def _recv_exact(self, n: int) -> bytes:
312
+ """精确读取 n 字节。"""
313
+ if self._socket is None:
314
+ raise DeviceConnectError("socket 未建立")
315
+ buf = bytearray()
316
+ while len(buf) < n:
317
+ chunk = self._socket.recv(min(4096, n - len(buf)))
318
+ if not chunk:
319
+ raise ConnectionResetError("socket 对端关闭")
320
+ buf.extend(chunk)
321
+ if len(buf) > RECV_BUFFER_LIMIT:
322
+ raise DeviceConnectError(
323
+ "回显超过缓冲上限 %d 字节" % RECV_BUFFER_LIMIT)
324
+ return bytes(buf)
325
+
326
+ def _probe_agent(self) -> None:
327
+ """发送探活 RPC 确认通道可用。
328
+
329
+ 使用无副作用的 getDeviceInfo 作为探活消息。
330
+ """
331
+ probe_msg = {
332
+ "module": "com.ohos.devicetest.hypiumApiHelper",
333
+ "method": "callHypiumApi",
334
+ "params": {
335
+ "api": "Driver.getDeviceInfo",
336
+ "this": "Driver#0",
337
+ "args": [],
338
+ "message_type": "hypium"
339
+ },
340
+ "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
341
+ }
342
+ msg = json.dumps(probe_msg, ensure_ascii=False, separators=(',', ':'))
343
+ frame = self._build_frame(msg)
344
+ if self._socket is None:
345
+ raise DeviceConnectError("socket 未建立")
346
+ self._socket.sendall(frame)
347
+ self._recv_frame()
348
+
349
+ # ============================================================
350
+ # 端口转发
351
+ # ============================================================
352
+
353
+ def _setup_fport(self) -> None:
354
+ """建立 hdc 端口转发。
355
+
356
+ v2 协议(6.0+)转发到 localabstract:uitest_socket;
357
+ v1 协议(5.0)转发到 tcp:8012。
358
+ """
359
+ if self._fport_established:
360
+ return
361
+ if self._agent.protocol_version and self._agent.protocol_version >= 2:
362
+ target = "localabstract:%s" % UITEST_SOCKET_NAME
363
+ else:
364
+ target = "tcp:%d" % RPC_PORT
365
+ subprocess.run(
366
+ self._hdc_cmd("fport", "tcp:%d" % RPC_PORT, target),
367
+ check=True
368
+ )
369
+ self._fport_established = True
370
+
371
+ def _cleanup_fport(self) -> None:
372
+ """清理 hdc 端口转发。"""
373
+ if not self._fport_established:
374
+ return
375
+ if self._agent.protocol_version and self._agent.protocol_version >= 2:
376
+ target = "localabstract:%s" % UITEST_SOCKET_NAME
377
+ else:
378
+ target = "tcp:%d" % RPC_PORT
379
+ try:
380
+ subprocess.run(
381
+ self._hdc_cmd("fport", "rm",
382
+ "tcp:%d" % RPC_PORT, target),
383
+ check=False
384
+ )
385
+ except Exception:
386
+ pass
387
+ self._fport_established = False
388
+
389
+ # ============================================================
390
+ # 状态机
391
+ # ============================================================
392
+
393
+ def _set_broken(self) -> None:
394
+ """转入 BROKEN 状态。"""
395
+ self._state = STATE_BROKEN
396
+ self._close_socket()
397
+
398
+ def _close_socket(self) -> None:
399
+ """关闭 socket。"""
400
+ if self._socket is not None:
401
+ try:
402
+ self._socket.close()
403
+ except Exception:
404
+ pass
405
+ self._socket = None
406
+
407
+ # ============================================================
408
+ # 生命周期
409
+ # ============================================================
410
+
411
+ def close(self, stop_daemon: bool = False) -> None:
412
+ """关闭通道,释放 socket 与端口转发。
413
+
414
+ Args:
415
+ stop_daemon: 是否停止设备端 uitest 守护进程。
416
+ 默认 False,守护进程可复用,避免频繁启停开销。
417
+ True 时调用 AgentManager.stop_daemon() 清理设备端进程。
418
+ """
419
+ self._close_socket()
420
+ self._cleanup_fport()
421
+ if stop_daemon:
422
+ self._agent.stop_daemon()
423
+ self._state = STATE_CLOSED
424
+
425
+ def __enter__(self) -> 'HdcDevice':
426
+ return self
427
+
428
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
429
+ self.close()
430
+ return False