From 5a7bc18ddaa4f1b43b7a0e98fb0f30436739ea5b Mon Sep 17 00:00:00 2001 From: JFScorpio <1355373623@qq.com> Date: Tue, 2 Sep 2025 20:39:55 +0800 Subject: [PATCH] Resolve socket packet sticking issues and align element lookup with uiautomator2 --- hmdriver2/_client.py | 548 ++++++++++++++++-------- hmdriver2/_gesture.py | 4 +- hmdriver2/_screenrecord.py | 157 +++++-- hmdriver2/_swipe.py | 6 +- hmdriver2/_uiobject.py | 422 ++++++++++++------ hmdriver2/_xpath.py | 377 +++++++++++++--- hmdriver2/assets/uitest_agent_v1.1.7.so | Bin 0 -> 153782 bytes hmdriver2/driver.py | 296 +++++++++---- hmdriver2/hdc.py | 101 +++-- hmdriver2/proto.py | 50 ++- hmdriver2/utils.py | 2 - test_example.py | 70 +++ 12 files changed, 1465 insertions(+), 568 deletions(-) create mode 100644 hmdriver2/assets/uitest_agent_v1.1.7.so create mode 100644 test_example.py diff --git a/hmdriver2/_client.py b/hmdriver2/_client.py index 74a583e..8cb2385 100644 --- a/hmdriver2/_client.py +++ b/hmdriver2/_client.py @@ -1,231 +1,405 @@ # -*- coding: utf-8 -*- - -import socket +import os import json +import socket +import struct import time -import os -import typing -import subprocess import hashlib from datetime import datetime from functools import cached_property - -from . import logger -from .hdc import HdcWrapper -from .proto import HypiumResponse, DriverData from .exception import InvokeHypiumError, InvokeCaptures +from .hdc import HdcWrapper +from .proto import HypiumResponse + + +class SocketConfig: + PORT = 8012 + TIMEOUT = 5 + BUFFER_SIZE = 8192 + + +class MessageProtocol: + HEADER = b'_uitestkit_rpc_message_head_' # 消息头 + TAILER = b'_uitestkit_rpc_message_tail_' # 消息尾 + SESSION_ID_LENGTH = 4 + LENGTH_FIELD_LENGTH = 4 + HEADER_LENGTH = len(HEADER) + TAILER_LENGTH = len(TAILER) + FULL_HEADER_LENGTH = HEADER_LENGTH + SESSION_ID_LENGTH + LENGTH_FIELD_LENGTH + +LOCAL_HOST = "127.0.0.1" +API_MODULE = "com.ohos.devicetest.hypiumApiHelper" +API_METHOD_HYPIUM = "callHypiumApi" +API_METHOD_CAPTURES = "Captures" +DEFAULT_THIS = "Driver#0" -UITEST_SERVICE_PORT = 8012 -SOCKET_TIMEOUT = 20 + +class MessageCircularBuffer: + """环形缓冲区实现""" + + def __init__(self, initial_capacity: int = 65536, max_capacity: int = 10 * 1024 * 1024): + self.initial_capacity = initial_capacity + self.max_capacity = max_capacity + self.buffer = bytearray(initial_capacity) + self.capacity = initial_capacity + self.head = 0 + self.tail = 0 + self.size = 0 + + def _ensure_capacity(self, required: int) -> None: + if self.size + required <= self.capacity: + return + + new_capacity = self.capacity + while new_capacity < self.size + required: + new_capacity = min(new_capacity * 2, self.max_capacity) + + if new_capacity > self.capacity: + self._expand(new_capacity) + + def _expand(self, new_capacity: int) -> None: + if new_capacity <= self.capacity: + return + + new_buffer = bytearray(new_capacity) + if self.size > 0: + if self.tail < self.head: + new_buffer[0:self.size] = self.buffer[self.tail:self.head] + else: + first_part = self.capacity - self.tail + new_buffer[0:first_part] = self.buffer[self.tail:self.capacity] + new_buffer[first_part:self.size] = self.buffer[0:self.head] + + self.buffer = new_buffer + self.capacity = new_capacity + self.tail = 0 + self.head = self.size + + def write(self, data: bytes) -> int: + data_len = len(data) + if data_len == 0: + return 0 + + self._ensure_capacity(data_len) + write_len = min(data_len, self.capacity - self.size) + + if self.head + write_len <= self.capacity: + self.buffer[self.head:self.head + write_len] = data[:write_len] + self.head = (self.head + write_len) % self.capacity + else: + first_part = self.capacity - self.head + self.buffer[self.head:self.capacity] = data[:first_part] + second_part = write_len - first_part + self.buffer[0:second_part] = data[first_part:first_part + second_part] + self.head = second_part + + self.size += write_len + return write_len + + def read(self, length: int) -> bytearray: + if length <= 0 or self.size == 0: + return bytearray() + + read_len = min(length, self.size) + result = bytearray(read_len) + + if self.tail + read_len <= self.capacity: + result[:] = self.buffer[self.tail:self.tail + read_len] + self.tail = (self.tail + read_len) % self.capacity + else: + first_part = self.capacity - self.tail + result[0:first_part] = self.buffer[self.tail:self.capacity] + second_part = read_len - first_part + result[first_part:read_len] = self.buffer[0:second_part] + self.tail = second_part + + self.size -= read_len + return result + + def peek(self, length: int) -> bytearray: + if length <= 0 or self.size == 0: + return bytearray() + + read_len = min(length, self.size) + result = bytearray(read_len) + + if self.tail + read_len <= self.capacity: + result[:] = self.buffer[self.tail:self.tail + read_len] + else: + first_part = self.capacity - self.tail + result[0:first_part] = self.buffer[self.tail:self.capacity] + second_part = read_len - first_part + result[first_part:read_len] = self.buffer[0:second_part] + + return result + + def discard(self, length: int) -> int: + if length <= 0 or self.size == 0: + return 0 + + discard_len = min(length, self.size) + self.tail = (self.tail + discard_len) % self.capacity + self.size -= discard_len + return discard_len + + def find(self, pattern: bytes, start: int = 0) -> int: + if not pattern or self.size == 0 or start >= self.size: + return -1 + + pattern_len = len(pattern) + if pattern_len == 0: + return start + + for i in range(start, self.size - pattern_len + 1): + match = True + for j in range(pattern_len): + pos = (self.tail + i + j) % self.capacity + if self.buffer[pos] != pattern[j]: + match = False + break + if match: + return i + + return -1 + + def clear(self) -> None: + self.buffer = bytearray(self.initial_capacity) + self.capacity = self.initial_capacity + self.head = 0 + self.tail = 0 + self.size = 0 class HmClient: - """harmony uitest client""" + """Harmony OS 设备通信客户端""" + def __init__(self, serial: str): self.hdc = HdcWrapper(serial) - self.sock = None + self.sock: socket.socket | None = None + self.recv_buffer = MessageCircularBuffer() @cached_property - def local_port(self): - fports = self.hdc.list_fport() - logger.debug(fports) if fports else None - - return self.hdc.forward_port(UITEST_SERVICE_PORT) + def local_port(self) -> int: + return self.hdc.forward_port(SocketConfig.PORT) - def _rm_local_port(self): - logger.debug("rm fport local port") - self.hdc.rm_forward(self.local_port, UITEST_SERVICE_PORT) + def _rm_local_port(self) -> None: + self.hdc.rm_forward(self.local_port, SocketConfig.PORT) - def _connect_sock(self): - """Create socket and connect to the uiTEST server.""" + def _connect_sock(self) -> None: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(SOCKET_TIMEOUT) - self.sock.connect((("127.0.0.1", self.local_port))) - - def _send_msg(self, msg: typing.Dict): - """Send an message to the server. - Example: - { - "module": "com.ohos.devicetest.hypiumApiHelper", - "method": "callHypiumApi", - "params": { - "api": "Driver.create", - "this": null, - "args": [], - "message_type": "hypium" - }, - "request_id": "20240815161352267072", - "client": "127.0.0.1" - } - """ - msg = json.dumps(msg, ensure_ascii=False, separators=(',', ':')) - logger.debug(f"sendMsg: {msg}") - self.sock.sendall(msg.encode('utf-8') + b'\n') - - def _recv_msg(self, buff_size: int = 4096, decode=False, print=True) -> typing.Union[bytearray, str]: - full_msg = bytearray() - try: - # FIXME - relay = self.sock.recv(buff_size) - if decode: - relay = relay.decode() - if print: - logger.debug(f"recvMsg: {relay}") - full_msg = relay - - except (socket.timeout, UnicodeDecodeError) as e: - logger.warning(e) - if decode: - full_msg = "" - - return full_msg - - def invoke(self, api: str, this: str = "Driver#0", args: typing.List = []) -> HypiumResponse: - """ - Hypium invokes given API method with the specified arguments and handles exceptions. - - Args: - api (str): The name of the API method to invoke. - args (List, optional): A list of arguments to pass to the API method. Default is an empty list. - - Returns: - HypiumResponse: The response from the API call. - - Raises: - InvokeHypiumError: If the API call returns an exception in the response. - """ - - request_id = datetime.now().strftime("%Y%m%d%H%M%S%f") - params = { - "api": api, - "this": this, - "args": args, - "message_type": "hypium" - } + self.sock.settimeout(SocketConfig.TIMEOUT) + self.sock.connect((LOCAL_HOST, self.local_port)) + self.recv_buffer.clear() - msg = { - "module": "com.ohos.devicetest.hypiumApiHelper", - "method": "callHypiumApi", - "params": params, - "request_id": request_id - } + def _send_msg(self, msg: dict) -> None: + msg_str = json.dumps(msg, ensure_ascii=False, separators=(',', ':')) + msg_bytes = msg_str.encode('utf-8') - self._send_msg(msg) - raw_data = self._recv_msg(decode=True) - data = HypiumResponse(**(json.loads(raw_data))) - if data.exception: - raise InvokeHypiumError(data.exception) - return data + session_id = self._generate_session_id(msg_str) + header = ( + MessageProtocol.HEADER + + struct.pack('>I', session_id) + + struct.pack('>I', len(msg_bytes)) + ) - def invoke_captures(self, api: str, args: typing.List = []) -> HypiumResponse: - request_id = datetime.now().strftime("%Y%m%d%H%M%S%f") - params = { - "api": api, - "args": args - } + if self.sock is None: + raise ConnectionError("Socket 未连接") + + self.sock.sendall(header + msg_bytes + MessageProtocol.TAILER) - msg = { - "module": "com.ohos.devicetest.hypiumApiHelper", - "method": "Captures", + @staticmethod + def _generate_session_id(message: str) -> int: + combined = str(int(time.time() * 1000)) + message + os.urandom(4).hex() + return struct.unpack('>I', hashlib.sha256(combined.encode()).digest()[:4])[0] | 0x80000000 + + def _recv_msg(self, decode: bool = False) -> bytearray | str: + try: + while True: + result = self._try_parse_message() + if result is not None: + return result.decode('utf-8') if decode else result + + if self.sock is None: + raise ConnectionError("Socket 未连接") + + chunk = self.sock.recv(SocketConfig.BUFFER_SIZE) + if not chunk: + raise ConnectionError("连接已关闭") + + self.recv_buffer.write(chunk) + + except (socket.timeout, ValueError, json.JSONDecodeError) as e: + print(f"接收消息时出错: {e}") + return bytearray() if not decode else "" + + def _try_parse_message(self) -> bytearray | None: + """尝试从缓冲区中解析一个完整消息""" + MAX_MESSAGE_SIZE = 5 * 1024 * 1024 + + while True: + header_pos = self.recv_buffer.find(MessageProtocol.HEADER) + if header_pos == -1: + # 没有找到消息头,保留数据等待更多数据 + # 但需要防止缓冲区被填满,如果数据量过大可能不是合法数据 + if self.recv_buffer.size > MessageProtocol.FULL_HEADER_LENGTH * 3: + keep_size = MessageProtocol.FULL_HEADER_LENGTH * 2 + # 丢弃部分数据,避免缓冲区溢出 + discard_len = self.recv_buffer.size - keep_size + if discard_len > 0: + self.recv_buffer.discard(discard_len) + return None + # 丢弃头部之前的数据 + if header_pos > 0: + self.recv_buffer.discard(header_pos) + # 检查是否有完整的消息头 + if self.recv_buffer.size < MessageProtocol.FULL_HEADER_LENGTH: + return None + # 提取消息头 + header_data = self.recv_buffer.peek(MessageProtocol.FULL_HEADER_LENGTH) + # 验证消息头格式 + if header_data[:MessageProtocol.HEADER_LENGTH] != MessageProtocol.HEADER: + # 无效的消息头,丢弃一个字节后重试 + self.recv_buffer.discard(1) + continue + + length_pos = MessageProtocol.HEADER_LENGTH + MessageProtocol.SESSION_ID_LENGTH + msg_length = struct.unpack('>I', header_data[length_pos:length_pos + 4])[0] + + if msg_length > MAX_MESSAGE_SIZE or msg_length == 0: + print(f"无效的消息长度: {msg_length},丢弃数据") + self.recv_buffer.discard(MessageProtocol.FULL_HEADER_LENGTH) + continue + # 计算完整消息长度 + full_msg_length = MessageProtocol.FULL_HEADER_LENGTH + msg_length + MessageProtocol.TAILER_LENGTH + # 检查是否有完整消息 + if self.recv_buffer.size < full_msg_length: + return None + # 提取完整消息 + full_msg = self.recv_buffer.read(full_msg_length) + # 验证消息尾 + tail_start = MessageProtocol.FULL_HEADER_LENGTH + msg_length + tailer = full_msg[tail_start:tail_start + MessageProtocol.TAILER_LENGTH] + + if tailer != MessageProtocol.TAILER: + print("消息尾不匹配,可能数据损坏") + next_header_pos = self.recv_buffer.find(MessageProtocol.HEADER) + if next_header_pos != -1: + self.recv_buffer.discard(next_header_pos) + continue + + return full_msg[MessageProtocol.FULL_HEADER_LENGTH:tail_start] + + @staticmethod + def _build_request(method: str, api: str, args: list, this=None, message_type=None) -> dict: + params = {"api": api, "args": args} + if this is not None: + params["this"] = this + + if message_type is not None: + params["message_type"] = message_type + + return { + "module": API_MODULE, + "method": method, "params": params, - "request_id": request_id + "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f") } + def _invoke_common(self, method: str, api: str, args: list | None, this: str | None, message_type, + exception_class) -> HypiumResponse: + if args is None: + args = [] + + msg = self._build_request(method, api, args, this, message_type) self._send_msg(msg) + raw_data = self._recv_msg(decode=True) - data = HypiumResponse(**(json.loads(raw_data))) + if not raw_data: + raise exception_class("接收响应失败") + + try: + data = HypiumResponse(**(json.loads(raw_data))) + except json.JSONDecodeError as e: + raise exception_class(f"解析响应失败: {e}") + if data.exception: - raise InvokeCaptures(data.exception) + raise exception_class(data.exception) return data - def start(self): - logger.info("Start HmClient connection") - self._init_so_resource() - self._restart_uitest_service() + def invoke(self, api: str, this: str | None = DEFAULT_THIS, args: list | None = None) -> HypiumResponse: + return self._invoke_common(API_METHOD_HYPIUM, api, args, this, "hypium", InvokeHypiumError) - self._connect_sock() + def invoke_captures(self, api: str, args: list | None = None) -> HypiumResponse: + return self._invoke_common(API_METHOD_CAPTURES, api, args, None, None, InvokeCaptures) - self._create_hdriver() + def start(self) -> None: + _UITestService(self.hdc).init() + self._connect_sock() - def release(self): - logger.info(f"Release {self.__class__.__name__} connection") + def release(self) -> None: try: if self.sock: self.sock.close() self.sock = None - self._rm_local_port() - + self.recv_buffer.clear() except Exception as e: - logger.error(f"An error occurred: {e}") - - def _create_hdriver(self) -> DriverData: - logger.debug("create uitest driver") - resp: HypiumResponse = self.invoke("Driver.create") # {"result":"Driver#0"} - hdriver: DriverData = DriverData(resp.result) - return hdriver - - def _init_so_resource(self): - "Initialize the agent.so resource on the device." - - logger.debug("init the agent.so resource on the device.") - - def __get_so_local_path() -> str: - current_path = os.path.realpath(__file__) - return os.path.join(os.path.dirname(current_path), "assets", "uitest_agent_v1.1.0.so") - - def __check_device_so_file_exists() -> bool: - """Check if the agent.so file exists on the device.""" - command = "[ -f /data/local/tmp/agent.so ] && echo 'so exists' || echo 'so not exists'" - result = self.hdc.shell(command).output.strip() - return "so exists" in result - - def __get_remote_md5sum() -> str: - """Get the MD5 checksum of the file on the device.""" - command = "md5sum /data/local/tmp/agent.so" - data = self.hdc.shell(command).output.strip() - return data.split()[0] - - def __get_local_md5sum(f: str) -> str: - """Calculate the MD5 checksum of a local file.""" - hash_md5 = hashlib.md5() - with open(f, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - hash_md5.update(chunk) - return hash_md5.hexdigest() - - local_path = __get_so_local_path() - remote_path = "/data/local/tmp/agent.so" - - if __check_device_so_file_exists() and __get_local_md5sum(local_path) == __get_remote_md5sum(): - return + print(f"释放资源时出错: {e}") + + +class _UITestService: + """UITest 服务管理类""" + + def __init__(self, hdc: HdcWrapper): + self.hdc = hdc + self._remote_agent_path = "/data/local/tmp/agent.so" + + def init(self) -> None: + local_path = self._get_local_agent_path() + self._kill_uitest_service() + self._setup_device_agent(local_path, self._remote_agent_path) + self._start_uitest_daemon() + time.sleep(0.5) + + def _get_local_agent_path(self) -> str: + target_agent = "uitest_agent_v1.1.7.so" + return os.path.join(os.path.dirname(os.path.realpath(__file__)), "assets", target_agent) + + def _get_remote_md5sum(self, file_path: str) -> str | None: + output = self.hdc.shell(f"md5sum {file_path}").output.strip() + return output.split()[0] if output else None + + def _get_local_md5sum(self, file_path: str) -> str: + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + def _is_remote_file_exists(self, file_path: str) -> bool: + result = self.hdc.shell(f"[ -f {file_path} ] && echo 'exists' || echo 'not exists'").output.strip() + return "exists" in result + + def _setup_device_agent(self, local_path: str, remote_path: str) -> None: + if self._is_remote_file_exists(remote_path): + local_md5 = self._get_local_md5sum(local_path) + remote_md5 = self._get_remote_md5sum(remote_path) + if local_md5 == remote_md5: + self.hdc.shell(f"chmod +x {remote_path}") + return + self.hdc.shell(f"rm {remote_path}") + self.hdc.send_file(local_path, remote_path) self.hdc.shell(f"chmod +x {remote_path}") - def _restart_uitest_service(self): - """ - Restart the UITest daemon. + def _get_uitest_pid(self) -> list: + result = self.hdc.shell("pgrep -f 'uitest start-daemon singleness'").output.strip() + return result.splitlines() if result else [] - Note: 'hdc shell aa test' will also start a uitest daemon. - $ hdc shell ps -ef |grep uitest - shell 44306 1 25 11:03:37 ? 00:00:16 uitest start-daemon singleness - shell 44416 1 2 11:03:42 ? 00:00:01 uitest start-daemon com.hmtest.uitest@4x9@1" - """ - try: - result = self.hdc.shell("ps -ef").output.strip() - lines = result.splitlines() - filtered_lines = [line for line in lines if 'uitest' in line and 'singleness' in line] - - for line in filtered_lines: - if 'uitest start-daemon singleness' in line: - parts = line.split() - pid = parts[1] - self.hdc.shell(f"kill -9 {pid}") - logger.debug(f"Killed uitest process with PID {pid}") - - self.hdc.shell("uitest start-daemon singleness") - time.sleep(.5) - - except subprocess.CalledProcessError as e: - logger.error(f"An error occurred: {e}") + def _kill_uitest_service(self) -> None: + for pid in self._get_uitest_pid(): + self.hdc.shell(f"kill -9 {pid}") + + def _start_uitest_daemon(self) -> None: + self.hdc.shell("uitest start-daemon singleness") diff --git a/hmdriver2/_gesture.py b/hmdriver2/_gesture.py index d81d6b5..ddd2c51 100644 --- a/hmdriver2/_gesture.py +++ b/hmdriver2/_gesture.py @@ -2,7 +2,6 @@ import math from typing import List, Union -from . import logger from .utils import delay from .driver import Driver from .proto import HypiumResponse, Point @@ -95,7 +94,6 @@ def action(self): """ Execute the gesture action. """ - logger.info(f">>>Gesture steps: {self.steps}") total_points = self._calculate_total_points() pointer_matrix = self._create_pointer_matrix(total_points) @@ -140,7 +138,7 @@ def _add_step(self, x: int, y: int, step_type: str, interval: float): step_type (str): Type of step ("start", "move", or "pause"). interval (float): Interval duration in seconds. """ - point: Point = self.d._to_abs_pos(x, y) + point: Point = self.d.to_abs_pos(x, y) step = GestureStep(point.to_tuple(), step_type, interval) self.steps.append(step) diff --git a/hmdriver2/_screenrecord.py b/hmdriver2/_screenrecord.py index 4cf56ab..b8f2170 100644 --- a/hmdriver2/_screenrecord.py +++ b/hmdriver2/_screenrecord.py @@ -1,36 +1,70 @@ # -*- coding: utf-8 -*- -import typing +import queue import threading -import numpy as np -from queue import Queue from datetime import datetime +from typing import List, Optional, Any import cv2 +import numpy as np from . import logger from ._client import HmClient from .driver import Driver from .exception import ScreenRecordError +# 常量定义 +JPEG_START_FLAG = b'\xff\xd8' # JPEG 图像开始标记 +JPEG_END_FLAG = b'\xff\xd9' # JPEG 图像结束标记 +VIDEO_FPS = 10 # 视频帧率 +VIDEO_CODEC = 'mp4v' # 视频编码格式 +QUEUE_TIMEOUT = 0.1 # 队列超时时间(秒) + class RecordClient(HmClient): + """ + 屏幕录制客户端 + + 继承自 HmClient,提供设备屏幕录制功能 + """ + def __init__(self, serial: str, d: Driver): + """ + 初始化屏幕录制客户端 + + Args: + serial: 设备序列号 + d: Driver 实例 + """ super().__init__(serial) self.d = d - self.video_path = None - self.jpeg_queue = Queue() - self.threads: typing.List[threading.Thread] = [] - self.stop_event = threading.Event() + self.video_path: Optional[str] = None + self.jpeg_queue: queue.Queue = queue.Queue() + self.threads: List[threading.Thread] = [] + self.stop_event: threading.Event = threading.Event() def __enter__(self): + """上下文管理器入口""" return self def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器退出时停止录制""" self.stop() - def _send_msg(self, api: str, args: list): + def _send_msg(self, api: str, args: Optional[List[Any]] = None): + """ + 发送消息到设备 + + 重写父类方法,使用 Captures API + + Args: + api: API 名称 + args: API 参数列表,默认为空列表 + """ + if args is None: + args = [] + _msg = { "module": "com.ohos.devicetest.hypiumApiHelper", "method": "Captures", @@ -43,16 +77,32 @@ def _send_msg(self, api: str, args: list): super()._send_msg(_msg) def start(self, video_path: str): - logger.info("Start RecordClient connection") - + """ + 开始屏幕录制 + + Args: + video_path: 视频保存路径 + + Returns: + RecordClient: 当前实例,支持链式调用 + + Raises: + ScreenRecordError: 启动屏幕录制失败时抛出 + """ + logger.info("开始屏幕录制") + + # 连接设备 self._connect_sock() self.video_path = video_path + # 发送开始录制命令 self._send_msg("startCaptureScreen", []) - reply: str = self._recv_msg(1024, decode=True, print=False) + # 检查响应 + reply: str = self._recv_msg(decode=True, print=False) if "true" in reply: + # 创建并启动工作线程 record_th = threading.Thread(target=self._record_worker) writer_th = threading.Thread(target=self._video_writer) record_th.daemon = True @@ -61,69 +111,100 @@ def start(self, video_path: str): writer_th.start() self.threads.extend([record_th, writer_th]) else: - raise ScreenRecordError("Failed to start device screen capture.") + raise ScreenRecordError("启动设备屏幕录制失败") return self def _record_worker(self): - """Capture screen frames and save current frames.""" - - # JPEG start and end markers. - start_flag = b'\xff\xd8' - end_flag = b'\xff\xd9' + """ + 屏幕帧捕获工作线程 + + 捕获屏幕帧并保存当前帧 + """ buffer = bytearray() while not self.stop_event.is_set(): try: - buffer += self._recv_msg(4096 * 1024, decode=False, print=False) + buffer += self._recv_msg(decode=False, print=False) except Exception as e: - print(f"Error receiving data: {e}") + logger.error(f"接收数据时出错: {e}") break - start_idx = buffer.find(start_flag) - end_idx = buffer.find(end_flag) + # 查找 JPEG 图像的开始和结束标记 + start_idx = buffer.find(JPEG_START_FLAG) + end_idx = buffer.find(JPEG_END_FLAG) + + # 处理所有完整的 JPEG 图像 while start_idx != -1 and end_idx != -1 and end_idx > start_idx: - # Extract one JPEG image + # 提取一个 JPEG 图像 jpeg_image: bytearray = buffer[start_idx:end_idx + 2] self.jpeg_queue.put(jpeg_image) + # 从缓冲区中移除已处理的数据 buffer = buffer[end_idx + 2:] - # Search for the next JPEG image in the buffer - start_idx = buffer.find(start_flag) - end_idx = buffer.find(end_flag) + # 在缓冲区中查找下一个 JPEG 图像 + start_idx = buffer.find(JPEG_START_FLAG) + end_idx = buffer.find(JPEG_END_FLAG) def _video_writer(self): - """Write frames to video file.""" + """ + 视频写入工作线程 + + 将帧写入视频文件 + """ cv2_instance = None + img = None while not self.stop_event.is_set(): - if not self.jpeg_queue.empty(): - jpeg_image = self.jpeg_queue.get(timeout=0.1) + try: + # 从队列获取 JPEG 图像 + jpeg_image = self.jpeg_queue.get(timeout=QUEUE_TIMEOUT) img = cv2.imdecode(np.frombuffer(jpeg_image, np.uint8), cv2.IMREAD_COLOR) - if cv2_instance is None: - height, width = img.shape[:2] - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - cv2_instance = cv2.VideoWriter(self.video_path, fourcc, 10, (width, height)) - - cv2_instance.write(img) - + except queue.Empty: + pass + + # 跳过无效图像 + if img is None or img.size == 0: + continue + + # 首次获取有效图像时初始化视频写入器 + if cv2_instance is None: + height, width = img.shape[:2] + fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC) + cv2_instance = cv2.VideoWriter(self.video_path, fourcc, VIDEO_FPS, (width, height)) + + # 写入帧 + cv2_instance.write(img) + + # 释放资源 if cv2_instance: cv2_instance.release() def stop(self) -> str: + """ + 停止屏幕录制 + + Returns: + str: 视频保存路径 + """ try: + # 设置停止事件,通知工作线程退出 self.stop_event.set() + + # 等待所有工作线程结束 for t in self.threads: t.join() + # 发送停止录制命令 self._send_msg("stopCaptureScreen", []) - self._recv_msg(1024, decode=True, print=False) + self._recv_msg(decode=True, print=False) + # 释放资源 self.release() - # Invalidate the cached property + # 使缓存的属性失效 self.d._invalidate_cache('screenrecord') except Exception as e: - logger.error(f"An error occurred: {e}") + logger.error(f"停止屏幕录制时出错: {e}") return self.video_path diff --git a/hmdriver2/_swipe.py b/hmdriver2/_swipe.py index aab92a0..a080734 100644 --- a/hmdriver2/_swipe.py +++ b/hmdriver2/_swipe.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- - from typing import Union, Tuple - from .driver import Driver from .proto import SwipeDirection @@ -77,8 +75,8 @@ def _validate_and_convert_box(self, box: Tuple) -> Tuple[int, int, int, int]: raise ValueError("Box coordinates must satisfy x1 < x2 and y1 < y2.") from .driver import Point - p1: Point = self._d._to_abs_pos(x1, y1) - p2: Point = self._d._to_abs_pos(x2, y2) + p1: Point = self._d.to_abs_pos(x1, y1) + p2: Point = self._d.to_abs_pos(x2, y2) x1, y1, x2, y2 = p1.x, p1.y, p2.x, p2.y return x1, y1, x2, y2 diff --git a/hmdriver2/_uiobject.py b/hmdriver2/_uiobject.py index 31f58d0..41f912a 100644 --- a/hmdriver2/_uiobject.py +++ b/hmdriver2/_uiobject.py @@ -1,11 +1,7 @@ # -*- coding: utf-8 -*- - import enum import time -from typing import List, Union - -from . import logger -from .utils import delay +from typing import List, Union, Optional, Any from ._client import HmClient from .exception import ElementNotFoundError from .proto import ComponentData, ByData, HypiumResponse, Point, Bounds, ElementInfo @@ -25,168 +21,109 @@ class ByType(enum.Enum): selected = "selected" checked = "checked" checkable = "checkable" - isBefore = "isBefore" - isAfter = "isAfter" + isBefore = "isBefore" # 表示找前一个元素 + isAfter = "isAfter" # 表示找后一个元素 @classmethod def verify(cls, value): return any(value == item.value for item in cls) -class UiObject: - DEFAULT_TIMEOUT = 2 +class UiElement: + """表示单个UI元素,封装所有元素操作""" - def __init__(self, client: HmClient, **kwargs) -> None: + def __init__(self, client: HmClient, component: ComponentData) -> None: self._client = client - self._raw_kwargs = kwargs - - self._index = kwargs.pop("index", 0) - self._isBefore = kwargs.pop("isBefore", False) - self._isAfter = kwargs.pop("isAfter", False) - - self._kwargs = kwargs - self.__verify() - - self._component: Union[ComponentData, None] = None # cache - - def __str__(self) -> str: - return f"UiObject [{self._raw_kwargs}" - - def __verify(self): - for k, v in self._kwargs.items(): - if not ByType.verify(k): - raise ReferenceError(f"{k} is not allowed.") - - @property - def count(self) -> int: - eleements = self.__find_components() - return len(eleements) if eleements else 0 - - def __len__(self): - return self.count - - def exists(self, retries: int = 2, wait_time=1) -> bool: - obj = self.find_component(retries, wait_time) - return True if obj else False - - def __set_component(self, component: ComponentData): self._component = component - - def find_component(self, retries: int = 1, wait_time=1) -> ComponentData: - for attempt in range(retries): - components = self.__find_components() - if components and self._index < len(components): - self.__set_component(components[self._index]) - return self._component - - if attempt < retries: - time.sleep(wait_time) - logger.info(f"Retry found element {self}") - - return None - - # useless - def __find_component(self) -> Union[ComponentData, None]: - by: ByData = self.__get_by() - resp: HypiumResponse = self._client.invoke("Driver.findComponent", args=[by.value]) - if not resp.result: - return None - return ComponentData(resp.result) - - def __find_components(self) -> Union[List[ComponentData], None]: - by: ByData = self.__get_by() - resp: HypiumResponse = self._client.invoke("Driver.findComponents", args=[by.value]) - if not resp.result: - return None - components: List[ComponentData] = [] - for item in resp.result: - components.append(ComponentData(item)) - - return components - - def __get_by(self) -> ByData: - for k, v in self._kwargs.items(): - api = f"On.{k}" - this = "On#seed" - resp: HypiumResponse = self._client.invoke(api, this, args=[v]) - this = resp.result - - if self._isBefore: - resp: HypiumResponse = self._client.invoke("On.isBefore", this="On#seed", args=[resp.result]) - - if self._isAfter: - resp: HypiumResponse = self._client.invoke("On.isAfter", this="On#seed", args=[resp.result]) - - return ByData(resp.result) - - def __operate(self, api, args=[], retries: int = 2): - if not self._component: - if not self.find_component(retries): - raise ElementNotFoundError(f"Element({self}) not found after {retries} retries") - + self._last_check_time = 0 # 记录最后检查时间 + self._cached_state = None # 缓存元素状态 + self._state_cache = {} + self._cache_expiry = 0 + + def __operate(self, api, args=None): + if args is None: + args = [] resp: HypiumResponse = self._client.invoke(api, this=self._component.value, args=args) return resp.result + def _get_cached_property(self, prop_name: str) -> Any: + """带缓存的属性获取""" + current_time = time.perf_counter() + if current_time - self._cache_expiry < UiObject.CACHE_TTL: + return self._state_cache.get(prop_name) + + # 刷新整个状态缓存 + self._state_cache = { + key: self.__operate(f"Component.{key}") + for key in ( + "isSelected", "isChecked", "isEnabled", + "isFocused", "isClickable", "isLongClickable" + ) + } + self._cache_expiry = current_time + return self._state_cache.get(prop_name) + @property def id(self) -> str: - return self.__operate("Component.getId") + return self._get_cached_property("getId") @property def key(self) -> str: - return self.__operate("Component.getId") + return self._get_cached_property("getId") @property def type(self) -> str: - return self.__operate("Component.getType") + return self._get_cached_property("getType") @property def text(self) -> str: - return self.__operate("Component.getText") + return self._get_cached_property("getText") @property def description(self) -> str: - return self.__operate("Component.getDescription") + return self._get_cached_property("getDescription") @property def isSelected(self) -> bool: - return self.__operate("Component.isSelected") + + return self._get_cached_property("isSelected") @property def isChecked(self) -> bool: - return self.__operate("Component.isChecked") + return self._get_cached_property("isChecked") @property def isEnabled(self) -> bool: - return self.__operate("Component.isEnabled") + return self._get_cached_property("isEnabled") @property def isFocused(self) -> bool: - return self.__operate("Component.isFocused") + return self._get_cached_property("isFocused") @property def isCheckable(self) -> bool: - return self.__operate("Component.isCheckable") + return self._get_cached_property("isCheckable") @property def isClickable(self) -> bool: - return self.__operate("Component.isClickable") + return self._get_cached_property("isClickable") @property def isLongClickable(self) -> bool: - return self.__operate("Component.isLongClickable") + return self._get_cached_property("isLongClickable") @property def isScrollable(self) -> bool: - return self.__operate("Component.isScrollable") + return self._get_cached_property("isScrollable") @property def bounds(self) -> Bounds: - _raw = self.__operate("Component.getBounds") + _raw = self._get_cached_property("getBounds") return Bounds(**_raw) @property def boundsCenter(self) -> Point: - _raw = self.__operate("Component.getBoundsCenter") + _raw = self._get_cached_property("getBoundsCenter") return Point(**_raw) @property @@ -208,41 +145,272 @@ def info(self) -> ElementInfo: bounds=self.bounds, boundsCenter=self.boundsCenter) - @delay def click(self): + print(111) return self.__operate("Component.click") - @delay - def click_if_exists(self): - try: - return self.__operate("Component.click") - except ElementNotFoundError: - pass - - @delay def double_click(self): return self.__operate("Component.doubleClick") - @delay def long_click(self): return self.__operate("Component.longClick") - @delay - def drag_to(self, component: ComponentData): - return self.__operate("Component.dragTo", [component.value]) + def drag_to(self, element: 'UiElement'): + return self.__operate("Component.dragTo", [element._component.value]) - @delay def input_text(self, text: str): return self.__operate("Component.inputText", [text]) - @delay def clear_text(self): return self.__operate("Component.clearText") - @delay def pinch_in(self, scale: float = 0.5): return self.__operate("Component.pinchIn", [scale]) - @delay def pinch_out(self, scale: float = 2): return self.__operate("Component.pinchOut", [scale]) + + +class UiElementList: + """管理多个UI元素的集合类""" + + def __init__(self, elements: List[UiElement]) -> None: + self.elements = elements + + def __len__(self): + return len(self.elements) + + def __getitem__(self, index): + return self.elements[index] + + def __iter__(self): + return iter(self.elements) + + @property + def first(self) -> Optional[UiElement]: + return self.elements[0] if self.elements else None + + @property + def last(self) -> Optional[UiElement]: + return self.elements[-1] if self.elements else None + + def click_all(self): + for element in self.elements: + element.click() + + def get_by_index(self, index: int) -> Optional[UiElement]: + if 0 <= index < len(self.elements): + return self.elements[index] + return None + + +class UiObject: + DEFAULT_TIMEOUT = 2 + POLL_INTERVAL = 0.001 # 10ms轮询间隔 + CACHE_TTL = 0.05 # 缓存有效期200ms + + def __init__(self, client: HmClient, **kwargs) -> None: + self._client = client + self._raw_kwargs = kwargs + self._isBefore = kwargs.pop("isBefore", False) + self._isAfter = kwargs.pop("isAfter", False) + self._kwargs = kwargs + self.__verify() + self._cache = None # 查找结果缓存 + self._cache_time = 0 # 缓存时间戳 + self._by_cache = None # ByData缓存 + + def __str__(self) -> str: + return f"UiObject [{self._raw_kwargs}" + + def __verify(self): + for k, v in self._kwargs.items(): + if not ByType.verify(k): + raise ReferenceError(f"{k} is not allowed.") + + def _invalidate_cache(self): + """使缓存失效""" + self._cache = None + self._cache_time = 0 + self._by_cache = None + + @property + def count(self) -> int: + elements = self.find_components() + return len(elements) if elements else 0 + + def __len__(self): + return self.count + + def exists(self, timeout=0) -> bool: + """检查元素是否存在,支持即时检测""" + return len(self.find_components(timeout, use_cache=False)) > 0 + + def wait(self, exists=True, timeout=1) -> bool: + """等待元素出现或消失,延迟控制在10ms以内""" + start_time = time.perf_counter() + last_state = self.exists(0) + + # 初始状态检查 + if exists == last_state: + return exists + + # 优化后的轮询逻辑 + while time.perf_counter() - start_time < timeout: + current_state = self.exists(0) + # 状态变化立即返回 + if current_state != last_state: + if exists == current_state: + return True + last_state = current_state + + time.sleep(self.POLL_INTERVAL) + + # 超时后最终检查 + return exists == self.exists(0) + + def _poll_for_elements(self, timeout) -> list: + """封装轮询逻辑""" + start_time = time.perf_counter() + while True: + if components := self.__find_components(): + return [UiElement(self._client, comp) for comp in components] + + # 超时或无需轮询时退出 + if timeout <= 0 or (time.perf_counter() - start_time) >= timeout: + return [] + + time.sleep(self.POLL_INTERVAL) + + def find_components(self, timeout=DEFAULT_TIMEOUT, use_cache=True) -> UiElementList: + """改进的查找方法,支持禁用缓存""" + current_time = time.perf_counter() + + # 缓存有效且启用缓存时直接返回 + if use_cache and self._cache and current_time - self._cache_time < self.CACHE_TTL: + return UiElementList(self._cache) + + # 直接查找或轮询逻辑 + elements = self._poll_for_elements(timeout) + + # 更新缓存 + self._cache = elements + self._cache_time = time.perf_counter() + return UiElementList(elements) + + def __find_components(self) -> Union[List[ComponentData], None]: + """查找组件并缓存ByData""" + if self._by_cache: + by = self._by_cache + else: + by = self.__get_by() + self._by_cache = by + + resp: HypiumResponse = self._client.invoke("Driver.findComponents", args=[by.value]) + if not resp.result: + return None + return [ComponentData(item) for item in resp.result] + + def __get_by(self) -> ByData: + """链式构建 ByData 对象""" + seed = None + # 动态构建查询链 + for k, v in self._kwargs.items(): + api = f"On.{k}" + this = seed or "On#seed" + resp = self._client.invoke(api, this, [v]) + seed = resp.result + + # 处理位置关系 + position_actions = { + "isBefore": "On.isBefore", + "isAfter": "On.isAfter" + } + for attr, api in position_actions.items(): + if getattr(self, f"_{attr}", False): + resp = self._client.invoke(api, seed, [seed]) + seed = resp.result + + return ByData(seed) + + # def _perform_action(self, action: str, *args, **kwargs): + # """统一操作调度方法""" + # elements = self.find_components() + # if not elements or not elements.first: + # if kwargs.get("ignore_not_found"): + # return + # raise ElementNotFoundError(f"Element({self}) not found") + # + # # 动态调用 UiElement 方法 + # method = getattr(elements.first, action) + # return method(*args) + + def click(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.click() + + def click_first(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.click() + + def info(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + return elements.first.info + + def double_click(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.double_click() + + def long_click(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.long_click() + + def drag_to(self, element: 'UiElement'): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.drag_to(element) + + def input_text(self, text: str): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.input_text(text) + + def clear_text(self): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.clear_text() + + def pinch_in(self, scale: float = 0.5): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.pinch_in(scale) + + def pinch_out(self, scale: float = 2): + """点击找到的第一个元素""" + elements = self.find_components() + if not elements: + raise ElementNotFoundError(f"Element({self}) not found") + elements.first.pinch_out(scale) diff --git a/hmdriver2/_xpath.py b/hmdriver2/_xpath.py index f3ad21e..2666379 100644 --- a/hmdriver2/_xpath.py +++ b/hmdriver2/_xpath.py @@ -1,93 +1,352 @@ # -*- coding: utf-8 -*- - -from typing import Dict +import time +import threading +from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED +from typing import Dict, Any, List, Optional, Union from lxml import etree -from functools import cached_property - -from . import logger -from .proto import Bounds +from .proto import Bounds, ElementInfo, Point from .driver import Driver -from .utils import delay, parse_bounds -from .exception import XmlElementNotFoundError +from .utils import parse_bounds + +# XML相关常量 +XML_ROOT_TAG = "orgRoot" +XML_ATTRIBUTE_TYPE = "type" + +# 布尔属性列表 +BOOL_ATTRIBUTES = ["enabled", "focused", "selected", "checked", "checkable", "clickable", "longClickable", "scrollable"] class _XPath: def __init__(self, d: Driver): self._d = d - def __call__(self, xpath: str) -> '_XMLElement': + def __call__(self, xpath: Union[str, list]) -> '_XPathResult': + hierarchy_dict: dict | None = self._d.dump_hierarchy() + if not hierarchy_dict: + return _XPathResult([], self._d, xpath) + + xml = self._json2xml(hierarchy_dict) + if xml is None: + return _XPathResult([], self._d, xpath) + + if isinstance(xpath, list): + return self._concurrent_xpath_search(xml, xpath) + return self._single_xpath_search(xml, xpath) + + @staticmethod + def _sanitize_text(text: str) -> str: + """快速移除XML不兼容的控制字符""" + return ''.join(ch for ch in text if 31 < ord(ch) < 127) + + def _single_xpath_search(self, xml: etree.Element, xpath: str) -> '_XPathResult': + """处理单个XPath查询""" + try: + results = xml.xpath(xpath) + except etree.XPathError as e: + return _XPathResult([], self._d, xpath) + return _XPathResult([_XMLElement(node, self._d) for node in results], self._d, xpath) + + def _concurrent_xpath_search(self, xml: etree.Element, xpath_list: List[str]) -> '_XPathResult': + """ + 并发查询多个XPath表达式 + - 返回标准的_XPathResult对象 + - 包含第一个找到结果的表达式的元素 + - 在结果对象中保存命中的XPath表达式 + """ + found_event = threading.Event() + result_lock = threading.Lock() + result_elements = [] # 保存找到的元素 + hit_xpath = None # 保存命中的XPath表达式 + + def worker(expr: str): + nonlocal result_elements, hit_xpath + # 检查是否已有结果,避免不必要计算 + if found_event.is_set(): + return + try: + nodes = xml.xpath(expr) + if nodes: + with result_lock: + if not found_event.is_set(): # 双重检查 + # 保存结果和命中的表达式 + result_elements = [_XMLElement(node, self._d) for node in nodes] + hit_xpath = expr + found_event.set() # 通知其他线程终止 + except etree.XPathError: + pass # 忽略单个表达式的语法错误 - hierarchy: Dict = self._d.dump_hierarchy() - if not hierarchy: - raise XmlElementNotFoundError(f"xpath: {xpath} not found") + # 使用线程池提交任务 + with ThreadPoolExecutor(max_workers=min(len(xpath_list), 10)) as executor: + futures = [executor.submit(worker, expr) for expr in xpath_list] - xml = _XPath._json2xml(hierarchy) - result = xml.xpath(xpath) + # 等待首个结果或全部完成 + done, _ = wait(futures, return_when=FIRST_COMPLETED) - if len(result) > 0: - node = result[0] - raw_bounds: str = node.attrib.get("bounds") # [832,1282][1125,1412] - bounds: Bounds = parse_bounds(raw_bounds) - logger.debug(f"{xpath} Bounds: {bounds}") - return _XMLElement(bounds, self._d) + # 如果已有结果则立即取消未启动任务 + if found_event.is_set(): + for future in futures: + if not future.done(): + future.cancel() - return _XMLElement(None, self._d) + # 返回标准化的_XPathResult对象 + return _XPathResult( + elements=result_elements, + d=self._d, + xpath=hit_xpath or " | ".join(xpath_list), # 使用命中的表达式或组合表达式 + matched_xpath=hit_xpath # 新增字段保存命中的具体表达式 + ) @staticmethod - def _json2xml(hierarchy: Dict) -> etree.Element: - attributes = hierarchy.get("attributes", {}) - tag = attributes.get("type", "orgRoot") or "orgRoot" - xml = etree.Element(tag, attrib=attributes) + def _json2xml(hierarchy: Dict[str, Any]) -> etree.Element: + if not isinstance(hierarchy, dict): + return etree.Element(XML_ROOT_TAG) # 空根元素 - children = hierarchy.get("children", []) - for item in children: - xml.append(_XPath._json2xml(item)) - return xml + stack_xml = [(hierarchy, None)] + root = None + while stack_xml: + current_node, parent_node = stack_xml.pop() + if not isinstance(current_node, dict): + print('current_node', current_node) + continue -class _XMLElement: - def __init__(self, bounds: Bounds, d: Driver): - self.bounds = bounds + attributes = current_node.get("attributes", {}) + if not isinstance(attributes, dict): + attributes = {} + + cleaned_attributes = {} + for k, v in attributes.items(): + if k in BOOL_ATTRIBUTES: + cleaned_attributes[k] = v + else: + cleaned_attributes[k] = _XPath._sanitize_text(str(v)) if v is not None else "" + + tag_name = cleaned_attributes.get(XML_ATTRIBUTE_TYPE) or XML_ROOT_TAG + if not isinstance(tag_name, str): + tag_name = XML_ROOT_TAG + + node = etree.Element(tag_name, attrib=cleaned_attributes) + + if parent_node is None: + root = node + else: + parent_node.append(node) + + children = current_node.get("children", []) + if not isinstance(children, list): + children = [] + + valid_children = [] + for child in children: + if child and isinstance(child, dict): + valid_children.append(child) + + for child in reversed(valid_children): + stack_xml.append((child, node)) + + # 最终保障:确保永不返回None + return root if root is not None else etree.Element(XML_ROOT_TAG) + + +class _XPathResult: + __slots__ = ('elements', '_d', '_xpath', '_matched_xpath') + + def __init__(self, elements: List['_XMLElement'], d: Driver, xpath: str, matched_xpath: str = None): + self.elements = elements self._d = d + self._xpath = xpath # 原始查询的XPath(单个或组合) + self._matched_xpath = matched_xpath or xpath # 实际匹配的XPath - def _verify(self): - if not self.bounds: - raise XmlElementNotFoundError("xpath not found") + def find_all(self) -> List['_XMLElement']: + """返回所有匹配的元素""" + return self.elements - @cached_property - def center(self): - self._verify() - return self.bounds.get_center() + @property + def first(self) -> Optional['_XMLElement']: + """返回第一个匹配的元素(如果存在)""" + return self.elements[0] if self.elements else None + + @property + def count(self) -> int: + """返回匹配的元素数量""" + return len(self.elements) def exists(self) -> bool: - return self.bounds is not None + """检查是否存在匹配的元素""" + return len(self.elements) > 0 - @delay - def click(self): - x, y = self.center.x, self.center.y - self._d.click(x, y) + def wait(self, exists=True, timeout=1) -> bool: + start_time = time.perf_counter() + xpath_obj = _XPath(self._d) # 创建新的XPath查询对象 + + while time.perf_counter() - start_time < timeout: + # 每次迭代都重新查询 + current_result = xpath_obj(self._xpath) + current_state = current_result.exists() + + if exists: + if current_state: + # 更新元素列表为当前找到的元素 + self.elements = current_result.elements + return True + else: + if not current_state: + self.elements = [] + return True + # 最终检查 + final_result = xpath_obj(self._xpath) + final_state = final_result.exists() + if exists: + if final_state: + self.elements = final_result.elements + return True + return False + else: + if not final_state: + self.elements = [] + return True + return False + + def click_first(self): + """点击第一个匹配的元素""" + if self.elements: + self.elements[0].click() - @delay - def click_if_exists(self): + def click_all(self): + """点击所有匹配的元素""" + for element in self.elements: + element.click() # 直接点击,元素不存在时会抛出异常 - if not self.exists(): - logger.debug("click_exist: xpath not found") - return + def input_text(self, text: str): + """在第一个匹配的元素输入文本""" + if self.elements: + self.elements[0].input_text(text) - x, y = self.center.x, self.center.y - self._d.click(x, y) + @property + def matched_xpath(self) -> str: + """返回实际匹配的XPath表达式(多查询时有效)""" + return self._matched_xpath + + def __repr__(self) -> str: + return f"" + + def __getitem__(self, index: int) -> '_XMLElement': + """通过索引获取元素""" + return self.elements[index] + + def __iter__(self): + """支持迭代""" + return iter(self.elements) + + def __len__(self) -> int: + """返回元素数量""" + return self.count + + +class _XMLElement: + __slots__ = ('_d', 'attrib', '_bounds', '_center') + + def __init__(self, xpath_node: Any, d: Driver): + self._d = d + self.attrib = xpath_node.attrib + # 边界和中心点 + raw_bounds = self.attrib.get("bounds", "[0,0][0,0]") + self._bounds = parse_bounds(raw_bounds) + self._center = self._bounds.get_center() + + @property + def bounds(self) -> Bounds: + return self._bounds + + @property + def center(self) -> Point: + return self._center + + def click(self): + """点击元素中心点""" + self._d.click(self._center.x, self._center.y) - @delay def double_click(self): - x, y = self.center.x, self.center.y - self._d.double_click(x, y) + """双击元素""" + self._d.double_click(self._center.x, self._center.y) - @delay def long_click(self): - x, y = self.center.x, self.center.y - self._d.long_click(x, y) + """长按元素""" + self._d.long_click(self._center.x, self._center.y) - @delay def input_text(self, text): + """在元素中输入文本""" self.click() - self._d.input_text(text) \ No newline at end of file + self._d.input_text(text) + + @property + def id(self): + return self.attrib.get("id", "") + + @property + def key(self): + return self.attrib.get("key", "") + + @property + def type(self): + return self.attrib.get("type", "") + + @property + def text(self): + return self.attrib.get("text", "") + + @property + def description(self): + return self.attrib.get("description", "") + + @property + def isSelected(self): + return self.attrib.get("selected", "false") == "true" + + @property + def isChecked(self): + return self.attrib.get("checked", "false") == "true" + + @property + def isEnabled(self): + return self.attrib.get("enabled", "false") == "true" + + @property + def isFocused(self): + return self.attrib.get("focused", "false") == "true" + + @property + def isCheckable(self): + return self.attrib.get("checkable", "false") == "true" + + @property + def isClickable(self): + return self.attrib.get("clickable", "false") == "true" + + @property + def isLongClickable(self): + return self.attrib.get("longClickable", "false") == "true" + + @property + def isScrollable(self): + return self.attrib.get("scrollable", "false") == "true" + + @property + def info(self) -> ElementInfo: + return ElementInfo( + id=self.id, + key=self.key, + type=self.type, + text=self.text, + description=self.description, + isSelected=self.isSelected, + isChecked=self.isChecked, + isEnabled=self.isEnabled, + isFocused=self.isFocused, + isCheckable=self.isCheckable, + isClickable=self.isClickable, + isLongClickable=self.isLongClickable, + isScrollable=self.isScrollable, + bounds=self._bounds, + boundsCenter=self._center + ) diff --git a/hmdriver2/assets/uitest_agent_v1.1.7.so b/hmdriver2/assets/uitest_agent_v1.1.7.so new file mode 100644 index 0000000000000000000000000000000000000000..70198f15fe79e3d073a0ede29d4d7c94b4cc5aac GIT binary patch literal 153782 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 UO8i7$-(ZKs@dCRP4XNS(09eU30ssI2 literal 0 HcmV?d00001 diff --git a/hmdriver2/driver.py b/hmdriver2/driver.py index cde7fbb..b0e7f75 100644 --- a/hmdriver2/driver.py +++ b/hmdriver2/driver.py @@ -1,80 +1,133 @@ # -*- coding: utf-8 -*- - import json +import time import uuid -import re -from typing import Type, Any, Tuple, Dict, Union, List -from functools import cached_property # python3.8+ - -from . import logger +import atexit +from typing import Type, Tuple, Dict, Union, List, Optional +from functools import cached_property +from weakref import WeakValueDictionary from .utils import delay from ._client import HmClient from ._uiobject import UiObject +from .hdc import list_devices +from .exception import DeviceNotFoundError from .proto import HypiumResponse, KeyCode, Point, DisplayRotation, DeviceInfo, CommandResult class Driver: - _instance: Dict = {} - - def __init__(self, serial: str): - self.serial = serial - self._client = HmClient(self.serial) - self.hdc = self._client.hdc + _instance: Dict[str, "Driver"] = WeakValueDictionary() # 改用弱引用字典 + _cleanup_registered = False - self._init_hmclient() - - def __new__(cls: Type[Any], serial: str) -> Any: + def __new__(cls: Type["Driver"], serial: Optional[str] = None) -> "Driver": """ - Ensure that only one instance of Driver exists per device serial number. + Ensure that only one instance of Driver exists per serial. + If serial is None, use the first serial from list_devices(). """ + serial = cls._prepare_serial(serial) + if serial not in cls._instance: - cls._instance[serial] = super().__new__(cls) + instance = super().__new__(cls) + cls._instance[serial] = instance + instance._serial_for_init = serial # 临时存储serial用于初始化 + + # 注册全局清理(仅第一次) + if not cls._cleanup_registered: + atexit.register(cls._global_cleanup) + cls._cleanup_registered = True + return cls._instance[serial] - def __call__(self, **kwargs) -> UiObject: + def __init__(self, serial: Optional[str] = None): + """Initialize only once per instance.""" + if hasattr(self, "_initialized"): + return - return UiObject(self._client, **kwargs) + serial = getattr(self, "_serial_for_init", serial) + if serial is None: + raise ValueError("Serial number is required for initialization.") - def __del__(self): - if hasattr(self, '_client') and self._client: + self.serial = serial + print("开始启动", time.time()) + self._client = HmClient(self.serial) + self._client.start() + print("启动成功", time.time()) + self.hdc = self._client.hdc + self._initialized = True + del self._serial_for_init + + @classmethod + def _global_cleanup(cls): + """Safe cleanup during program exit.""" + for serial, instance in list(cls._instance.items()): + instance.close() + del cls._instance[serial] + + def close(self): + """Explicit resource release.""" + if hasattr(self, "_client") and self._client: self._client.release() + self._client = None - def _init_hmclient(self): - self._client.start() + def __call__(self, **kwargs) -> UiObject: + return UiObject(self._client, **kwargs) - def _invoke(self, api: str, args: List = []) -> HypiumResponse: + def _invoke(self, api: str, args=None) -> HypiumResponse: + if args is None: + args = [] return self._client.invoke(api, this="Driver#0", args=args) - @delay - def start_app(self, package_name: str, page_name: str = "MainAbility"): - self.hdc.start_app(package_name, page_name) + @classmethod + def _prepare_serial(cls, serial: Optional[str]) -> str: + """Validate device serial or auto-select first available.""" + devices = list_devices() + if not devices: + raise DeviceNotFoundError("No devices found.") + + if serial is None: + return devices[0] + if serial not in devices: + raise DeviceNotFoundError(f"Device [{serial}] not found") + return serial + + def app_start(self, package_name: str, page_name: Optional[str] = None): + """ + Start an application on the device. + If the `package_name` is empty, it will retrieve main ability using `get_app_main_ability`. - def force_start_app(self, package_name: str, page_name: str = "MainAbility"): + Args: + package_name (str): The package name of the application. + page_name (Optional[str]): Ability Name within the application to start. + """ + if not page_name: + page_name = self.get_app_main_ability(package_name).get('name', 'MainAbility') + self._client.hdc.app_start(package_name, page_name) + + def force_app_start(self, package_name: str, page_name: Optional[str] = None): self.go_home() - self.stop_app(package_name) - self.start_app(package_name, page_name) + self.app_stop(package_name) + self.app_start(package_name, page_name) - def stop_app(self, package_name: str): - self.hdc.stop_app(package_name) + def app_stop(self, package_name: str): + self._client.hdc.app_stop(package_name) def clear_app(self, package_name: str): """ Clear the application's cache and data. """ - self.hdc.shell(f"bm clean -n {package_name} -c") # clear cache - self.hdc.shell(f"bm clean -n {package_name} -d") # clear data + self._client.hdc.shell(f"bm clean -n {package_name} -c") # clear cache + self._client.hdc.shell(f"bm clean -n {package_name} -d") # clear data def install_app(self, apk_path: str): - self.hdc.install(apk_path) + self._client.hdc.install(apk_path) def uninstall_app(self, package_name: str): - self.hdc.uninstall(package_name) + self._client.hdc.uninstall(package_name) def list_apps(self) -> List: - return self.hdc.list_apps() + return self._client.hdc.list_apps() def has_app(self, package_name: str) -> bool: - return self.hdc.has_app(package_name) + return self._client.hdc.has_app(package_name) def current_app(self) -> Tuple[str, str]: """ @@ -85,7 +138,7 @@ def current_app(self) -> Tuple[str, str]: If no foreground application is found, returns (None, None). """ - return self.hdc.current_app() + return self._client.hdc.current_app() def get_app_info(self, package_name: str) -> Dict: """ @@ -99,7 +152,7 @@ def get_app_info(self, package_name: str) -> Dict: an empty dictionary is returned. """ app_info = {} - data: CommandResult = self.hdc.shell(f"bm dump -n {package_name}") + data: CommandResult = self._client.hdc.shell(f"bm dump -n {package_name}") output = data.output try: json_start = output.find("{") @@ -108,12 +161,76 @@ def get_app_info(self, package_name: str) -> Dict: app_info = json.loads(json_output) except Exception as e: - logger.error(f"An error occurred:{e}") + print(f"An error occurred:{e}") return app_info + def get_app_abilities(self, package_name: str) -> List[Dict]: + """ + Get the abilities of an application. + + Args: + package_name (str): The package name of the application. + + Returns: + List[Dict]: A list of dictionaries containing the abilities of the application. + """ + result = [] + app_info = self.get_app_info(package_name) + hap_module_infos = app_info.get("hapModuleInfos") + main_entry = app_info.get("mainEntry") + for hap_module_info in hap_module_infos: + # 尝试读取moduleInfo + try: + ability_infos = hap_module_info.get("abilityInfos") + module_main = hap_module_info["mainAbility"] + except Exception as e: + print(f"解析模块信息项失败, {repr(e)}") + continue + # 尝试读取abilityInfo + for ability_info in ability_infos: + try: + is_launcher_ability = False + skills = ability_info['skills'] + if len(skills) > 0 or "action.system.home" in skills[0]["actions"]: + is_launcher_ability = True + icon_ability_info = { + "name": ability_info["name"], + "moduleName": ability_info["moduleName"], + "moduleMainAbility": module_main, + "mainModule": main_entry, + "isLauncherAbility": is_launcher_ability + } + result.append(icon_ability_info) + except Exception as e: + print(f"解析ability_info项失败, {repr(e)}") + continue + return result + + def get_app_main_ability(self, package_name: str) -> Dict: + """ + Get the main ability of an application. + + Args: + package_name (str): The package name of the application to retrieve information for. + + Returns: + Dict: A dictionary containing the main ability of the application. + + """ + if not (abilities := self.get_app_abilities(package_name)): + return {} + for item in abilities: + score = 0 + if (name := item["name"]) and name == item["moduleMainAbility"]: + score += 1 + if (module_name := item["moduleName"]) and module_name == item["mainModule"]: + score += 1 + item["score"] = score + abilities.sort(key=lambda x: (not x["isLauncherAbility"], -x["score"])) + return abilities[0] + @cached_property def toast_watcher(self): - obj = self class _Watcher: @@ -122,7 +239,7 @@ def start(self) -> bool: resp: HypiumResponse = obj._invoke(api, args=["toastShow"]) return resp.result - def get_toast(self, timeout: int = 3) -> str: + def get_toast(self, timeout: int = 3) -> Union[str, None]: api = "Driver.getRecentUiEvent" resp: HypiumResponse = obj._invoke(api, args=[timeout]) if resp.result: @@ -131,23 +248,20 @@ def get_toast(self, timeout: int = 3) -> str: return _Watcher() - @delay def go_back(self): - self.hdc.send_key(KeyCode.BACK) + self._client.hdc.send_key(KeyCode.BACK) - @delay def go_home(self): - self.hdc.send_key(KeyCode.HOME) + self._client.hdc.send_key(KeyCode.HOME) - @delay def press_key(self, key_code: Union[KeyCode, int]): - self.hdc.send_key(key_code) + self._client.hdc.send_key(key_code) def screen_on(self): - self.hdc.wakeup() + self._client.hdc.wakeup() def screen_off(self): - self.hdc.wakeup() + self._client.hdc.wakeup() self.press_key(KeyCode.POWER) @delay @@ -163,6 +277,12 @@ def display_size(self) -> Tuple[int, int]: w, h = resp.result.get("x"), resp.result.get("y") return w, h + def window_size(self) -> Tuple[int, int]: + api = "Driver.getDisplaySize" + resp: HypiumResponse = self._invoke(api) + w, h = resp.result.get("x"), resp.result.get("y") + return w, h + @cached_property def display_rotation(self) -> DisplayRotation: api = "Driver.getDisplayRotation" @@ -174,7 +294,7 @@ def set_display_rotation(self, rotation: DisplayRotation): Sets the display rotation to the specified orientation. Args: - rotation (DisplayRotation): The desired display rotation. This should be an instance of the DisplayRotation enum. + rotation (DisplayRotation): display rotation. """ api = "Driver.setDisplayRotation" self._invoke(api, args=[rotation.value]) @@ -187,7 +307,7 @@ def device_info(self) -> DeviceInfo: Returns: DeviceInfo: An object containing various properties of the device. """ - hdc = self.hdc + hdc = self._client.hdc return DeviceInfo( productName=hdc.product_name(), model=hdc.model(), @@ -203,10 +323,10 @@ def device_info(self) -> DeviceInfo: def open_url(self, url: str, system_browser: bool = True): if system_browser: # Use the system browser - self.hdc.shell(f"aa start -A ohos.want.action.viewData -e entity.system.browsable -U {url}") + self._client.hdc.shell(f"aa start -A ohos.want.action.viewData -e entity.system.browsable -U {url}") else: # Default method - self.hdc.shell(f"aa start -U {url}") + self._client.hdc.shell(f"aa start -U {url}") def pull_file(self, rpath: str, lpath: str): """ @@ -216,7 +336,7 @@ def pull_file(self, rpath: str, lpath: str): rpath (str): The remote path of the file on the device. lpath (str): The local path where the file should be saved. """ - self.hdc.recv_file(rpath, lpath) + self._client.hdc.recv_file(rpath, lpath) def push_file(self, lpath: str, rpath: str): """ @@ -226,7 +346,7 @@ def push_file(self, lpath: str, rpath: str): lpath (str): The local path of the file. rpath (str): The remote path where the file should be saved on the device. """ - self.hdc.send_file(lpath, rpath) + self._client.hdc.send_file(lpath, rpath) def screenshot(self, path: str) -> str: """ @@ -246,9 +366,9 @@ def screenshot(self, path: str) -> str: return path def shell(self, cmd) -> CommandResult: - return self.hdc.shell(cmd) + return self._client.hdc.shell(cmd) - def _to_abs_pos(self, x: Union[int, float], y: Union[int, float]) -> Point: + def to_abs_pos(self, x: Union[int, float], y: Union[int, float]) -> Point: """ Convert percentages to absolute screen coordinates. @@ -270,27 +390,23 @@ def _to_abs_pos(self, x: Union[int, float], y: Union[int, float]) -> Point: y = int(h * y) return Point(int(x), int(y)) - @delay def click(self, x: Union[int, float], y: Union[int, float]): + # todo 使用hdc自带的uinput命令点击速度更快 + self._client.hdc.tap(x, y) + # point = self.to_abs_pos(x, y) + # api = "Driver.click" + # self._invoke(api, args=[point.x, point.y]) - # self.hdc.tap(point.x, point.y) - point = self._to_abs_pos(x, y) - api = "Driver.click" - self._invoke(api, args=[point.x, point.y]) - - @delay def double_click(self, x: Union[int, float], y: Union[int, float]): - point = self._to_abs_pos(x, y) + point = self.to_abs_pos(x, y) api = "Driver.doubleClick" self._invoke(api, args=[point.x, point.y]) - @delay def long_click(self, x: Union[int, float], y: Union[int, float]): - point = self._to_abs_pos(x, y) + point = self.to_abs_pos(x, y) api = "Driver.longClick" self._invoke(api, args=[point.x, point.y]) - @delay def swipe(self, x1, y1, x2, y2, speed=2000): """ Perform a swipe action on the device screen. @@ -300,14 +416,13 @@ def swipe(self, x1, y1, x2, y2, speed=2000): y1 (float): The start Y coordinate as a percentage or absolute value. x2 (float): The end X coordinate as a percentage or absolute value. y2 (float): The end Y coordinate as a percentage or absolute value. - speed (int, optional): The swipe speed in pixels per second. Default is 2000. Range: 200-40000. If not within the range, set to default value of 2000. + speed (int, optional): The swipe speed in pixels per second. Default is 2000. Range: 200-40000, + If not within the range, set to default value of 2000. """ - - point1 = self._to_abs_pos(x1, y1) - point2 = self._to_abs_pos(x2, y2) + point1 = self.to_abs_pos(x1, y1) + point2 = self.to_abs_pos(x2, y2) if speed < 200 or speed > 40000: - logger.warning("`speed` is not in the range[200-40000], Set to default value of 2000.") speed = 2000 api = "Driver.swipe" @@ -322,38 +437,41 @@ def swipe_ext(self): from ._swipe import SwipeExt return SwipeExt(self) - @delay def input_text(self, text: str): """ - Inputs text into the currently focused input field. + 在当前焦点输入框中输入文本 - Note: The input field must have focus before calling this method. + 注意:调用此方法前,输入框必须已获得焦点 Args: - text (str): input value + text: 要输入的文本 + + Returns: + HypiumResponse: API 调用响应 """ return self._invoke("Driver.inputText", args=[{"x": 1, "y": 1}, text]) - def dump_hierarchy(self) -> Dict: + def dump_hierarchy(self) -> Union[dict, None]: """ - Dump the UI hierarchy of the device screen. + 导出界面层次结构 Returns: - Dict: The dumped UI hierarchy as a dictionary. + str: 界面层次结构的 JSON 字符串 """ - # return self._client.invoke_captures("captureLayout").result - return self.hdc.dump_hierarchy() + # 环形缓冲区要设置大一些,才能获取到消息头和尾部。完美解决 + result = self._client.invoke_captures("captureLayout") + if result: + xml_result = result.result + if isinstance(xml_result, dict): + return xml_result + return json.loads(xml_result) + return None @cached_property def gesture(self): from ._gesture import _Gesture return _Gesture(self) - @cached_property - def screenrecord(self): - from ._screenrecord import RecordClient - return RecordClient(self.serial, self) - def _invalidate_cache(self, attribute_name): """ Invalidate the cached property. diff --git a/hmdriver2/hdc.py b/hmdriver2/hdc.py index d9d5405..da33729 100644 --- a/hmdriver2/hdc.py +++ b/hmdriver2/hdc.py @@ -1,14 +1,12 @@ # -*- coding: utf-8 -*- - -import tempfile import json +import tempfile +import os import uuid import shlex import re import subprocess -from typing import Union, List, Dict, Tuple - -from . import logger +from typing import Union, List, Tuple, Dict from .utils import FreePort from .proto import CommandResult, KeyCode from .exception import HdcError, DeviceNotFoundError @@ -17,19 +15,20 @@ def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult: if isinstance(cmdargs, (list, tuple)): cmdline: str = ' '.join(list(map(shlex.quote, cmdargs))) - elif isinstance(cmdargs, str): + else: cmdline = cmdargs - logger.debug(cmdline) try: - process = subprocess.Popen(cmdline, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, shell=True) + process = subprocess.Popen(cmdline, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True) output, error = process.communicate() output = output.decode('utf-8') error = error.decode('utf-8') exit_code = process.returncode - if output.lower().__contains__('error:'): + if 'error:' in output.lower() or '[fail]' in output.lower(): return CommandResult("", output, -1) return CommandResult(output, error, exit_code) @@ -37,9 +36,21 @@ def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult: return CommandResult("", str(e), -1) +def _build_hdc_prefix() -> str: + """ + Construct the hdc command prefix based on environment variables. + """ + host = os.getenv("HDC_SERVER_HOST") + port = os.getenv("HDC_SERVER_PORT") + if host and port: + return f"hdc -s {host}:{port}" + return "hdc" + + def list_devices() -> List[str]: devices = [] - result = _execute_command('hdc list targets') + hdc_prefix = _build_hdc_prefix() + result = _execute_command(f"{hdc_prefix} list targets") if result.exit_code == 0 and result.output: lines = result.output.strip().split('\n') for line in lines: @@ -56,6 +67,8 @@ def list_devices() -> List[str]: class HdcWrapper: def __init__(self, serial: str) -> None: self.serial = serial + self.hdc_prefix = _build_hdc_prefix() + if not self.is_online(): raise DeviceNotFoundError(f"Device [{self.serial}] not found") @@ -63,55 +76,63 @@ def is_online(self): _serials = list_devices() return True if self.serial in _serials else False - def forward_port(self, rport: int) -> int: - lport: int = FreePort().get() - result = _execute_command(f"hdc -t {self.serial} fport tcp:{lport} tcp:{rport}") + def forward_port(self, r_port: int) -> int: + l_port: int = FreePort().get() + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport tcp:{l_port} tcp:{r_port}") if result.exit_code != 0: raise HdcError("HDC forward port error", result.error) - return lport + return l_port - def rm_forward(self, lport: int, rport: int) -> int: - result = _execute_command(f"hdc -t {self.serial} fport rm tcp:{lport} tcp:{rport}") + def rm_forward(self, l_port: int, r_port: int) -> int: + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport rm tcp:{l_port} tcp:{r_port}") if result.exit_code != 0: raise HdcError("HDC rm forward error", result.error) - return lport + return l_port def list_fport(self) -> List: """ eg.['tcp:10001 tcp:8012', 'tcp:10255 tcp:8012'] """ - result = _execute_command(f"hdc -t {self.serial} fport ls") + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport ls") if result.exit_code != 0: raise HdcError("HDC forward list error", result.error) pattern = re.compile(r"tcp:\d+ tcp:\d+") return pattern.findall(result.output) def send_file(self, lpath: str, rpath: str): - result = _execute_command(f"hdc -t {self.serial} file send {lpath} {rpath}") + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} file send {lpath} {rpath}") if result.exit_code != 0: raise HdcError("HDC send file error", result.error) return result def recv_file(self, rpath: str, lpath: str): - result = _execute_command(f"hdc -t {self.serial} file recv {rpath} {lpath}") + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} file recv {rpath} {lpath}") if result.exit_code != 0: raise HdcError("HDC receive file error", result.error) return result def shell(self, cmd: str, error_raise=True) -> CommandResult: - result = _execute_command(f"hdc -t {self.serial} shell {cmd}") + # ensure the command is wrapped in double quotes + if cmd[0] != '\"': + cmd = "\"" + cmd + if cmd[-1] != '\"': + cmd += '\"' + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} shell {cmd}") if result.exit_code != 0 and error_raise: raise HdcError("HDC shell error", f"{cmd}\n{result.output}\n{result.error}") return result def uninstall(self, bundlename: str): - result = _execute_command(f"hdc -t {self.serial} uninstall {bundlename}") + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} uninstall {bundlename}") if result.exit_code != 0: raise HdcError("HDC uninstall error", result.output) return result def install(self, apkpath: str): - result = _execute_command(f"hdc -t {self.serial} install '{apkpath}'") + # Ensure the path is properly quoted for Windows + quoted_path = f'"{apkpath}"' + + result = _execute_command(f"{self.hdc_prefix} -t {self.serial} install {quoted_path}") if result.exit_code != 0: raise HdcError("HDC install error", result.error) return result @@ -125,10 +146,10 @@ def has_app(self, package_name: str) -> bool: data = self.shell("bm dump -a").output return True if package_name in data else False - def start_app(self, package_name: str, ability_name: str): + def app_start(self, package_name: str, ability_name: str): return self.shell(f"aa start -a {ability_name} -b {package_name}") - def stop_app(self, package_name: str): + def app_stop(self, package_name: str): return self.shell(f"aa force-stop {package_name}") def current_app(self) -> Tuple[str, str]: @@ -140,23 +161,23 @@ def current_app(self) -> Tuple[str, str]: If no foreground application is found, returns (None, None). """ - def __extract_info(output: str): - results = [] + def __extract_info(_output: str): + _results = [] - mission_blocks = re.findall(r'Mission ID #[\s\S]*?isKeepAlive: false\s*}', output) + mission_blocks = re.findall(r'Mission ID #[\s\S]*?isKeepAlive: false\s*}', _output) if not mission_blocks: - return results + return _results for block in mission_blocks: if 'state #FOREGROUND' in block: - bundle_name_match = re.search(r'bundle name \[(.*?)\]', block) - main_name_match = re.search(r'main name \[(.*?)\]', block) + bundle_name_match = re.search(r'bundle name \[(.*?)\\]', block) + main_name_match = re.search(r'main name \[(.*?)\\]', block) if bundle_name_match and main_name_match: package_name = bundle_name_match.group(1) page_name = main_name_match.group(1) - results.append((package_name, page_name)) + _results.append((package_name, page_name)) - return results + return _results data: CommandResult = self.shell("aa dump -l") output = data.output @@ -215,8 +236,8 @@ def display_size(self) -> Tuple[int, int]: if match: w = int(match.group(1)) h = int(match.group(2)) - return (w, h) - return (0, 0) + return w, h + return 0, 0 def send_key(self, key_code: Union[KeyCode, int]) -> None: if isinstance(key_code, KeyCode): @@ -229,7 +250,8 @@ def send_key(self, key_code: Union[KeyCode, int]) -> None: self.shell(f"uitest uiInput keyEvent {key_code}") def tap(self, x: int, y: int) -> None: - self.shell(f"uitest uiInput click {x} {y}") + # 点击用这个方法,速度更快 参考文档 https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/dfx/uinput.md + self.shell(f"uinput -T -c {x} {y}") def swipe(self, x1, y1, x2, y2, speed=1000): self.shell(f"uitest uiInput swipe {x1} {y1} {x2} {y2} {speed}") @@ -254,10 +276,9 @@ def dump_hierarchy(self) -> Dict: self.recv_file(_tmp_path, path) try: - with open(path, 'r') as file: + with open(path, 'r', encoding='utf8') as file: data = json.load(file) except Exception as e: - logger.error(f"Error loading JSON file: {e}") data = {} - return data + return data \ No newline at end of file diff --git a/hmdriver2/proto.py b/hmdriver2/proto.py index e987cc9..59f08da 100644 --- a/hmdriver2/proto.py +++ b/hmdriver2/proto.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- - import json from enum import Enum -from typing import Union, List +from typing import Union, Dict, List from dataclasses import dataclass, asdict @@ -35,12 +34,12 @@ def from_value(cls, value): class AppState: - INIT = 0 # 初始化状态,应用正在初始化 - READY = 1 # 就绪状态,应用已初始化完毕 + INIT = 0 # 初始化状态,应用正在初始化 + READY = 1 # 就绪状态,应用已初始化完毕 FOREGROUND = 2 # 前台状态,应用位于前台 - FOCUS = 3 # 获焦状态。(预留状态,当前暂不支持) + FOCUS = 3 # 获焦状态。(预留状态,当前暂不支持) BACKGROUND = 4 # 后台状态,应用位于后台 - EXIT = 5 # 退出状态,应用已退出 + EXIT = 5 # 退出状态,应用已退出 @dataclass @@ -64,8 +63,9 @@ class HypiumResponse: {"result":null,"exception":"Can not connect to AAMS, RET_ERR_CONNECTION_EXIST"} {"exception":{"code":401,"message":"(PreProcessing: APiCallInfoChecker)Illegal argument count"}} """ - result: Union[List, bool, str, None] = None - exception: Union[List, bool, str, None] = None + result: Union[List, Dict, bool, str, None] = None + exception: Union[List, Dict, bool, str, None] = None + pts:Union[List, Dict, bool, str, None] = None @dataclass @@ -75,7 +75,7 @@ class ByData: @dataclass class DriverData: - value: str # "Driver#0" + value: str # "Driver#0" @dataclass @@ -85,8 +85,13 @@ class ComponentData: @dataclass class Point: - x: int - y: int + def __init__(self, x, y): + self.x = x + self.y = y + + def __iter__(self): + yield self.x + yield self.y def to_tuple(self): return self.x, self.y @@ -100,14 +105,21 @@ def to_dict(self): @dataclass class Bounds: - left: int - top: int - right: int - bottom: int + + def __init__(self, left, top, right, bottom): + self.left = left + self.top = top + self.right = right + self.bottom = bottom + + def __iter__(self): + yield self.left + yield self.top + yield self.right + yield self.bottom def get_center(self) -> Point: - return Point(int((self.left + self.right) / 2), - int((self.top + self.bottom) / 2)) + return Point(int((self.left + self.right) / 2), int((self.top + self.bottom) / 2)) @dataclass @@ -126,7 +138,7 @@ class ElementInfo: isLongClickable: bool isScrollable: bool bounds: Bounds - boundsCenter: Point + boundsCenter: Union[Point, None] = None def __str__(self) -> str: return json.dumps(asdict(self), indent=4) @@ -471,4 +483,4 @@ class KeyCode(Enum): BTN_6 = 3106 # 按键6 BTN_7 = 3107 # 按键7 BTN_8 = 3108 # 按键8 - BTN_9 = 3109 # 按键9 \ No newline at end of file + BTN_9 = 3109 # 按键9 diff --git a/hmdriver2/utils.py b/hmdriver2/utils.py index cf4f75a..37bf78b 100644 --- a/hmdriver2/utils.py +++ b/hmdriver2/utils.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- - - import time import socket import re diff --git a/test_example.py b/test_example.py new file mode 100644 index 0000000..595604d --- /dev/null +++ b/test_example.py @@ -0,0 +1,70 @@ +import time +from hmdriver2.driver import Driver + + +class TestExample: + demo_test = None + + @classmethod + def setup_class(cls): + """类级别的前置方法,整个测试类只执行一次""" + cls.demo_test = Driver("2PM0224423003375") + cls.demo_test.app_start(package_name="com.qihoo.smartoh", page_name="EntryAbility") + + @classmethod + def teardown_class(cls): + """类级别的后置方法,整个测试类只执行一次""" + cls.demo_test = None + print("Driver closed") + + def setup_method(self): + """完全对齐 uiautomator2的元素查询,属性获取""" + # text 查询 + ele = self.demo_test(text='settings') + print(ele.info) + ele.click() + self.demo_test.go_back() + + # 对齐uiautomator2 仅方法名不一样 + # 1. 判断元素消失或出现 + # 在指定时间内,如10秒内等待元素消失 或 出现。当符合条件是返回True; 否则10秒超时后,返回False + # 1.1 text 等其他选择器 + ele_exist = self.demo_test(text='settings').wait(exists=True, timeout=10) # exists=False 当元消失返回True + print("元素状态", ele_exist) + + # 1.2 xpath + xpath_exist = self.demo_test.xpath('//*[contains(@text,"card_5081e5dd2d6a")]').wait(exists=True, timeout=10) + print("元素状态", xpath_exist) + + # 2. 元素查询 + # 2.1 text 等其他选择器 + # 10秒内一直查询,直到10秒超时: 默认返回多元素列表,对齐 uiautomator2 + text_ele = self.demo_test(text='settings').find_components(timeout=10) + if text_ele: + print(text_ele) + # 链式调用 + aa = text_ele[0].bounds + text_ele[1].click() + + # 2.2 xpath 选择器. 没有超时逻辑,可自行封装,对齐 uiautomator2 + # 2.3 高级功能 支持xpath 表达式列表。并发查询如 ['//*[contains(@text,"card_123")]', '//*[contains(@text,"card_345")]'] + xpath_elements = self.demo_test.xpath('//*[contains(@text,"card_5081e5dd2d6a")]').find_all() + if xpath_elements: + print(xpath_elements) + # 链式调用 + bb = xpath_elements[0].bounds + xpath_elements[1].click() + + def teardown_method(self): + """方法级别的后置方法,每个测试方法执行后都会执行""" + # 返回首页 + self.demo_test.go_back() + time.sleep(3) + + def test_example_case(self): + # 判断开流 + rr = self.demo_test.xpath('//*[@text="camera_rtc_duplex"]') + time.sleep(2) + assert rr.enabled == True + +# pytest test_example.py -s -v --count=20