diff --git a/README.md b/README.md
index 2aa2f9b..0d1c621 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,12 @@
[](https://github.com/codematrixer/hmdriver2/actions)
[](https://pypi.python.org/pypi/hmdriver2)

-[](https://pepy.tech/project/hmdriver2)
+[](https://pepy.tech/project/hmdriver2)
-> 写这个项目前github上已有个叫`hmdriver`的项目,但它是侵入式(需要提前在手机端安装一个testRunner app);另外鸿蒙官方提供的hypium自动化框架,使用较为复杂,依赖繁杂。于是决定重写一套。
+> **📢 重要通知:**
+>
+> **由于原项目的维护模式已变更,本仓库(当前项目)将接替成为核心维护分支。会尽量保持更新(包括功能改进、Bug修复等)。**
**hmdriver2** 是一款支持`HarmonyOS NEXT`系统的UI自动化框架,**无侵入式**,提供应用管理,UI操作,元素定位等功能,轻量高效,上手简单,快速实现鸿蒙应用自动化测试需求。
@@ -17,6 +19,9 @@
+> 其他项目:HarmonyScrcpy(HarmonyOS NEXT / OpenHarmony 同屏与应用重签名工具)
+> 项目地址:
+
# Key idea
- **无侵入式**
- 无需提前在手机端安装testRunner APP(类似atx app)
@@ -47,6 +52,11 @@
- 控件点击,长按,拖拽,缩放
- 文本输入,清除
- 获取控件树
+- 支持 WebView(WebDriver)自动化
+ - 自动发现与连接 WebView
+ - 端口转发(TCP / 应用内部端口)
+ - ChromeDriver 版本自动适配(114 / 140)
+ - 自动切换到可见窗口
- 支持Toast获取
- UI Inspector
- [TODO] 全场景弹窗处理
@@ -779,6 +789,86 @@ toast = d.toast_watcher.get_toast()
# output: 'testMessage'
```
+## WebDriver
+
+### 配置 ChromeDriver
+
+- 下载对应版本的 chromedriver,并放置到项目目录:
+ - `hmdriver2/assets/web_debug_tools/chromedriver_<版本>/chromedriver[.exe]`
+ - 示例:
+ - Windows: `hmdriver2/assets/web_debug_tools/chromedriver_114/chromedriver.exe`
+ - Linux/macOS: `hmdriver2/assets/web_debug_tools/chromedriver_140/chromedriver`
+- 仅使用项目内置目录,不从系统 PATH 中查找。
+
+可选配置:
+```python
+driver.webdriver.configure_chromedriver(
+ enable_log=False, # 是否启用 chromedriver 日志
+ log_level="INFO", # OFF/SEVERE/WARNING/INFO/DEBUG/ALL
+ backup_log=False, # 是否备份日志
+ port=9515 # chromedriver 服务端口(默认9515)
+)
+```
+
+### 连接 WebView
+
+```python
+from hmdriver2.driver import Driver
+
+d = Driver()
+d.start_app("com.huawei.hmos.browser")
+
+wd = d.webdriver.connect("com.huawei.hmos.browser")
+# 或指定 chromedriver 版本
+# wd = d.webdriver.connect("com.huawei.hmos.browser", chromedriver_version=140)
+
+wd.get("https://www.baidu.com")
+print(wd.title)
+```
+
+### 超时设置
+
+- 页面加载超时:默认 10 秒
+- 脚本执行超时:默认 10 秒
+- 元素查找隐式等待:默认 5 秒
+
+如需自定义,可在连接后自行调用 Selenium 原生 API:
+```python
+wd.set_page_load_timeout(30)
+wd.set_script_timeout(30)
+wd.implicitly_wait(10)
+```
+
+### 窗口管理
+
+- 连接后自动切换到“可见窗口”;如需手动:
+```python
+d.webdriver.switch_to_visible_window(index=0) # 第N个可见窗口,支持负数
+```
+
+### 常用示例
+
+```python
+from selenium.webdriver.common.by import By
+
+wd.get("https://www.baidu.com")
+search = wd.find_element(By.ID, "index-kw")
+search.send_keys("HarmonyOS")
+```
+
+### 故障排查
+
+- 无法获取 WebView 版本:
+ 1) 确认应用已启用 WebView 调试
+ 2) 设备未锁屏且 WebView 界面可见
+ 3) WebView 已完全加载
+- 端口转发失败:
+ - 检查是否使用了应用内部端口(abstract socket)或 TCP 端口
+ - 查看日志中的“系统内部端口转发/应用内部端口转发”信息
+- 版本不兼容:
+ - WebView 114-132 使用 chromedriver_114
+ - WebView 140+ 使用 chromedriver_140
+
# 鸿蒙Uitest协议
See [DEVELOP.md](/docs/DEVELOP.md)
diff --git a/example.py b/example.py
index f11eed1..6a1281b 100644
--- a/example.py
+++ b/example.py
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
import time
+
from hmdriver2.driver import Driver
from hmdriver2.proto import DeviceInfo, KeyCode, ComponentData, DisplayRotation
-
# New driver
d = Driver("FMR0223C13000649")
@@ -135,3 +135,5 @@
d.xpath('//*[@text="showDialog"]').click_if_exists()
d.xpath('//root[1]/Row[1]/Column[1]/Row[1]/Button[3]').click()
d.xpath('//*[@text="showDialog"]').input_text("xxx")
+d.xpath('//*[@text="showDialog"]').text()
+d.xpath('//*[@text="showDialog"]').clickable()
diff --git a/hmdriver2/__init__.py b/hmdriver2/__init__.py
index 29620bf..6334814 100644
--- a/hmdriver2/__init__.py
+++ b/hmdriver2/__init__.py
@@ -2,8 +2,8 @@
import logging
-formatter = logging.Formatter('[%(asctime)s] %(filename)15s[line:%(lineno)4d] \
- [%(levelname)s] %(message)s',
+formatter = logging.Formatter('[%(asctime)s] %(filename)18s[line:%(lineno)4d] ' +
+ '[%(levelname)-7s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
logger = logging.getLogger('hmdriver2')
diff --git a/hmdriver2/_client.py b/hmdriver2/_client.py
index 6dfe01a..4d79ed2 100644
--- a/hmdriver2/_client.py
+++ b/hmdriver2/_client.py
@@ -1,100 +1,247 @@
# -*- coding: utf-8 -*-
-import socket
+
+import hashlib
import json
-import time
import os
-import hashlib
-import typing
-from typing import Optional
+import socket
+import struct
+import time
from datetime import datetime
-from functools import cached_property
+from typing import Optional, Union, Dict, List, Any
from . import logger
+from .exception import InvokeHypiumError, InvokeCaptures
from .hdc import HdcWrapper
from .proto import HypiumResponse, DriverData
-from .exception import InvokeHypiumError, InvokeCaptures
+# 连接相关常量
+UITEST_SERVICE_PORT = 8012 # 设备端服务端口
+SOCKET_TIMEOUT = 20 # Socket 超时时间(秒)
+LOCAL_HOST = "127.0.0.1" # 本地主机地址
-UITEST_SERVICE_PORT = 8012
-SOCKET_TIMEOUT = 20
+# 消息协议常量
+MSG_HEADER = b'_uitestkit_rpc_message_head_' # 消息头标识
+MSG_TAILER = b'_uitestkit_rpc_message_tail_' # 消息尾标识
+SESSION_ID_LENGTH = 4 # 会话ID长度(字节)
+
+# API 模块常量
+API_MODULE = "com.ohos.devicetest.hypiumApiHelper" # API 模块名
+API_METHOD_HYPIUM = "callHypiumApi" # Hypium API 调用方法
+API_METHOD_CAPTURES = "Captures" # Captures API 调用方法
+DEFAULT_THIS = "Driver#0" # 默认目标对象
class HmClient:
- """harmony uitest client"""
+ """
+ Harmony OS 设备通信客户端
+
+ 负责与设备建立连接、发送命令和接收响应,是与设备交互的基础类。
+ 通过 HDC(Harmony Debug Console)建立端口转发,使用 Socket 进行通信。
+ """
+
def __init__(self, serial: str):
+ """
+ 初始化客户端
+
+ Args:
+ serial: 设备序列号
+ """
self.hdc = HdcWrapper(serial)
- self.sock = None
-
- @cached_property
- def local_port(self):
- fports = self.hdc.list_fport()
- logger.debug(fports) if fports else None
+ self.sock: Optional[socket.socket] = None
+ self._header_length = len(MSG_HEADER)
+ self._tailer_length = len(MSG_TAILER)
+ self._local_port: Optional[int] = None # 存储已建立的本地端口
- return self.hdc.forward_port(UITEST_SERVICE_PORT)
-
- def _rm_local_port(self):
- logger.debug("rm fport local port")
- self.hdc.rm_forward(self.local_port, UITEST_SERVICE_PORT)
-
- def _connect_sock(self):
- """Create socket and connect to the uiTEST server."""
+ @property
+ def local_port(self) -> int:
+ """
+ 获取本地转发端口
+
+ Returns:
+ int: 本地端口号
+ """
+ if self._local_port is None:
+ self._local_port = self.hdc.forward_port(UITEST_SERVICE_PORT)
+ logger.debug(f"建立端口转发: {self._local_port} -> {UITEST_SERVICE_PORT}")
+ return self._local_port
+
+ def _rm_local_port(self) -> None:
+ """移除本地端口转发"""
+ if self._local_port is not None:
+ logger.debug(f"移除端口转发: {self._local_port} -> {UITEST_SERVICE_PORT}")
+ try:
+ self.hdc.rm_forward(self._local_port, UITEST_SERVICE_PORT)
+ except Exception as e:
+ logger.warning(f"移除端口转发时出错: {e}")
+ finally:
+ self._local_port = None # 清除端口缓存
+
+ def _connect_sock(self) -> None:
+ """创建 Socket 并连接到 UITest 服务器"""
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()
+ self.sock.connect((LOCAL_HOST, self.local_port))
+
+ def _send_msg(self, msg: Dict[str, Any]) -> None:
+ """
+ 发送消息到服务器
+
+ Args:
+ msg: 要发送的消息字典
+
+ 消息格式示例:
+ {
+ "module": "com.ohos.devicetest.hypiumApiHelper",
+ "method": "callHypiumApi",
+ "params": {
+ "api": "Driver.create",
+ "this": null,
+ "args": [],
+ "message_type": "hypium"
+ },
+ "request_id": "20240815161352267072"
+ }
+ """
+ # 序列化消息
+ msg_str = json.dumps(msg, ensure_ascii=False, separators=(',', ':'))
+ logger.debug(f"发送消息: {msg_str}")
+
+ # 生成会话ID并构建消息头
+ msg_bytes = msg_str.encode('utf-8')
+ session_id = self._generate_session_id(msg_str)
+ header = (
+ MSG_HEADER +
+ struct.pack('>I', session_id) +
+ struct.pack('>I', len(msg_bytes))
+ )
+
+ # 发送完整消息(头部 + 消息体 + 尾部)
+ if self.sock is None:
+ raise ConnectionError("Socket 未连接")
+ self.sock.sendall(header + msg_bytes + MSG_TAILER)
+
+ def _generate_session_id(self, message: str) -> int:
+ """
+ 生成会话ID
+
+ 将时间戳、消息内容和随机数据组合生成唯一标识符
+
+ Args:
+ message: 消息内容
+
+ Returns:
+ int: 生成的会话ID
+ """
+ # 组合时间戳、消息内容和随机数据
+ combined = (
+ str(int(time.time() * 1000)) + # 毫秒时间戳
+ message +
+ os.urandom(4).hex() # 16字节随机熵
+ )
+ # 生成哈希并取前8位转为整数
+ return int(hashlib.sha256(combined.encode()).hexdigest()[:8], 16)
+
+ def _recv_msg(self, decode: bool = False, print: bool = True) -> Union[bytearray, str]:
+ """
+ 接收并解析消息
+
+ Args:
+ decode: 是否解码为字符串
+ print: 是否打印接收到的消息
+
+ Returns:
+ 解析后的消息内容(字节数组或字符串)
+
+ Raises:
+ ConnectionError: 连接中断时抛出
+ """
try:
- # FIXME
- relay = self.sock.recv(buff_size)
- if decode:
- relay = relay.decode()
+ # 接收消息头
+ header_len = self._header_length + SESSION_ID_LENGTH + 4
+ header = self._recv_exact(header_len) # 头部 + session_id + length
+ if not header or header[:self._header_length] != MSG_HEADER:
+ logger.warning("接收到无效的消息头")
+ return bytearray() if not decode else ""
+
+ # 解析消息长度(不验证session_id)
+ msg_length = struct.unpack('>I', header[self._header_length + SESSION_ID_LENGTH:])[0]
+
+ # 接收消息体
+ msg_bytes = self._recv_exact(msg_length)
+ if not msg_bytes:
+ logger.warning("接收消息体失败")
+ return bytearray() if not decode else ""
+
+ # 接收消息尾
+ tailer = self._recv_exact(self._tailer_length)
+ if not tailer or tailer != MSG_TAILER:
+ logger.warning("接收到无效的消息尾")
+ return bytearray() if not decode else ""
+
+ # 处理消息内容
+ if not decode:
+ logger.debug(f"接收到字节消息 (大小: {len(msg_bytes)})")
+ return bytearray(msg_bytes)
+
+ # 解码为字符串
+ msg_str = msg_bytes.decode('utf-8')
if print:
- logger.debug(f"recvMsg: {relay}")
- full_msg = relay
-
- except (socket.timeout, UnicodeDecodeError) as e:
- logger.warning(e)
- if decode:
- full_msg = ""
+ logger.debug(f"接收到消息: {msg_str}")
+ return msg_str
- return full_msg
+ except (socket.timeout, ValueError, json.JSONDecodeError) as e:
+ logger.warning(f"接收消息时出错: {e}")
+ return bytearray() if not decode else ""
- def invoke(self, api: str, this: str = "Driver#0", args: typing.List = []) -> HypiumResponse:
+ def _recv_exact(self, length: int) -> bytes:
"""
- 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.
-
+ length: 要接收的数据长度
+
Returns:
- HypiumResponse: The response from the API call.
-
+ bytes: 接收到的数据
+
Raises:
- InvokeHypiumError: If the API call returns an exception in the response.
+ ConnectionError: 连接关闭时抛出
+ """
+ if self.sock is None:
+ raise ConnectionError("Socket 未连接")
+
+ buf = bytearray(length)
+ view = memoryview(buf)
+ pos = 0
+
+ while pos < length:
+ chunk_size = self.sock.recv_into(view[pos:], length - pos)
+ if not chunk_size:
+ raise ConnectionError("接收数据时连接已关闭")
+ pos += chunk_size
+
+ return buf
+
+ def invoke(self, api: str, this: Optional[str] = DEFAULT_THIS, args: Optional[List[Any]] = None) -> HypiumResponse:
"""
+ 调用 Hypium API
+
+ Args:
+ api: API 名称
+ this: 目标对象标识符,默认为 "Driver#0"
+ args: API 参数列表,默认为空列表
+
+ Returns:
+ HypiumResponse: API 调用响应
+
+ Raises:
+ InvokeHypiumError: API 调用返回异常时抛出
+ """
+ if args is None:
+ args = []
+ # 构建请求参数
request_id = datetime.now().strftime("%Y%m%d%H%M%S%f")
params = {
"api": api,
@@ -103,51 +250,96 @@ def invoke(self, api: str, this: str = "Driver#0", args: typing.List = []) -> Hy
"message_type": "hypium"
}
+ # 构建完整消息
msg = {
- "module": "com.ohos.devicetest.hypiumApiHelper",
- "method": "callHypiumApi",
+ "module": API_MODULE,
+ "method": API_METHOD_HYPIUM,
"params": params,
"request_id": request_id
}
+ # 发送请求并处理响应
self._send_msg(msg)
raw_data = self._recv_msg(decode=True)
- data = HypiumResponse(**(json.loads(raw_data)))
+ if not raw_data:
+ raise InvokeHypiumError("接收响应失败")
+
+ try:
+ data = HypiumResponse(**(json.loads(raw_data)))
+ except json.JSONDecodeError as e:
+ raise InvokeHypiumError(f"解析响应失败: {e}")
+
+ # 处理异常
if data.exception:
raise InvokeHypiumError(data.exception)
return data
- def invoke_captures(self, api: str, args: typing.List = []) -> HypiumResponse:
+ def invoke_captures(self, api: str, args: Optional[List[Any]] = None) -> HypiumResponse:
+ """
+ 调用 Captures API
+
+ Args:
+ api: API 名称
+ args: API 参数列表,默认为空列表
+
+ Returns:
+ HypiumResponse: API 调用响应
+
+ Raises:
+ InvokeCaptures: API 调用返回异常时抛出
+ """
+ if args is None:
+ args = []
+
+ # 构建请求参数
request_id = datetime.now().strftime("%Y%m%d%H%M%S%f")
params = {
"api": api,
"args": args
}
+ # 构建完整消息
msg = {
- "module": "com.ohos.devicetest.hypiumApiHelper",
- "method": "Captures",
+ "module": API_MODULE,
+ "method": API_METHOD_CAPTURES,
"params": params,
"request_id": request_id
}
+ # 发送请求并处理响应
self._send_msg(msg)
raw_data = self._recv_msg(decode=True)
- data = HypiumResponse(**(json.loads(raw_data)))
+ if not raw_data:
+ raise InvokeCaptures("接收响应失败")
+
+ try:
+ data = HypiumResponse(**(json.loads(raw_data)))
+ except json.JSONDecodeError as e:
+ raise InvokeCaptures(f"解析响应失败: {e}")
+
+ # 处理异常
if data.exception:
raise InvokeCaptures(data.exception)
return data
- def start(self):
- logger.info("Start HmClient connection")
+ def start(self) -> None:
+ """
+ 启动客户端连接
+
+ 初始化 UITest 服务,建立 Socket 连接,创建驱动实例
+ """
+ logger.info("启动 HmClient 连接")
_UITestService(self.hdc).init()
-
self._connect_sock()
-
self._create_hdriver()
- def release(self):
- logger.info(f"Release {self.__class__.__name__} connection")
+ def release(self) -> None:
+ """
+ 释放客户端资源
+
+ 关闭 Socket 连接,移除端口转发
+ """
+ logger.info(f"释放 {self.__class__.__name__} 连接")
try:
if self.sock:
self.sock.close()
@@ -156,54 +348,97 @@ def release(self):
self._rm_local_port()
except Exception as e:
- logger.error(f"An error occurred: {e}")
+ logger.error(f"释放资源时出错: {e}")
def _create_hdriver(self) -> DriverData:
- logger.debug("Create uitest driver")
+ """
+ 创建 UITest 驱动实例
+
+ Returns:
+ DriverData: 驱动数据对象
+ """
+ logger.debug("创建 UITest 驱动")
resp: HypiumResponse = self.invoke("Driver.create") # {"result":"Driver#0"}
hdriver: DriverData = DriverData(resp.result)
return hdriver
class _UITestService:
+ """
+ UITest 服务管理类
+
+ 负责初始化设备上的 UITest 服务,包括安装必要的库文件和启动服务进程
+ """
+
def __init__(self, hdc: HdcWrapper):
- """Initialize the UITestService class."""
+ """
+ 初始化 UITest 服务管理类
+
+ Args:
+ hdc: HDC 包装器实例
+ """
self.hdc = hdc
+ self._remote_agent_path = "/data/local/tmp/agent.so"
- def init(self):
+ def init(self) -> None:
"""
- Initialize the UITest service:
- 1. Ensure agent.so is set up on the device.
- 2. Start the UITest daemon.
-
- 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"
+ 初始化 UITest 服务
+
+ 1. 确保设备上安装了 agent.so
+ 2. 启动 UITest 守护进程
+
+ Note:
+ 'hdc shell aa test' 也会启动 UITest 守护进程
+ $ 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"
"""
-
- logger.debug("Initializing UITest service")
+ logger.debug("初始化 UITest 服务")
local_path = self._get_local_agent_path()
- remote_path = "/data/local/tmp/agent.so"
- self._kill_uitest_service() # Stop the service if running
- self._setup_device_agent(local_path, remote_path)
+ # 按顺序执行初始化步骤
+ self._kill_uitest_service() # 停止可能运行的服务
+ self._setup_device_agent(local_path, self._remote_agent_path)
self._start_uitest_daemon()
- time.sleep(0.5)
+ time.sleep(0.5) # 等待服务启动
def _get_local_agent_path(self) -> str:
- """Return the local path of the agent file."""
- target_agent = "uitest_agent_v1.1.0.so"
+ """
+ 获取本地 agent.so 文件路径
+
+ 根据设备 CPU 架构选择对应的库文件
+
+ Returns:
+ str: 本地 agent.so 文件路径
+ """
+ cpu_abi = self.hdc.cpu_abi()
+ target_agent = os.path.join("so", cpu_abi, "agent.so")
return os.path.join(os.path.dirname(os.path.realpath(__file__)), "assets", target_agent)
def _get_remote_md5sum(self, file_path: str) -> Optional[str]:
- """Get the MD5 checksum of a remote file."""
+ """
+ 获取远程文件的 MD5 校验和
+
+ Args:
+ file_path: 远程文件路径
+
+ Returns:
+ Optional[str]: MD5 校验和,如果文件不存在则返回 None
+ """
command = f"md5sum {file_path}"
output = self.hdc.shell(command).output.strip()
return output.split()[0] if output else None
def _get_local_md5sum(self, file_path: str) -> str:
- """Get the MD5 checksum of a local file."""
+ """
+ 获取本地文件的 MD5 校验和
+
+ Args:
+ file_path: 本地文件路径
+
+ Returns:
+ str: MD5 校验和
+ """
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
@@ -211,27 +446,50 @@ def _get_local_md5sum(self, file_path: str) -> str:
return hash_md5.hexdigest()
def _is_remote_file_exists(self, file_path: str) -> bool:
- """Check if a file exists on the device."""
+ """
+ 检查远程文件是否存在
+
+ Args:
+ file_path: 远程文件路径
+
+ Returns:
+ bool: 文件存在返回 True,否则返回 False
+ """
command = f"[ -f {file_path} ] && echo 'exists' || echo 'not exists'"
result = self.hdc.shell(command).output.strip()
return "exists" in result
- def _setup_device_agent(self, local_path: str, remote_path: str):
- """Ensure the remote agent file is correctly set up."""
+ def _setup_device_agent(self, local_path: str, remote_path: str) -> None:
+ """
+ 设置设备上的 agent.so 文件
+
+ 如果远程文件不存在或与本地文件不一致,则上传本地文件
+
+ Args:
+ local_path: 本地文件路径
+ remote_path: 远程文件路径
+ """
+ # 检查远程文件是否存在且与本地文件一致
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:
- logger.debug("Remote agent file is up-to-date")
- self.hdc.shell(f"chmod +x {remote_path}")
+ logger.debug("远程 agent 文件已是最新")
return
self.hdc.shell(f"rm {remote_path}")
+ # 上传并设置权限
self.hdc.send_file(local_path, remote_path)
self.hdc.shell(f"chmod +x {remote_path}")
- logger.debug("Updated remote agent file")
+ logger.debug("已更新远程 agent 文件")
- def _get_uitest_pid(self) -> typing.List[str]:
+ def _get_uitest_pid(self) -> List[str]:
+ """
+ 获取 UITest 守护进程的 PID 列表
+
+ Returns:
+ List[str]: PID 列表
+ """
proc_pids = []
result = self.hdc.shell("ps -ef").output.strip()
lines = result.splitlines()
@@ -242,12 +500,13 @@ def _get_uitest_pid(self) -> typing.List[str]:
proc_pids.append(line.split()[1])
return proc_pids
- def _kill_uitest_service(self):
+ def _kill_uitest_service(self) -> None:
+ """终止所有 UITest 守护进程"""
for pid in self._get_uitest_pid():
self.hdc.shell(f"kill -9 {pid}")
- logger.debug(f"Killed uitest process with PID {pid}")
+ logger.debug(f"已终止 UITest 进程,PID: {pid}")
- def _start_uitest_daemon(self):
- """Start the UITest daemon."""
+ def _start_uitest_daemon(self) -> None:
+ """启动 UITest 守护进程"""
self.hdc.shell("uitest start-daemon singleness")
- logger.debug("Started UITest daemon")
\ No newline at end of file
+ logger.debug("已启动 UITest 守护进程")
diff --git a/hmdriver2/_gesture.py b/hmdriver2/_gesture.py
index d81d6b5..4af6602 100644
--- a/hmdriver2/_gesture.py
+++ b/hmdriver2/_gesture.py
@@ -1,26 +1,40 @@
# -*- coding: utf-8 -*-
import math
-from typing import List, Union
+from typing import List, Union, Optional, Tuple, Callable, Any
+
from . import logger
-from .utils import delay
from .driver import Driver
-from .proto import HypiumResponse, Point
from .exception import InjectGestureError
+from .proto import HypiumResponse, Point
+from .utils import delay
+
+# 手势采样时间常量(毫秒)
+SAMPLE_TIME_MIN = 10 # 最小采样时间
+SAMPLE_TIME_NORMAL = 50 # 正常采样时间
+SAMPLE_TIME_MAX = 100 # 最大采样时间
+
+# 手势步骤类型常量
+STEP_TYPE_START = "start" # 开始手势
+STEP_TYPE_MOVE = "move" # 移动手势
+STEP_TYPE_PAUSE = "pause" # 暂停手势
class _Gesture:
- SAMPLE_TIME_MIN = 10
- SAMPLE_TIME_NORMAL = 50
- SAMPLE_TIME_MAX = 100
+ """
+ 手势操作类
+
+ 提供了创建和执行复杂手势操作的功能,包括点击、滑动、暂停等。
+ 通过链式调用可以组合多个手势步骤。
+ """
- def __init__(self, d: Driver, sampling_ms=50):
+ def __init__(self, d: Driver, sampling_ms: int = SAMPLE_TIME_NORMAL):
"""
- Initialize a gesture object.
-
+ 初始化手势对象
+
Args:
- d (Driver): The driver object to interact with.
- sampling_ms (int): Sampling time for gesture operation points in milliseconds. Default is 50.
+ d: Driver 实例,用于与设备交互
+ sampling_ms: 手势操作点的采样时间(毫秒),默认为 50
"""
self.d = d
self.steps: List[GestureStep] = []
@@ -28,74 +42,86 @@ def __init__(self, d: Driver, sampling_ms=50):
def _validate_sampling_time(self, sampling_time: int) -> int:
"""
- Validate the input sampling time.
-
+ 验证采样时间是否在有效范围内
+
Args:
- sampling_time (int): The given sampling time.
-
+ sampling_time: 给定的采样时间
+
Returns:
- int: Valid sampling time within allowed range.
+ int: 有效范围内的采样时间
"""
- if _Gesture.SAMPLE_TIME_MIN <= sampling_time <= _Gesture.SAMPLE_TIME_MAX:
+ if SAMPLE_TIME_MIN <= sampling_time <= SAMPLE_TIME_MAX:
return sampling_time
- return _Gesture.SAMPLE_TIME_NORMAL
+ return SAMPLE_TIME_NORMAL
- def _release(self):
+ def _release(self) -> None:
+ """清空手势步骤列表"""
self.steps = []
def start(self, x: Union[int, float], y: Union[int, float], interval: float = 0.5) -> '_Gesture':
"""
- Start gesture operation.
-
+ 开始手势操作
+
Args:
- x: oordinate as a percentage or absolute value.
- y: coordinate as a percentage or absolute value.
- interval (float, optional): Duration to hold at start position in seconds. Default is 0.5.
-
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+ interval: 在起始位置停留的时间(秒),默认为 0.5
+
Returns:
- Gesture: Self instance to allow method chaining.
+ _Gesture: 当前实例,支持链式调用
+
+ Raises:
+ InjectGestureError: 手势已经开始时抛出
"""
self._ensure_can_start()
- self._add_step(x, y, "start", interval)
+ self._add_step(x, y, STEP_TYPE_START, interval)
return self
def move(self, x: Union[int, float], y: Union[int, float], interval: float = 0.5) -> '_Gesture':
"""
- Move to specified position.
-
+ 移动到指定位置
+
Args:
- x: coordinate as a percentage or absolute value.
- y: coordinate as a percentage or absolute value.
- interval (float, optional): Duration of move in seconds. Default is 0.5.
-
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+ interval: 移动的持续时间(秒),默认为 0.5
+
Returns:
- Gesture: Self instance to allow method chaining.
+ _Gesture: 当前实例,支持链式调用
+
+ Raises:
+ InjectGestureError: 手势未开始时抛出
"""
self._ensure_started()
- self._add_step(x, y, "move", interval)
+ self._add_step(x, y, STEP_TYPE_MOVE, interval)
return self
def pause(self, interval: float = 1) -> '_Gesture':
"""
- Pause at current position for specified duration.
-
+ 在当前位置暂停指定时间
+
Args:
- interval (float, optional): Duration to pause in seconds. Default is 1.
-
+ interval: 暂停时间(秒),默认为 1
+
Returns:
- Gesture: Self instance to allow method chaining.
+ _Gesture: 当前实例,支持链式调用
+
+ Raises:
+ InjectGestureError: 手势未开始时抛出
"""
self._ensure_started()
pos = self.steps[-1].pos
- self.steps.append(GestureStep(pos, "pause", interval))
+ self.steps.append(GestureStep(pos, STEP_TYPE_PAUSE, interval))
return self
@delay
- def action(self):
+ def action(self) -> None:
"""
- Execute the gesture action.
+ 执行手势操作
+
+ 该方法会将所有已定义的手势步骤转换为触摸事件并发送到设备
"""
- logger.info(f">>>Gesture steps: {self.steps}")
+ logger.info(f">>>执行手势步骤: {self.steps}")
total_points = self._calculate_total_points()
pointer_matrix = self._create_pointer_matrix(total_points)
@@ -105,76 +131,82 @@ def action(self):
self._release()
- def _create_pointer_matrix(self, total_points: int):
+ def _create_pointer_matrix(self, total_points: int) -> Any:
"""
- Create a pointer matrix for the gesture.
-
+ 创建手势操作的指针矩阵
+
Args:
- total_points (int): Total number of points.
-
+ total_points: 总点数
+
Returns:
- PointerMatrix: Pointer matrix object.
+ Any: 指针矩阵对象
"""
- fingers = 1
+ fingers = 1 # 当前仅支持单指操作
api = "PointerMatrix.create"
data: HypiumResponse = self.d._client.invoke(api, this=None, args=[fingers, total_points])
return data.result
- def _inject_pointer_actions(self, pointer_matrix):
+ def _inject_pointer_actions(self, pointer_matrix: Any) -> None:
"""
- Inject pointer actions into the driver.
-
+ 将指针操作注入到设备
+
Args:
- pointer_matrix (PointerMatrix): Pointer matrix to inject.
+ pointer_matrix: 要注入的指针矩阵
"""
api = "Driver.injectMultiPointerAction"
self.d._client.invoke(api, args=[pointer_matrix, 2000])
- def _add_step(self, x: int, y: int, step_type: str, interval: float):
+ def _add_step(self, x: Union[int, float], y: Union[int, float], step_type: str, interval: float) -> None:
"""
- Add a step to the gesture.
-
+ 添加手势步骤
+
Args:
- x (int): x-coordinate of the point.
- y (int): y-coordinate of the point.
- step_type (str): Type of step ("start", "move", or "pause").
- interval (float): Interval duration in seconds.
+ x: X 坐标
+ y: Y 坐标
+ step_type: 步骤类型("start"、"move" 或 "pause")
+ interval: 时间间隔(秒)
"""
point: Point = self.d._to_abs_pos(x, y)
step = GestureStep(point.to_tuple(), step_type, interval)
self.steps.append(step)
- def _ensure_can_start(self):
+ def _ensure_can_start(self) -> None:
"""
- Ensure that the gesture can start.
+ 确保手势可以开始
+
+ Raises:
+ InjectGestureError: 手势已经开始时抛出
"""
if self.steps:
- raise InjectGestureError("Can't start gesture twice")
+ raise InjectGestureError("不能重复开始手势")
- def _ensure_started(self):
+ def _ensure_started(self) -> None:
"""
- Ensure that the gesture has started.
+ 确保手势已经开始
+
+ Raises:
+ InjectGestureError: 手势未开始时抛出
"""
if not self.steps:
- raise InjectGestureError("Please call gesture.start first")
+ raise InjectGestureError("请先调用 gesture.start")
- def _generate_points(self, pointer_matrix, total_points):
+ def _generate_points(self, pointer_matrix: Any, total_points: int) -> None:
"""
- Generate points for the pointer matrix.
-
+ 为指针矩阵生成点
+
Args:
- pointer_matrix (PointerMatrix): Pointer matrix to populate.
- total_points (int): Total points to generate.
+ pointer_matrix: 要填充的指针矩阵
+ total_points: 要生成的总点数
"""
-
- def set_point(point_index: int, point: Point, interval: int = None):
+ # 定义设置点的内部函数
+ def set_point(point_index: int, point: Point, interval: Optional[int] = None) -> None:
"""
- Set a point in the pointer matrix.
-
+ 在指针矩阵中设置点
+
Args:
- point_index (int): Index of the point.
- point (Point): The point object.
- interval (int, optional): Interval duration.
+ point_index: 点的索引
+ point: 点对象
+ interval: 时间间隔(可选)
"""
if interval is not None:
point.x += 65536 * interval
@@ -183,30 +215,33 @@ def set_point(point_index: int, point: Point, interval: int = None):
point_index = 0
+ # 处理所有手势步骤
for index, step in enumerate(self.steps):
- if step.type == "start":
+ if step.type == STEP_TYPE_START:
point_index = self._generate_start_point(step, point_index, set_point)
- elif step.type == "move":
+ elif step.type == STEP_TYPE_MOVE:
point_index = self._generate_move_points(index, step, point_index, set_point)
- elif step.type == "pause":
+ elif step.type == STEP_TYPE_PAUSE:
point_index = self._generate_pause_points(step, point_index, set_point)
+ # 填充剩余点
step = self.steps[-1]
while point_index < total_points:
set_point(point_index, Point(*step.pos))
point_index += 1
- def _generate_start_point(self, step, point_index, set_point):
+ def _generate_start_point(self, step: 'GestureStep', point_index: int,
+ set_point: Callable) -> int:
"""
- Generate start points.
-
+ 生成起始点
+
Args:
- step (GestureStep): Gesture step.
- point_index (int): Current point index.
- set_point (function): Function to set the point in pointer matrix.
-
+ step: 手势步骤
+ point_index: 当前点索引
+ set_point: 设置点的函数
+
Returns:
- int: Updated point index.
+ int: 更新后的点索引
"""
set_point(point_index, Point(*step.pos), step.interval)
point_index += 1
@@ -214,18 +249,19 @@ def _generate_start_point(self, step, point_index, set_point):
set_point(point_index, Point(*pos))
return point_index + 1
- def _generate_move_points(self, index, step, point_index, set_point):
+ def _generate_move_points(self, index: int, step: 'GestureStep',
+ point_index: int, set_point: Callable) -> int:
"""
- Generate move points.
-
+ 生成移动点
+
Args:
- index (int): Step index.
- step (GestureStep): Gesture step.
- point_index (int): Current point index.
- set_point (function): Function to set the point in pointer matrix.
-
+ index: 步骤索引
+ step: 手势步骤
+ point_index: 当前点索引
+ set_point: 设置点的函数
+
Returns:
- int: Updated point index.
+ int: 更新后的点索引
"""
last_step = self.steps[index - 1]
offset_x = step.pos[0] - last_step.pos[0]
@@ -234,6 +270,10 @@ def _generate_move_points(self, index, step, point_index, set_point):
interval_ms = step.interval
cur_steps = self._calculate_move_step_points(distance, interval_ms)
+ # 避免除零错误
+ if cur_steps <= 0:
+ cur_steps = 1
+
step_x = int(offset_x / cur_steps)
step_y = int(offset_y / cur_steps)
@@ -246,55 +286,57 @@ def _generate_move_points(self, index, step, point_index, set_point):
point_index += 1
return point_index
- def _generate_pause_points(self, step, point_index, set_point):
+ def _generate_pause_points(self, step: 'GestureStep', point_index: int,
+ set_point: Callable) -> int:
"""
- Generate pause points.
-
+ 生成暂停点
+
Args:
- step (GestureStep): Gesture step.
- point_index (int): Current point index.
- set_point (function): Function to set the point in pointer matrix.
-
+ step: 手势步骤
+ point_index: 当前点索引
+ set_point: 设置点的函数
+
Returns:
- int: Updated point index.
+ int: 更新后的点索引
"""
- points = int(step.interval / self.sampling_ms)
+ # 计算需要的点数
+ points = max(1, int(step.interval / self.sampling_ms))
for _ in range(points):
- set_point(point_index, Point(*step.pos), int(step.interval / self.sampling_ms))
+ set_point(point_index, Point(*step.pos), int(step.interval / points))
point_index += 1
- pos = step.pos[0] + 3, step.pos[1]
+ pos = step.pos[0] + 3, step.pos[1] # 微小移动以触发事件
set_point(point_index, Point(*pos))
return point_index + 1
def _calculate_total_points(self) -> int:
"""
- Calculate the total number of points needed for the gesture.
-
+ 计算手势所需的总点数
+
Returns:
- int: Total points.
+ int: 总点数
"""
total_points = 0
for index, step in enumerate(self.steps):
- if step.type == "start":
+ if step.type == STEP_TYPE_START:
total_points += 2
- elif step.type == "move":
- total_points += self._calculate_move_step_points(
- *self._calculate_move_distance(step, index))
- elif step.type == "pause":
- points = int(step.interval / self.sampling_ms)
+ elif step.type == STEP_TYPE_MOVE:
+ distance, interval_ms = self._calculate_move_distance(step, index)
+ total_points += self._calculate_move_step_points(distance, interval_ms)
+ elif step.type == STEP_TYPE_PAUSE:
+ points = max(1, int(step.interval / self.sampling_ms))
total_points += points + 1
return total_points
- def _calculate_move_distance(self, step, index):
+ def _calculate_move_distance(self, step: 'GestureStep', index: int) -> Tuple[int, float]:
"""
- Calculate move distance and interval.
-
+ 计算移动距离和时间间隔
+
Args:
- step (GestureStep): Gesture step.
- index (int): Step index.
-
+ step: 手势步骤
+ index: 步骤索引
+
Returns:
- tuple: Tuple (distance, interval_ms).
+ Tuple[int, float]: (距离, 时间间隔(毫秒))
"""
last_step = self.steps[index - 1]
offset_x = step.pos[0] - last_step.pos[0]
@@ -305,39 +347,45 @@ def _calculate_move_distance(self, step, index):
def _calculate_move_step_points(self, distance: int, interval_ms: float) -> int:
"""
- Calculate the number of move step points based on distance and time.
-
+ 根据距离和时间计算移动步骤点数
+
Args:
- distance (int): Distance to move.
- interval_ms (float): Move duration in milliseconds.
-
+ distance: 移动距离
+ interval_ms: 移动持续时间(毫秒)
+
Returns:
- int: Number of move step points.
+ int: 移动步骤点数
"""
if interval_ms < self.sampling_ms or distance < 1:
return 1
nums = interval_ms / self.sampling_ms
- return distance if nums > distance else int(nums)
+ return min(distance, int(nums))
class GestureStep:
- """Class to store each step of a gesture, not to be used directly, use via Gesture class"""
+ """
+ 手势步骤类
+
+ 存储手势的每个步骤,不直接使用,通过 Gesture 类使用
+ """
- def __init__(self, pos: tuple, step_type: str, interval: float):
+ def __init__(self, pos: Tuple[int, int], step_type: str, interval: float):
"""
- Initialize a gesture step.
-
+ 初始化手势步骤
+
Args:
- pos (tuple): Tuple containing x and y coordinates.
- step_type (str): Type of step ("start", "move", "pause").
- interval (float): Interval duration in seconds.
+ pos: 包含 x 和 y 坐标的元组
+ step_type: 步骤类型("start"、"move"、"pause")
+ interval: 时间间隔(秒)
"""
self.pos = pos[0], pos[1]
- self.interval = int(interval * 1000)
+ self.interval = int(interval * 1000) # 转换为毫秒
self.type = step_type
- def __repr__(self):
+ def __repr__(self) -> str:
+ """返回手势步骤的字符串表示"""
return f"GestureStep(pos=({self.pos[0]}, {self.pos[1]}), type='{self.type}', interval={self.interval})"
- def __str__(self):
+ def __str__(self) -> str:
+ """返回手势步骤的字符串表示"""
return self.__repr__()
\ No newline at end of file
diff --git a/hmdriver2/_screenrecord.py b/hmdriver2/_screenrecord.py
index 6207d69..b8f2170 100644
--- a/hmdriver2/_screenrecord.py
+++ b/hmdriver2/_screenrecord.py
@@ -1,36 +1,70 @@
# -*- coding: utf-8 -*-
-import typing
-import threading
-import numpy as np
import queue
+import threading
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.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,74 +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():
try:
- jpeg_image = self.jpeg_queue.get(timeout=0.1)
+ # 从队列获取 JPEG 图像
+ jpeg_image = self.jpeg_queue.get(timeout=QUEUE_TIMEOUT)
img = cv2.imdecode(np.frombuffer(jpeg_image, np.uint8), cv2.IMREAD_COLOR)
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(*'mp4v')
- cv2_instance = cv2.VideoWriter(self.video_path, fourcc, 10, (width, height))
+ 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..9769d66 100644
--- a/hmdriver2/_swipe.py
+++ b/hmdriver2/_swipe.py
@@ -1,35 +1,69 @@
# -*- coding: utf-8 -*-
-from typing import Union, Tuple
+from typing import Union, Tuple, Optional, Literal
from .driver import Driver
-from .proto import SwipeDirection
+from .proto import SwipeDirection, Point
-class SwipeExt(object):
+# 滑动方向的字符串类型
+SwipeDirectionStr = Literal["left", "right", "up", "down"]
+
+# 默认滑动速度(像素/秒)
+DEFAULT_SWIPE_SPEED = 2000
+# 速度限制
+MIN_SWIPE_SPEED = 200
+MAX_SWIPE_SPEED = 40000
+
+
+class SwipeExt:
+ """
+ 扩展滑动功能类
+
+ 提供了更灵活的滑动操作,支持方向、比例和区域定制
+ """
+
def __init__(self, d: Driver):
+ """
+ 初始化滑动扩展功能
+
+ Args:
+ d: Driver 实例
+ """
self._d = d
- def __call__(self,
- direction: Union[SwipeDirection, str],
- scale: float = 0.8,
- box: Union[Tuple, None] = None,
- speed=2000):
+ def __call__(
+ self,
+ direction: Union[SwipeDirection, SwipeDirectionStr],
+ scale: float = 0.8,
+ box: Optional[Tuple[int, int, int, int]] = None,
+ speed: int = DEFAULT_SWIPE_SPEED
+ ) -> None:
"""
+ 执行滑动操作
+
Args:
- direction (str): one of "left", "right", "up", "bottom" or SwipeDirection.LEFT
- scale (float): percent of swipe, range (0, 1.0]
- box (Tuple): None or (x1, x1, y1, x2, y2)
- 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.
+ direction: 滑动方向,可以是 "left", "right", "up", "down" 或 SwipeDirection 枚举
+ scale: 滑动比例,范围 (0, 1.0],表示滑动距离占可滑动区域的比例
+ box: 滑动区域,格式为 (x1, y1, x2, y2),默认为全屏
+ speed: 滑动速度(像素/秒),默认为 2000,有效范围 200-40000
+
Raises:
- ValueError
+ ValueError: 参数无效时抛出
"""
- def _swipe(_from, _to):
+ def _swipe(_from: Tuple[int, int], _to: Tuple[int, int]) -> None:
+ """执行从一点到另一点的滑动"""
self._d.swipe(_from[0], _from[1], _to[0], _to[1], speed=speed)
+ # 验证 scale 参数
if scale <= 0 or scale > 1.0 or not isinstance(scale, (float, int)):
raise ValueError("scale must be in range (0, 1.0]")
+ # 验证 speed 参数
+ if speed < MIN_SWIPE_SPEED or speed > MAX_SWIPE_SPEED:
+ speed = DEFAULT_SWIPE_SPEED
+
+ # 确定滑动区域
if box:
x1, y1, x2, y2 = self._validate_and_convert_box(box)
else:
@@ -38,47 +72,61 @@ def _swipe(_from, _to):
width, height = x2 - x1, y2 - y1
+ # 计算偏移量,确保滑动在边缘留有间距
h_offset = int(width * (1 - scale) / 2)
v_offset = int(height * (1 - scale) / 2)
- if direction == SwipeDirection.LEFT:
+ # 根据方向确定滑动的起点和终点
+ if direction in [SwipeDirection.LEFT, "left"]:
start = (x2 - h_offset, y1 + height // 2)
end = (x1 + h_offset, y1 + height // 2)
- elif direction == SwipeDirection.RIGHT:
+ elif direction in [SwipeDirection.RIGHT, "right"]:
start = (x1 + h_offset, y1 + height // 2)
end = (x2 - h_offset, y1 + height // 2)
- elif direction == SwipeDirection.UP:
+ elif direction in [SwipeDirection.UP, "up"]:
start = (x1 + width // 2, y2 - v_offset)
end = (x1 + width // 2, y1 + v_offset)
- elif direction == SwipeDirection.DOWN:
+ elif direction in [SwipeDirection.DOWN, "down"]:
start = (x1 + width // 2, y1 + v_offset)
end = (x1 + width // 2, y2 - v_offset)
else:
- raise ValueError("Unknown SwipeDirection:", direction)
+ raise ValueError(f"Unknown SwipeDirection: {direction}")
+ # 执行滑动
_swipe(start, end)
def _validate_and_convert_box(self, box: Tuple) -> Tuple[int, int, int, int]:
"""
- Validate and convert the box coordinates if necessay.
-
+ 验证并转换区域坐标
+
Args:
- box (Tuple): The box coordinates as a tuple (x1, y1, x2, y2).
-
+ box: 区域坐标元组 (x1, y1, x2, y2)
+
Returns:
- Tuple[int, int, int, int]: The validated and converted box coordinates.
+ Tuple[int, int, int, int]: 验证并转换后的区域坐标
+
+ Raises:
+ ValueError: 坐标无效时抛出
"""
+ # 验证元组长度
if not isinstance(box, tuple) or len(box) != 4:
raise ValueError("Box must be a tuple of length 4.")
+
x1, y1, x2, y2 = box
+
+ # 验证坐标值
+ if not all(isinstance(coord, (int, float)) for coord in box):
+ raise ValueError("All coordinates must be numeric.")
+
+ # 验证坐标范围
if not (x1 >= 0 and y1 >= 0 and x2 > 0 and y2 > 0):
raise ValueError("Box coordinates must be greater than 0.")
+
+ # 验证坐标关系
if not (x1 < x2 and y1 < y2):
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)
- x1, y1, x2, y2 = p1.x, p1.y, p2.x, p2.y
-
- return x1, y1, x2, y2
+ return p1.x, p1.y, p2.x, p2.y
diff --git a/hmdriver2/_uiobject.py b/hmdriver2/_uiobject.py
index 31f58d0..b148305 100644
--- a/hmdriver2/_uiobject.py
+++ b/hmdriver2/_uiobject.py
@@ -2,98 +2,212 @@
import enum
import time
-from typing import List, Union
+from typing import List, Optional, Any
from . import logger
-from .utils import delay
from ._client import HmClient
from .exception import ElementNotFoundError
from .proto import ComponentData, ByData, HypiumResponse, Point, Bounds, ElementInfo
+from .utils import delay
+
+
+# 定义本地的匹配模式枚举
+class Match(enum.Enum):
+ """
+ 匹配模式枚举
+
+ 用于指定查找元素时的匹配方式
+ """
+ EQ = 0 # 完全匹配 (Equals)
+ IN = 1 # 包含匹配 (Contains)
+ SW = 2 # 开头匹配 (Starts With)
+ EW = 3 # 结尾匹配 (Ends With)
+ RE = 4 # 正则匹配 (Regexp)
+ REI = 5 # 忽略大小写的正则匹配 (Regexp Ignore case)
+
class ByType(enum.Enum):
- id = "id"
- key = "key"
- text = "text"
- type = "type"
- description = "description"
- clickable = "clickable"
- longClickable = "longClickable"
- scrollable = "scrollable"
- enabled = "enabled"
- focused = "focused"
- selected = "selected"
- checked = "checked"
- checkable = "checkable"
- isBefore = "isBefore"
- isAfter = "isAfter"
+ """
+ UI 元素查找类型枚举
+
+ 定义了可用于查找 UI 元素的属性类型
+ """
+ id = "id" # 元素 ID
+ key = "key" # 元素键值
+ text = "text" # 元素文本
+ type = "type" # 元素类型
+ description = "description" # 元素描述
+ clickable = "clickable" # 是否可点击
+ longClickable = "longClickable" # 是否可长按
+ scrollable = "scrollable" # 是否可滚动
+ enabled = "enabled" # 是否启用
+ focused = "focused" # 是否获得焦点
+ selected = "selected" # 是否被选中
+ checked = "checked" # 是否被勾选
+ checkable = "checkable" # 是否可勾选
+ isBefore = "isBefore" # 是否在指定元素之前
+ isAfter = "isAfter" # 是否在指定元素之后
@classmethod
- def verify(cls, value):
+ def verify(cls, value: str) -> bool:
+ """
+ 验证属性类型是否有效
+
+ Args:
+ value: 要验证的属性类型
+
+ Returns:
+ bool: 属性类型有效返回 True,否则返回 False
+ """
return any(value == item.value for item in cls)
class UiObject:
+ """
+ UI 对象类,用于查找和操作 UI 元素
+
+ 提供了元素查找、属性获取和操作执行的功能
+ """
+
+ # 默认超时时间(秒)
DEFAULT_TIMEOUT = 2
def __init__(self, client: HmClient, **kwargs) -> None:
+ """
+ 初始化 UI 对象
+
+ Args:
+ client: HmClient 实例
+ **kwargs: 查找元素的条件,支持 Match 参数
+ 例如: text="搜索", match=Match.RE
+ 或者: text=("app_.*", Match.RE)
+ """
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._match = kwargs.pop("match", Match.EQ)
+
+ # 处理查找条件,支持 (值, 匹配模式) 的元组格式
+ self._kwargs = {}
+ self._match_patterns = {}
+
+ for k, v in kwargs.items():
+ if isinstance(v, tuple) and len(v) == 2:
+ # 支持 text=("搜索", Match.RE) 格式
+ self._kwargs[k] = v[0]
+ self._match_patterns[k] = v[1]
+ else:
+ # 普通格式,使用默认或全局匹配模式
+ self._kwargs[k] = v
+ self._match_patterns[k] = self._match
+
self.__verify()
- self._component: Union[ComponentData, None] = None # cache
+ self._component: Optional[ComponentData] = None # 缓存找到的组件
def __str__(self) -> str:
- return f"UiObject [{self._raw_kwargs}"
-
- def __verify(self):
+ """返回 UiObject 的字符串表示"""
+ return f"UiObject {self._raw_kwargs}"
+
+ def __verify(self) -> None:
+ """
+ 验证查找条件是否有效
+
+ Raises:
+ ReferenceError: 查找条件无效时抛出
+ """
for k, v in self._kwargs.items():
if not ByType.verify(k):
- raise ReferenceError(f"{k} is not allowed.")
+ raise ReferenceError(f"{k} 不是有效的查找条件")
@property
def count(self) -> int:
- eleements = self.__find_components()
- return len(eleements) if eleements else 0
-
- def __len__(self):
+ """
+ 获取匹配条件的元素数量
+
+ Returns:
+ int: 元素数量
+ """
+ elements = self.__find_components()
+ return len(elements) if elements else 0
+
+ def __len__(self) -> int:
+ """支持使用 len() 函数获取元素数量"""
return self.count
- def exists(self, retries: int = 2, wait_time=1) -> bool:
+ def exists(self, retries: int = 2, wait_time: float = 1) -> bool:
+ """
+ 检查元素是否存在
+
+ Args:
+ retries: 重试次数,默认为 2
+ wait_time: 重试间隔时间(秒),默认为 1
+
+ Returns:
+ bool: 元素存在返回 True,否则返回 False
+ """
obj = self.find_component(retries, wait_time)
- return True if obj else False
-
- def __set_component(self, component: ComponentData):
+ return obj is not None
+
+ def __set_component(self, component: ComponentData) -> None:
+ """
+ 设置找到的组件
+
+ Args:
+ component: 组件数据
+ """
self._component = component
- def find_component(self, retries: int = 1, wait_time=1) -> ComponentData:
+ def find_component(self, retries: int = 1, wait_time: float = 1) -> Optional[ComponentData]:
+ """
+ 查找匹配条件的组件
+
+ Args:
+ retries: 重试次数,默认为 1
+ wait_time: 重试间隔时间(秒),默认为 1
+
+ Returns:
+ Optional[ComponentData]: 找到的组件,未找到返回 None
+ """
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:
+ if attempt < retries - 1:
time.sleep(wait_time)
- logger.info(f"Retry found element {self}")
+ logger.info(f"重试查找元素 {self}")
return None
- # useless
- def __find_component(self) -> Union[ComponentData, None]:
+ def __find_component(self) -> Optional[ComponentData]:
+ """
+ 查找单个匹配条件的组件
+
+ 该方法直接调用 Driver.findComponent API 查找单个组件
+
+ Returns:
+ Optional[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]:
+ def __find_components(self) -> Optional[List[ComponentData]]:
+ """
+ 查找所有匹配条件的组件
+
+ Returns:
+ Optional[List[ComponentData]]: 找到的组件列表,未找到返回 None
+ """
by: ByData = self.__get_by()
resp: HypiumResponse = self._client.invoke("Driver.findComponents", args=[by.value])
if not resp.result:
@@ -105,92 +219,153 @@ def __find_components(self) -> Union[List[ComponentData], None]:
return components
def __get_by(self) -> ByData:
+ """
+ 构建查找条件
+
+ Returns:
+ ByData: 查找条件对象
+ """
+ this = "On#seed"
+
+ # 处理所有查找条件,支持 Match
for k, v in self._kwargs.items():
api = f"On.{k}"
- this = "On#seed"
- resp: HypiumResponse = self._client.invoke(api, this, args=[v])
+ match_pattern = self._match_patterns.get(k, Match.EQ)
+
+ # 构建参数:[值, 匹配模式值]
+ args = [v, match_pattern.value]
+ resp: HypiumResponse = self._client.invoke(api, this=this, args=args)
this = resp.result
+ # 处理位置关系
if self._isBefore:
- resp: HypiumResponse = self._client.invoke("On.isBefore", this="On#seed", args=[resp.result])
+ resp: HypiumResponse = self._client.invoke("On.isBefore", this=this, args=[resp.result])
if self._isAfter:
- resp: HypiumResponse = self._client.invoke("On.isAfter", this="On#seed", args=[resp.result])
+ resp: HypiumResponse = self._client.invoke("On.isAfter", this=this, args=[resp.result])
return ByData(resp.result)
- def __operate(self, api, args=[], retries: int = 2):
+ def __operate(self, api: str, args: Optional[List[Any]] = None, retries: int = 2) -> Any:
+ """
+ 对元素执行操作
+
+ Args:
+ api: 要调用的 API
+ args: API 参数,默认为空列表
+ retries: 重试次数,默认为 2
+
+ Returns:
+ Any: API 调用结果
+
+ Raises:
+ ElementNotFoundError: 元素未找到时抛出
+ """
+ if args is None:
+ args = []
+
if not self._component:
if not self.find_component(retries):
- raise ElementNotFoundError(f"Element({self}) not found after {retries} retries")
+ raise ElementNotFoundError(f"未找到元素({self}),重试 {retries} 次后失败")
resp: HypiumResponse = self._client.invoke(api, this=self._component.value, args=args)
return resp.result
@property
def id(self) -> str:
+ """元素 ID"""
return self.__operate("Component.getId")
@property
def key(self) -> str:
+ """元素键值"""
return self.__operate("Component.getId")
@property
def type(self) -> str:
+ """元素类型"""
return self.__operate("Component.getType")
@property
def text(self) -> str:
+ """元素文本"""
return self.__operate("Component.getText")
@property
def description(self) -> str:
+ """元素描述"""
return self.__operate("Component.getDescription")
@property
def isSelected(self) -> bool:
+ """元素是否被选中"""
return self.__operate("Component.isSelected")
@property
def isChecked(self) -> bool:
+ """元素是否被勾选"""
return self.__operate("Component.isChecked")
@property
def isEnabled(self) -> bool:
+ """元素是否启用"""
return self.__operate("Component.isEnabled")
@property
def isFocused(self) -> bool:
+ """元素是否获得焦点"""
return self.__operate("Component.isFocused")
@property
def isCheckable(self) -> bool:
+ """元素是否可勾选"""
return self.__operate("Component.isCheckable")
@property
def isClickable(self) -> bool:
+ """元素是否可点击"""
return self.__operate("Component.isClickable")
@property
def isLongClickable(self) -> bool:
+ """元素是否可长按"""
return self.__operate("Component.isLongClickable")
@property
def isScrollable(self) -> bool:
+ """元素是否可滚动"""
return self.__operate("Component.isScrollable")
@property
def bounds(self) -> Bounds:
+ """
+ 元素边界
+
+ Returns:
+ Bounds: 元素边界对象
+ """
_raw = self.__operate("Component.getBounds")
return Bounds(**_raw)
@property
def boundsCenter(self) -> Point:
+ """
+ 元素中心点坐标
+
+ Returns:
+ Point: 元素中心点坐标对象
+ """
_raw = self.__operate("Component.getBoundsCenter")
return Point(**_raw)
@property
def info(self) -> ElementInfo:
+ """
+ 获取元素的完整信息
+
+ Returns:
+ ElementInfo: 包含元素所有属性的信息对象
+ """
return ElementInfo(
id=self.id,
key=self.key,
@@ -209,40 +384,108 @@ def info(self) -> ElementInfo:
boundsCenter=self.boundsCenter)
@delay
- def click(self):
+ def click(self) -> Any:
+ """
+ 点击元素
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.click")
@delay
- def click_if_exists(self):
+ def click_if_exists(self) -> Optional[Any]:
+ """
+ 如果元素存在则点击
+
+ 与 click() 不同,该方法在元素不存在时不会抛出异常
+
+ Returns:
+ Optional[Any]: 操作结果,元素不存在时返回 None
+ """
try:
return self.__operate("Component.click")
except ElementNotFoundError:
- pass
+ return None
@delay
- def double_click(self):
+ def double_click(self) -> Any:
+ """
+ 双击元素
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.doubleClick")
@delay
- def long_click(self):
+ def long_click(self) -> Any:
+ """
+ 长按元素
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.longClick")
@delay
- def drag_to(self, component: ComponentData):
+ def drag_to(self, component: ComponentData) -> Any:
+ """
+ 将元素拖动到指定组件位置
+
+ Args:
+ component: 目标组件
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.dragTo", [component.value])
@delay
- def input_text(self, text: str):
+ def input_text(self, text: str) -> Any:
+ """
+ 在元素中输入文本
+
+ Args:
+ text: 要输入的文本
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.inputText", [text])
@delay
- def clear_text(self):
+ def clear_text(self) -> Any:
+ """
+ 清除元素中的文本
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.clearText")
@delay
- def pinch_in(self, scale: float = 0.5):
+ def pinch_in(self, scale: float = 0.5) -> Any:
+ """
+ 在元素上执行捏合手势(缩小)
+
+ Args:
+ scale: 缩放比例,默认为 0.5
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.pinchIn", [scale])
@delay
- def pinch_out(self, scale: float = 2):
+ def pinch_out(self, scale: float = 2) -> Any:
+ """
+ 在元素上执行张开手势(放大)
+
+ Args:
+ scale: 缩放比例,默认为 2
+
+ Returns:
+ Any: 操作结果
+ """
return self.__operate("Component.pinchOut", [scale])
diff --git a/hmdriver2/_webdriver.py b/hmdriver2/_webdriver.py
new file mode 100644
index 0000000..9fbcf5a
--- /dev/null
+++ b/hmdriver2/_webdriver.py
@@ -0,0 +1,934 @@
+# -*- coding: utf-8 -*-
+
+"""
+WebDriver 集成模块
+
+提供 HarmonyOS 设备 WebView 调试和 WebDriver 控制功能
+"""
+
+__all__ = ['WebDriver']
+
+import os
+import platform
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+from typing import List, Optional, Union
+
+from selenium import webdriver
+from selenium.webdriver.chromium.options import ChromiumOptions
+
+from hmdriver2 import logger
+from hmdriver2.exception import (
+ WebDriverSetupError, WebDriverConnectionError,
+ ChromeDriverError, WebViewNotFoundError
+)
+from hmdriver2.hdc import _execute_command
+from hmdriver2.utils import FreePort
+
+# WebDriver 相关常量
+DEFAULT_PAGE_LOAD_TIMEOUT = 10
+DEFAULT_SCRIPT_TIMEOUT = 10
+IMPLICIT_WAIT_TIMEOUT = 5
+CHROME_DRIVER_LOG_LEVEL_ENV_NAME = "CHROME_DRIVER_LOG_LEVEL"
+
+
+class WebDriver:
+ """
+ WebDriver 管理类
+
+ 提供 HarmonyOS 设备上 WebView 调试和控制功能,支持:
+ - 自动发现和连接 WebView
+ - ChromeDriver 生命周期管理
+ - 端口转发和版本适配
+ - 多窗口切换和管理
+ """
+
+ def __init__(self, driver):
+ """
+ 初始化 WebDriver 管理器
+
+ Args:
+ driver: hmdriver2 的 Driver 实例
+ """
+
+ self._driver = driver
+ self._device = driver.hdc
+ self._bundle_name: Optional[str] = None
+ self._webdriver: Optional[webdriver.Remote] = None
+ self._local_port: int = 9222
+ self._remote_port: Union[int, str] = 9222
+ self._chromedriver_port = 9515
+ self._chromedriver_host = f"http://localhost:{self._chromedriver_port}"
+ self._domain_socket_prefix = "webview_devtools_remote_"
+ self._chrome_log_path = ""
+ self._chromedriver_exe_path = ""
+ # 缓存 ChromeDriver 路径查询结果
+ self._chromedriver_path_cache = {}
+
+ # ChromeDriver 配置
+ self.enable_chromedriver_log = False # 默认禁用日志
+ self.chromedriver_log_level = "INFO"
+ self.backup_chromedriver_log = False # 默认不备份
+
+ @property
+ def driver(self) -> webdriver.Remote:
+ """获取 WebDriver 实例"""
+ if self._webdriver is None:
+ raise WebDriverConnectionError("WebView 未连接,请先调用 connect() 方法")
+ return self._webdriver
+
+ @driver.setter
+ def driver(self, value: webdriver.Remote):
+ """设置 WebDriver 实例"""
+ self._webdriver = value
+
+ def configure_chromedriver(self,
+ enable_log: bool = False,
+ log_level: str = "INFO",
+ backup_log: bool = False,
+ port: Optional[int] = None):
+ """
+ 配置 ChromeDriver 选项
+
+ Args:
+ enable_log: 是否启用 ChromeDriver 日志
+ log_level: 日志级别,可选: OFF/SEVERE/WARNING/INFO/DEBUG/ALL
+ backup_log: 是否备份 ChromeDriver 日志
+ port: ChromeDriver 服务端口,None 表示不修改当前端口
+ """
+ self.enable_chromedriver_log = enable_log
+ self.chromedriver_log_level = log_level.upper()
+ self.backup_chromedriver_log = backup_log
+
+ if port is not None:
+ self._chromedriver_port = port
+ self._chromedriver_host = f"http://localhost:{port}"
+
+ logger.debug(f"ChromeDriver 配置更新: 日志={enable_log}, 级别={log_level}, 备份={backup_log}, 端口={self._chromedriver_port}")
+
+ def connect(self,
+ bundle_name: str,
+ remote_port: Optional[Union[int, str]] = None,
+ options: Optional[ChromiumOptions] = None,
+ chromedriver_version: Optional[int] = None) -> webdriver.Remote:
+ """
+ 连接指定应用的 WebView
+
+ Args:
+ bundle_name: 应用包名
+ remote_port: 远程调试端口(可选)
+ options: Chrome 选项配置
+ chromedriver_version: 指定 ChromeDriver 版本(可选),如 114、140 等
+
+ Returns:
+ WebDriver 实例
+
+ Example:
+ driver = Driver()
+
+ # 配置 ChromeDriver(可选,默认已禁用日志)
+ driver.webdriver.configure_chromedriver(
+ enable_log=True, # 启用日志(如需要)
+ log_level="WARNING", # 设置日志级别
+ backup_log=True, # 启用日志备份(如需要)
+ port=9516 # 自定义端口(可选,默认9515)
+ )
+
+ # 连接 WebView(示例包名:华为浏览器)
+ wd = driver.webdriver.connect("com.huawei.hmos.browser")
+ # 或指定版本
+ wd = driver.webdriver.connect("com.huawei.hmos.browser", chromedriver_version=140)
+ wd.get("https://www.baidu.com")
+ """
+ self._bundle_name = bundle_name
+ # 只在有活跃连接时才关闭,避免不必要的端口操作
+ if self._webdriver is not None:
+ self.close()
+
+ self._init_webview(bundle_name, remote_port, options, chromedriver_version)
+ return self.driver
+
+ def _init_webview(self,
+ bundle_name: str,
+ remote_port: Optional[Union[int, str]] = None,
+ options: Optional[ChromiumOptions] = None,
+ chromedriver_version: Optional[int] = None):
+ """初始化 WebView 连接"""
+ try:
+ logger.debug(f"连接 WebView: {bundle_name}")
+
+ # 检查 WebView 进程是否存在(最多5秒)
+ if not self._check_webview_process(bundle_name, timeout=5):
+ raise WebViewNotFoundError(f"未找到应用 {bundle_name} 的 WebView 进程,请确保应用已启动并使用了 WebView")
+
+ # 设置端口转发
+ self._setup_port_forward(bundle_name, remote_port)
+
+ # 根据是否指定版本采用不同策略
+ if chromedriver_version is not None:
+ logger.debug(f"使用用户指定 ChromeDriver 版本: {chromedriver_version}")
+ self._init_fixed_chromedriver(chromedriver_version)
+ else:
+ # 查询 WebView 版本并智能选择 ChromeDriver
+ webview_version = self._get_webview_version()
+ self._init_auto_chromedriver(webview_version)
+
+ # 准备 Chrome 选项
+ if not isinstance(options, ChromiumOptions):
+ options = webdriver.ChromeOptions()
+
+ options.add_experimental_option(
+ name="debuggerAddress",
+ value=f"127.0.0.1:{self._local_port}"
+ )
+
+ # 尝试连接 WebDriver
+ try:
+ self._webdriver = webdriver.Remote(
+ command_executor=self._chromedriver_host,
+ options=options
+ )
+ logger.debug(f"WebDriver 初始化成功: {self._webdriver}")
+ except Exception as error:
+ # 日志使用简化的错误信息,异常使用完整信息
+ error_msg_short = str(error).split('\n')[0]
+ error_msg_full = str(error)
+ logger.error(f"连接失败 {self._chromedriver_host},错误: {error_msg_short}")
+
+ # 检查是否是用户指定版本的连接失败
+ if chromedriver_version is not None:
+ # 用户指定了版本,连接失败应该直接报错,不重试
+ logger.error(f"指定的 ChromeDriver 版本 {chromedriver_version} 连接失败,停止重试")
+ self._kill_chromedriver()
+ if self.backup_chromedriver_log:
+ self._backup_chromedriver_log()
+ raise WebDriverConnectionError(f"ChromeDriver {chromedriver_version} 连接失败: {error_msg_full}")
+
+ # 自动检测版本时,获取当前运行的版本进行重试逻辑
+ current_version = self._get_chromedriver_version() or 114 # 获取不到时使用默认版本
+ logger.debug(f"重新启动 ChromeDriver {current_version}")
+ self._kill_chromedriver()
+ self._backup_chromedriver_log()
+
+ # 重新获取正确版本的 ChromeDriver 路径并启动
+ chromedriver_name = WebDriver._get_chromedriver_name()
+ chromedriver_path = self._get_chromedriver_path(current_version, chromedriver_name)
+ if not chromedriver_path:
+ raise ChromeDriverError(f"未找到 ChromeDriver 版本 {current_version}")
+
+ self._start_chromedriver(chromedriver_path)
+
+ # 等待ChromeDriver启动并检查健康状态
+ self._wait_for_chromedriver_ready(timeout=5)
+
+ # 重新连接
+ self._webdriver = webdriver.Remote(
+ command_executor=self._chromedriver_host,
+ options=options
+ )
+
+ # 设置超时时间
+ self._webdriver.set_page_load_timeout(DEFAULT_PAGE_LOAD_TIMEOUT)
+ self._webdriver.set_script_timeout(DEFAULT_SCRIPT_TIMEOUT)
+ self._webdriver.implicitly_wait(IMPLICIT_WAIT_TIMEOUT)
+
+ # 窗口信息
+ handles = self._webdriver.window_handles
+ logger.debug(f"连接成功!共 {len(handles)} 个窗口")
+
+ if handles:
+ self._switch_to_visible_window()
+
+ except Exception as error:
+ # 收集诊断信息
+ forward_status = self._device.list_fport()
+ logger.error(f"端口转发状态: {forward_status}")
+
+ # 保留完整错误信息
+ error_msg = str(error)
+
+ if self.backup_chromedriver_log:
+ self._backup_chromedriver_log()
+ raise WebDriverSetupError(f"WebDriver 初始化失败: {error_msg}")
+
+ def _setup_port_forward(self,
+ bundle_name: str,
+ remote_port: Optional[Union[int, str]] = None):
+ """设置端口转发"""
+
+ if remote_port is not None:
+ self._remote_port = remote_port
+
+ if isinstance(remote_port, int):
+ self._check_tcp_port(self._remote_port)
+ self._local_port = self._device.forward_port(self._remote_port)
+ logger.debug(f"TCP 端口转发: {self._local_port} -> {self._remote_port}")
+ else:
+ self._local_port = WebDriver._allocate_local_port()
+ cmd = f"{self._device.hdc_prefix} -t {self._device.serial} fport tcp:{self._local_port} {remote_port}"
+ result = _execute_command(cmd)
+ if result.exit_code != 0:
+ raise WebDriverSetupError(f"系统内部端口转发失败: {result.error}")
+ logger.debug(f"系统内部端口转发: {self._local_port} -> {remote_port}")
+
+ else:
+ # 获取调试工具信息(一次查询,两次使用)
+ devtools_info = self._get_devtools_info()
+
+ if self._is_using_domain_socket(devtools_info):
+ socket_name = self._find_devtools_socket(bundle_name, devtools_info)
+ if socket_name is None:
+ raise WebViewNotFoundError(f"未找到 {bundle_name} 的调试端口")
+
+ self._local_port = WebDriver._allocate_local_port()
+ self._remote_port = f"localabstract:{socket_name}"
+ cmd = f"{self._device.hdc_prefix} -t {self._device.serial} fport tcp:{self._local_port} {self._remote_port}"
+ result = _execute_command(cmd)
+ if result.exit_code != 0:
+ raise WebDriverSetupError(f"应用内部端口转发失败: {result.error}")
+ logger.debug(f"应用内部端口转发: {self._local_port} -> {self._remote_port}")
+ else:
+ pass
+ self._check_tcp_port(self._remote_port)
+ self._local_port = self._device.forward_port(self._remote_port)
+ logger.debug(f"默认端口转发: {self._local_port} -> {self._remote_port}")
+
+
+ @staticmethod
+ def _allocate_local_port() -> int:
+ """分配本地端口"""
+ free_port = FreePort()
+ return free_port.get()
+
+ def _get_version_from_url(self, url: str, pattern: str, timeout: int = 3) -> Optional[int]:
+ """从URL获取版本信息"""
+ try:
+ response = urllib.request.urlopen(url, timeout=timeout)
+ text = response.read().decode(encoding="utf-8", errors="ignore")
+ match = re.search(pattern, text)
+ if match:
+ version = int(match.group(1))
+ return version
+ else:
+ return None
+ except Exception as e:
+ logger.error(f"获取版本失败 {url}: {e}")
+ return None
+
+ def _get_devtools_info(self, timeout: int = 2) -> Optional[str]:
+ """获取调试工具信息"""
+
+ for i in range(timeout):
+ try:
+ result = self._device.shell(
+ "cat /proc/net/unix | grep devtools",
+ error_raise=False
+ )
+
+ if "devtools" in result.output:
+ return result.output
+
+ except Exception as e:
+ logger.warning(f"查询调试工具时出错: {e}")
+
+ if i < timeout - 1: # 最后一次不需要等待
+ time.sleep(0.3)
+
+ return None
+
+ def _is_using_domain_socket(self, devtools_info: Optional[str] = None) -> bool:
+ """检查是否使用应用内部端口"""
+ if devtools_info is None:
+ devtools_info = self._get_devtools_info()
+
+ return devtools_info and self._domain_socket_prefix in devtools_info
+
+ def _find_devtools_socket(self,
+ process_name: str,
+ devtools_info: Optional[str] = None,
+ timeout: int = 2) -> Optional[str]:
+ """查找调试工具套接字"""
+
+ # 如果没有提供调试工具信息,则获取
+ if devtools_info is None:
+ devtools_info = self._get_devtools_info(timeout)
+
+ if not devtools_info:
+ logger.warning(f"未找到任何调试工具信息")
+ return None
+
+ # 解析调试端口
+ devtools_ports = []
+ for line in devtools_info.split('\n'):
+ items = line.split()
+ if len(items) >= 1:
+ devtools_ports.append(items[-1].strip('@'))
+
+
+ for i in range(timeout):
+ try:
+ # 获取进程信息
+ process_result = self._device.shell(f"ps -ef | grep {process_name}", error_raise=False)
+
+ # 匹配进程和端口
+ for line in process_result.output.split('\n'):
+ items = line.split()
+ if len(items) < 8:
+ continue
+
+ pid = items[1]
+ actual_process_name = items[7]
+
+ if process_name not in actual_process_name:
+ continue
+
+ for port in devtools_ports:
+ if pid in port:
+ logger.debug(f"找到 {process_name} 的调试端口: {port}")
+ return port
+
+ except Exception as e:
+ logger.warning(f"查找调试套接字时出错: {e}")
+
+ if i < timeout - 1: # 最后一次不需要等待
+ time.sleep(0.3)
+
+ logger.warning(f"未找到 {process_name} 的调试套接字")
+ return None
+
+ def _check_tcp_port(self, port: Union[int, str]):
+ """检查 TCP 端口是否开放"""
+
+ try:
+ result = self._device.shell(f"netstat -tlnp | grep :{port}", error_raise=False)
+
+ if str(port) not in result.output:
+ logger.warning(f"端口 {port} 未开放,请检查应用是否启用了 Web 调试")
+ # 不抛出异常,让后续流程尝试连接
+ else:
+ pass
+
+ except Exception:
+ pass
+ # 端口检查失败不影响主流程
+
+ def _get_webview_version(self) -> int:
+ """获取 WebView 内核版本"""
+ url = f"http://localhost:{self._local_port}/json/version"
+ version = self._get_version_from_url(url, r'"Browser":\s*"[^/]+/(\d+)', timeout=3)
+ if version:
+ logger.debug(f"WebView 内核版本: {version}")
+ return version
+
+ raise WebDriverConnectionError(
+ f"无法获取 WebView 版本信息。请检查:\n"
+ f"1. 应用是否已启用 WebView 调试\n"
+ f"2. 设备未锁屏且 WebView 界面可见\n"
+ f"3. WebView 是否已完全加载"
+ )
+
+ def _get_chromedriver_version(self) -> Optional[int]:
+ """获取 ChromeDriver 版本"""
+ return self._get_version_from_url(f"{self._chromedriver_host}/status", r'"version":\s*"(\d+)', timeout=3)
+
+ def _init_fixed_chromedriver(self, specified_version: int):
+ """初始化用户指定版本的 ChromeDriver"""
+ # 检查是否有 ChromeDriver 进程运行
+ if self._is_chromedriver_running():
+ logger.debug("检测到 ChromeDriver 正在运行")
+
+ # 获取运行中的 ChromeDriver 版本
+ running_version = self._get_chromedriver_version()
+ if running_version == specified_version:
+ logger.debug(f"运行中的 ChromeDriver 版本 {running_version} 匹配指定版本,复用现有进程")
+ return
+ else:
+ if running_version:
+ logger.debug(f"运行中的版本 {running_version} 与指定版本 {specified_version} 不匹配,重启")
+ else:
+ logger.debug("无法获取运行中的版本,重启")
+ self._kill_chromedriver()
+ else:
+ logger.debug("未检测到 ChromeDriver 进程")
+
+ # 启动指定版本的 ChromeDriver
+ logger.debug(f"启动指定的 ChromeDriver 版本: {specified_version}")
+ self._setup_chromedriver(specified_version)
+
+ def _init_auto_chromedriver(self, webview_version: int):
+ """自动选择兼容版本的 ChromeDriver"""
+ # 检查是否有 ChromeDriver 进程运行
+ if self._is_chromedriver_running():
+ logger.debug("检测到 ChromeDriver 正在运行")
+
+ # 获取运行中的 ChromeDriver 版本
+ running_version = self._get_chromedriver_version()
+ if running_version:
+ logger.debug(f"运行中的 ChromeDriver 版本: {running_version}")
+
+ # 检查是否支持当前 WebView 版本
+ if self._is_compatible(running_version, webview_version):
+ logger.debug(f"ChromeDriver {running_version} 兼容 WebView {webview_version},复用现有进程")
+ return
+ else:
+ logger.warning(f"ChromeDriver {running_version} 不兼容 WebView {webview_version},需要重启")
+ self._kill_chromedriver()
+ else:
+ logger.warning("无法获取运行中的 ChromeDriver 版本,将重启")
+ self._kill_chromedriver()
+ else:
+ logger.debug("未检测到 ChromeDriver 进程")
+
+ # 查找本地是否有支持 WebView 版本的 ChromeDriver
+ available_versions = self._get_available_chromedriver_versions()
+ if not available_versions:
+ raise ChromeDriverError("未找到任何本地 ChromeDriver 版本")
+
+ # 找到兼容的版本
+ compatible_version = None
+ for version in sorted(available_versions, reverse=True):
+ if self._is_compatible(version, webview_version):
+ compatible_version = version
+ break
+
+ if compatible_version is None:
+ raise ChromeDriverError(
+ f"不支持 WebView 版本 {webview_version}。\n"
+ f"本地版本: {available_versions}\n"
+ f"兼容性: ChromeDriver 114(支持114-132), ChromeDriver 140(支持140+)\n"
+ f"请下载兼容版本到 assets/web_debug_tools/ 目录"
+ )
+
+ logger.debug(f"选择 ChromeDriver 版本 {compatible_version} 用于 WebView {webview_version}")
+ self._setup_chromedriver(compatible_version)
+
+ def _setup_chromedriver(self, version: int = 114):
+ """准备和启动 ChromeDriver"""
+ chromedriver_name = WebDriver._get_chromedriver_name()
+ chromedriver_path = self._get_chromedriver_path(version, chromedriver_name)
+
+
+ if chromedriver_path is None:
+ raise ChromeDriverError("未找到 ChromeDriver")
+
+ if not os.path.isfile(chromedriver_path):
+ raise ChromeDriverError(f"ChromeDriver 文件不存在: {chromedriver_path}")
+
+ if self._is_chromedriver_running():
+ logger.debug(f"{chromedriver_name} 正在运行,将在连接失败时重启")
+ # 不进行版本检查,直接尝试连接,失败时再处理
+ else:
+ logger.debug(f"{chromedriver_name} 未运行,启动版本 {version}")
+ self._start_chromedriver(chromedriver_path)
+
+ @staticmethod
+ def _get_chromedriver_name() -> str:
+ """获取 ChromeDriver 可执行文件名"""
+ return "chromedriver.exe" if os.name == "nt" else "chromedriver"
+
+ def _get_chromedriver_path(self, version: int, name: str) -> Optional[str]:
+ """获取 ChromeDriver 路径(带缓存)
+
+ 目录结构要求(请将 chromedriver 放在项目托管目录中):
+ - hmdriver2/assets/web_debug_tools/chromedriver_<版本>/chromedriver[.exe]
+ 例如:
+ - hmdriver2/assets/web_debug_tools/chromedriver_114/chromedriver.exe (Windows)
+ - hmdriver2/assets/web_debug_tools/chromedriver_140/chromedriver (Linux/macOS)
+
+ 注意:不再从系统 PATH 中查找 chromedriver,只使用项目内置目录。
+ """
+ cache_key = f"{version}_{name}"
+ if cache_key in self._chromedriver_path_cache:
+ cached_path = self._chromedriver_path_cache[cache_key]
+ if cached_path and os.path.isfile(cached_path):
+ return cached_path
+ else:
+ # 缓存失效,清除
+ del self._chromedriver_path_cache[cache_key]
+
+
+ # 查找 ChromeDriver 文件的候选目录
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+
+ candidate_dirs = [
+ # hmdriver2/assets/web_debug_tools (主要目录)
+ os.path.join(current_dir, "assets", "web_debug_tools"),
+ ]
+
+ for chromedriver_base_dir in candidate_dirs:
+
+ if not os.path.exists(chromedriver_base_dir):
+ continue
+
+ # 尝试精确匹配版本
+ chromedriver_dir = os.path.join(chromedriver_base_dir, f"chromedriver_{version}")
+ chromedriver_path = os.path.join(chromedriver_dir, name)
+
+ if os.path.isfile(chromedriver_path):
+ self._cache_chromedriver_path(version, name, chromedriver_path)
+ return chromedriver_path
+
+ # 如果精确版本不存在,查找最接近的版本
+ available_versions = []
+ try:
+ for item in os.listdir(chromedriver_base_dir):
+ if item.startswith("chromedriver_") and os.path.isdir(os.path.join(chromedriver_base_dir, item)):
+ try:
+ ver = int(item.split("_")[1])
+ path = os.path.join(chromedriver_base_dir, item, name)
+ if os.path.isfile(path):
+ available_versions.append((ver, path))
+ except (ValueError, IndexError):
+ continue
+
+ if available_versions:
+ # 使用兼容性规则找到合适的版本
+ available_version_numbers = [ver for ver, path in available_versions]
+ compatible_version = None
+ for ver in sorted(available_version_numbers, reverse=True):
+ if self._is_compatible(ver, version):
+ compatible_version = ver
+ break
+
+ if compatible_version:
+ # 找到兼容版本对应的路径
+ compatible_path = None
+ for ver, path in available_versions:
+ if ver == compatible_version:
+ compatible_path = path
+ break
+
+ if compatible_path:
+ logger.debug(
+ f"在 {chromedriver_base_dir} 中使用兼容的 ChromeDriver 版本 {compatible_version} (WebView版本: {version})")
+ self._cache_chromedriver_path(version, name, compatible_path)
+ return compatible_path
+
+ # 如果没有找到兼容版本,记录可用版本信息
+ available_version_numbers.sort()
+ logger.warning(
+ f"在 {chromedriver_base_dir} 中没有兼容 WebView {version} 的 ChromeDriver,可用版本: {available_version_numbers}")
+ continue
+
+ except OSError:
+ continue
+
+ logger.error(f"未找到 ChromeDriver (版本 {version})")
+ return None
+
+ def _cache_chromedriver_path(self, version: int, name: str, path: str):
+ """缓存 ChromeDriver 路径"""
+ cache_key = f"{version}_{name}"
+ self._chromedriver_path_cache[cache_key] = path
+
+ def _is_chromedriver_running(self) -> bool:
+ """检查 ChromeDriver 是否正在运行"""
+ process_name = "chromedriver"
+
+ if platform.system() == "Windows":
+ try:
+ result = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10)
+ return f"{process_name}.exe" in result.stdout
+ except Exception as e:
+ logger.warning(f"检查进程状态失败: {e}")
+ return False
+ else:
+ try:
+ result = subprocess.run(f"ps -A|grep {process_name}", shell=True, capture_output=True, text=True, timeout=10)
+ return process_name in result.stdout
+ except Exception as e:
+ logger.warning(f"检查进程状态失败: {e}")
+ return False
+
+
+ def _kill_process_by_name(self, process_name: str):
+ """按名称终止进程"""
+ if sys.platform.startswith("win"):
+ result = subprocess.run(
+ f"taskkill /F /IM {process_name}",
+ capture_output=True
+ )
+ else:
+ result = subprocess.run(
+ f"kill $(pidof {process_name})",
+ capture_output=True,
+ shell=True
+ )
+
+ echo = ""
+ if result.stdout:
+ echo = result.stdout.decode('utf-8', errors='ignore')
+ if result.stderr:
+ echo += result.stderr.decode('utf-8', errors='ignore')
+
+ logger.debug(f"停止 {process_name}: {echo}")
+
+ def _kill_chromedriver(self):
+ """终止 ChromeDriver 进程"""
+ chromedriver_name = WebDriver._get_chromedriver_name()
+ self._kill_process_by_name(chromedriver_name)
+ time.sleep(0.5) # 等待进程终止
+
+ def _start_chromedriver(self, chromedriver_path: str, args: Optional[List[str]] = None):
+ """启动 ChromeDriver"""
+ # 设置可执行权限(Unix 系统)
+ if platform.system() in ["Darwin", "Linux"]:
+ os.chmod(chromedriver_path, stat.S_IRWXU)
+
+ if args:
+ cmd_list = [chromedriver_path] + args
+ else:
+ cmd_list = [
+ chromedriver_path,
+ f"--port={self._chromedriver_port}",
+ ]
+
+ if self.enable_chromedriver_log:
+ temp_log_path = os.path.join(tempfile.gettempdir(), "chromedriver.log")
+ cmd_list.extend([
+ f"--log-level={self.chromedriver_log_level}",
+ f"--log-path={temp_log_path}"
+ ])
+ logger.debug(f"ChromeDriver 日志: {temp_log_path} ({self.chromedriver_log_level})")
+ self._chrome_log_path = temp_log_path
+ else:
+ # 禁用日志时使用 OFF 级别
+ cmd_list.append("--log-level=OFF")
+ logger.debug("ChromeDriver 日志已禁用")
+ self._chrome_log_path = ""
+
+ process = subprocess.Popen(cmd_list)
+ logger.debug(f"ChromeDriver 已启动,PID: {process.pid}")
+ self._chromedriver_exe_path = chromedriver_path
+
+ def _wait_for_chromedriver_ready(self, timeout: int = 5):
+ """等待 ChromeDriver 准备就绪"""
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ response = urllib.request.urlopen(f"{self._chromedriver_host}/status", timeout=1)
+ if response.getcode() == 200:
+ return
+ except Exception:
+ time.sleep(0.1) # 短暂等待后重试
+ continue
+
+ logger.warning(f"ChromeDriver 启动超时 ({timeout}秒),继续尝试连接")
+
+ def _backup_chromedriver_log(self):
+ """备份 ChromeDriver 日志"""
+ if os.path.exists(self._chrome_log_path):
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ backup_path = f"{self._chrome_log_path}.{timestamp}.bak"
+ logger.debug(f"ChromeDriver 日志已备份: {backup_path}")
+ shutil.copy(self._chrome_log_path, backup_path)
+ else:
+ logger.warning("没有 ChromeDriver 日志需要备份")
+
+ def _check_webview_process(self, bundle_name: str, timeout: int = 5) -> bool:
+ """检查 WebView 进程是否存在"""
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ result = self._device.shell(f"ps -ef | grep {bundle_name}", error_raise=False)
+ if result.output:
+ # 逐行检查,排除 grep 进程本身
+ for line in result.output.split('\n'):
+ if bundle_name in line and 'grep' not in line:
+ logger.debug(f"找到应用进程: {line.strip()}")
+ return True
+ time.sleep(0.5)
+ except Exception as e:
+ logger.warning(f"检查进程时出错: {e}")
+ time.sleep(0.5)
+
+ logger.error(f"超时 {timeout}s 未找到应用 {bundle_name} 的进程")
+ return False
+
+ def _check_chromedriver_support(self, version: int) -> bool:
+ """检查是否支持指定的 WebView 版本"""
+ available_versions = self._get_available_chromedriver_versions()
+
+ # 按版本从高到低排序,优先选择高版本
+ for chromedriver_version in sorted(available_versions, reverse=True):
+ if self._is_compatible(chromedriver_version, version):
+ return True
+
+ logger.error(f"没有找到支持 WebView 版本 {version} 的 ChromeDriver")
+ return False
+
+ def _is_compatible(self, chromedriver_version: int, webview_version: int) -> bool:
+ """检查 ChromeDriver 版本是否与 WebView 版本兼容"""
+ if chromedriver_version == 114:
+ return 114 <= webview_version <= 132
+ elif chromedriver_version == 140:
+ return webview_version >= 140
+ else:
+ return chromedriver_version >= webview_version
+
+
+ def _get_available_chromedriver_versions(self) -> List[int]:
+ """
+ 获取可用的 ChromeDriver 版本列表
+
+ Returns:
+ List[int]: 版本号列表
+ """
+ versions = []
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+
+ # 检查 assets 目录
+ assets_dir = os.path.join(current_dir, "assets", "web_debug_tools")
+ if os.path.exists(assets_dir):
+ try:
+ for item in os.listdir(assets_dir):
+ if item.startswith("chromedriver_"):
+ try:
+ version_str = item.replace("chromedriver_", "")
+ version = int(version_str)
+ versions.append(version)
+ except ValueError:
+ continue
+ except OSError:
+ pass
+
+ versions.sort()
+ return versions
+
+ def get_all_windows(self, with_details: bool = False):
+ """获取所有窗口句柄"""
+ if not with_details:
+ return self.driver.window_handles
+
+ result = []
+ current_handle = self.driver.current_window_handle
+ for handle in self.driver.window_handles:
+ self.driver.switch_to.window(handle)
+ result.append({
+ "handle": handle,
+ "url": self.driver.current_url,
+ "title": self.driver.title,
+ "visible": self.driver.execute_script("return document.visibilityState")
+ })
+ self.driver.switch_to.window(current_handle)
+ return result
+
+ def get_current_window(self):
+ """获取当前窗口信息"""
+ return {
+ "handle": self.driver.current_window_handle,
+ "url": self.driver.current_url,
+ "title": self.driver.title
+ }
+
+ def switch_to_visible_window(self, index: int = 0):
+ """
+ 切换到可见窗口
+
+ Args:
+ index: 窗口索引,支持负数(从后往前)
+ """
+ org_index = index
+ handles = self.driver.window_handles
+
+ if index >= 0:
+ window_handles = handles
+ else:
+ window_handles = list(reversed(handles))
+ index = abs(index) - 1
+
+ if index >= len(handles):
+ raise ValueError(f"总共 {len(handles)} 个窗口,索引 [{org_index}] 超出范围")
+
+ visible_count = 0
+ for handle in window_handles:
+ self.driver.switch_to.window(handle)
+ visible = self.driver.execute_script("return document.visibilityState")
+
+ if visible == "visible":
+ if visible_count == index:
+ return
+ visible_count += 1
+
+ logger.warning(f"未找到索引为 [{org_index}] 的可见窗口")
+
+ def _switch_to_visible_window(self):
+ """自动切换到最佳窗口(初始化时使用)"""
+ handles = self._webdriver.window_handles
+ if not handles:
+ return
+
+ # 查找可见窗口
+ visible_handle = None
+ for handle in handles:
+ try:
+ self._webdriver.switch_to.window(handle)
+ visibility = self._webdriver.execute_script("return document.visibilityState")
+ if visibility == "visible":
+ visible_handle = handle
+ break
+ except Exception:
+ continue
+
+ # 如果找到可见窗口,使用它;否则使用最后一个窗口
+ if visible_handle:
+ self._webdriver.switch_to.window(visible_handle)
+ logger.debug("已切换到可见窗口")
+ else:
+ # 回退到最后一个窗口(通常是最新的)
+ self._webdriver.switch_to.window(handles[-1])
+ logger.debug("已切换到最后一个窗口")
+
+ def close(self):
+ """关闭 WebDriver 连接"""
+ if self._webdriver:
+ try:
+ # 关闭 WebDriver
+ self._webdriver.quit()
+ logger.debug("WebDriver 已关闭")
+ except Exception:
+ pass
+ finally:
+ self._webdriver = None
+
+ # 清理 ChromeDriver 进程
+ try:
+ if self._is_chromedriver_running():
+ self._kill_chromedriver()
+ logger.debug("ChromeDriver 进程已清理")
+ except Exception:
+ pass
+
+ # 尝试移除端口转发(忽略错误)
+ try:
+ if hasattr(self, '_local_port') and hasattr(self, '_remote_port'):
+ if isinstance(self._remote_port, int):
+ # TCP 端口转发:tcp:local_port tcp:remote_port
+ cmd = f"{self._device.hdc_prefix} -t {self._device.serial} fport rm tcp:{self._local_port} tcp:{self._remote_port}"
+ _execute_command(cmd)
+ elif isinstance(self._remote_port, str):
+ # 字符串端口(包括系统内部端口和手动指定的字符串端口)
+ cmd = f"{self._device.hdc_prefix} -t {self._device.serial} fport rm tcp:{self._local_port} {self._remote_port}"
+ _execute_command(cmd)
+ except Exception:
+ pass
+
+ def __enter__(self):
+ """上下文管理器入口"""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """上下文管理器退出"""
+ self.close()
+
+ def __getattr__(self, item):
+ """代理到 WebDriver 实例"""
+ if self._webdriver is None:
+ raise WebDriverConnectionError("WebView 未连接,请先调用 connect() 方法")
+ return getattr(self._webdriver, item)
diff --git a/hmdriver2/_xpath.py b/hmdriver2/_xpath.py
index 49cc576..d2dcd77 100644
--- a/hmdriver2/_xpath.py
+++ b/hmdriver2/_xpath.py
@@ -1,105 +1,417 @@
# -*- coding: utf-8 -*-
+import json
import re
-from typing import Dict
-from lxml import etree
from functools import cached_property
+from typing import Dict, Optional, List, Any
+
+from lxml import etree
from . import logger
-from .proto import Bounds
from .driver import Driver
-from .utils import delay, parse_bounds
from .exception import XmlElementNotFoundError
+from .proto import Point
+from .utils import parse_bounds, delay
+
+# XML相关常量
+XML_ROOT_TAG = "orgRoot"
+XML_ATTRIBUTE_TYPE = "type"
+XML_ATTRIBUTE_BOUNDS = "origBounds"
+XML_ATTRIBUTE_ID = "id"
+XML_ATTRIBUTE_TEXT = "text"
+XML_ATTRIBUTE_DESCRIPTION = "description"
+
+# 布尔属性的默认值
+DEFAULT_BOOL_VALUE = "false"
+TRUE_VALUE = "true"
+
+# 布尔属性列表
+BOOL_ATTRIBUTES = [
+ "enabled", "focused", "selected", "checked", "checkable",
+ "clickable", "longClickable", "scrollable"
+]
class _XPath:
- def __init__(self, d: Driver):
+ """
+ XPath查询类,用于在UI层次结构中查找元素。
+
+ 该类提供了将JSON格式的层次结构转换为XML,并执行XPath查询的功能。
+
+ Attributes:
+ _d (Driver): Driver实例,用于与设备交互
+ """
+
+ def __init__(self, d: Driver) -> None:
+ """
+ 初始化XPath查询对象。
+
+ Args:
+ d: Driver实例,用于执行设备操作
+ """
self._d = d
def __call__(self, xpath: str) -> '_XMLElement':
+ """
+ 执行XPath查询并返回匹配的元素。
+
+ Args:
+ xpath: XPath查询字符串
- hierarchy: Dict = self._d.dump_hierarchy()
- if not hierarchy:
- raise RuntimeError("hierarchy is empty")
+ Returns:
+ _XMLElement: 匹配XPath查询的XML元素
- xml = _XPath._json2xml(hierarchy)
- result = xml.xpath(xpath)
+ Raises:
+ RuntimeError: 当层次结构为空或解析失败时抛出
+ XmlElementNotFoundError: 当找不到匹配元素时抛出
+ """
+ hierarchy_str: str = self._d.dump_hierarchy()
+ if not hierarchy_str:
+ raise RuntimeError("层次结构为空")
- 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)
+ try:
+ hierarchy: Dict[str, Any] = json.loads(hierarchy_str)
+ except json.JSONDecodeError as e:
+ raise RuntimeError(f"解析层次结构JSON失败: {e}")
- return _XMLElement(None, self._d)
+ xml = self._json2xml(hierarchy)
+
+ try:
+ result: List[etree.Element] = xml.xpath(xpath)
+ except etree.XPathError as e:
+ raise RuntimeError(f"XPath查询语法错误: {e}")
+
+ # 返回第一个匹配的节点,如果未找到则返回None
+ node = result[0] if result else None
+ return _XMLElement(node, self._d)
@staticmethod
def _sanitize_text(text: str) -> str:
- """Remove XML-incompatible control characters."""
+ """
+ 移除XML不兼容的控制字符。
+
+ Args:
+ text: 需要清理的文本
+
+ Returns:
+ str: 清理后的文本
+ """
+ if not isinstance(text, str):
+ text = str(text)
return re.sub(r'[\x00-\x1F\x7F]', '', text)
@staticmethod
- def _json2xml(hierarchy: Dict) -> etree.Element:
- """Convert JSON-like hierarchy to XML."""
- attributes = hierarchy.get("attributes", {})
+ def _json2xml(hierarchy: Dict[str, Any]) -> etree.Element:
+ """
+ 将JSON格式的层次结构转换为XML。
- # 过滤所有属性的值,确保无非法字符
- cleaned_attributes = {k: _XPath._sanitize_text(str(v)) for k, v in attributes.items()}
+ Args:
+ hierarchy: JSON格式的层次结构数据
- tag = cleaned_attributes.get("type", "orgRoot") or "orgRoot"
+ Returns:
+ etree.Element: 转换后的XML元素
+ """
+ attributes = hierarchy.get("attributes", {})
+ cleaned_attributes = {
+ k: _XPath._sanitize_text(str(v))
+ for k, v in attributes.items()
+ }
+
+ tag = cleaned_attributes.get(XML_ATTRIBUTE_TYPE, XML_ROOT_TAG) or XML_ROOT_TAG
xml = etree.Element(tag, attrib=cleaned_attributes)
-
+
children = hierarchy.get("children", [])
for item in children:
xml.append(_XPath._json2xml(item))
-
+
return xml
class _XMLElement:
- def __init__(self, bounds: Bounds, d: Driver):
- self.bounds = bounds
+ """
+ XML元素类,用于处理UI控件的属性和操作。
+
+ 提供了控件属性的访问和基本的交互操作方法。
+
+ Attributes:
+ _node (Optional[etree.Element]): XML节点元素
+ _d (Driver): Driver实例
+ """
+
+ def __init__(self, node: Optional[etree.Element], d: Driver) -> None:
+ """
+ 初始化XML元素。
+
+ Args:
+ node: XML节点元素
+ d: Driver实例
+ """
+ self._node = node
self._d = d
- def _verify(self):
- if not self.bounds:
- raise XmlElementNotFoundError("xpath not found")
+ def _verify(self) -> None:
+ """
+ 验证控件是否存在。
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ if self._node is None or not self._node.attrib:
+ raise XmlElementNotFoundError("未找到匹配的元素")
@cached_property
- def center(self):
+ def center(self) -> Point:
+ """
+ 获取控件中心点坐标。
+
+ Returns:
+ Point: 控件中心点坐标
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
self._verify()
- return self.bounds.get_center()
+ bounds = parse_bounds(self._node.attrib.get(XML_ATTRIBUTE_BOUNDS, ""))
+ return bounds.get_center() if bounds else Point(0, 0)
def exists(self) -> bool:
- return self.bounds is not None
+ """
+ 检查控件是否存在。
+
+ Returns:
+ bool: 控件存在返回True,否则返回False
+ """
+ return self._node is not None and bool(self._node.attrib)
+
+ @property
+ def id(self) -> str:
+ """
+ 控件的唯一标识符
+
+ Returns:
+ str: 控件ID
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
+ return self._node.attrib.get(XML_ATTRIBUTE_ID, "")
+
+ @property
+ def type(self) -> str:
+ """
+ 控件的类型
+
+ Returns:
+ str: 控件类型
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
+ return self._node.attrib.get(XML_ATTRIBUTE_TYPE, "")
+
+ @property
+ def text(self) -> str:
+ """
+ 控件的文本内容
+
+ Returns:
+ str: 控件文本
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
+ return self._node.attrib.get(XML_ATTRIBUTE_TEXT, "")
+
+ @property
+ def description(self) -> str:
+ """
+ 控件的描述信息
+
+ Returns:
+ str: 控件描述
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
+ return self._node.attrib.get(XML_ATTRIBUTE_DESCRIPTION, "")
+
+ def _get_bool_attr(self, attr: str) -> bool:
+ """
+ 获取布尔类型的属性值。
+
+ Args:
+ attr: 属性名
+
+ Returns:
+ bool: 属性值
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
+ return self._node.attrib.get(attr, DEFAULT_BOOL_VALUE) == TRUE_VALUE
+
+ @property
+ def enabled(self) -> bool:
+ """
+ 控件是否启用
+
+ Returns:
+ bool: 启用状态
+ """
+ return self._get_bool_attr("enabled")
+
+ @property
+ def focused(self) -> bool:
+ """
+ 控件是否获得焦点
+
+ Returns:
+ bool: 焦点状态
+ """
+ return self._get_bool_attr("focused")
+
+ @property
+ def selected(self) -> bool:
+ """
+ 控件是否被选中
+
+ Returns:
+ bool: 选中状态
+ """
+ return self._get_bool_attr("selected")
+
+ @property
+ def checked(self) -> bool:
+ """
+ 控件是否被勾选
+
+ Returns:
+ bool: 勾选状态
+ """
+ return self._get_bool_attr("checked")
+
+ @property
+ def checkable(self) -> bool:
+ """
+ 控件是否可勾选
+
+ Returns:
+ bool: 可勾选状态
+ """
+ return self._get_bool_attr("checkable")
+
+ @property
+ def clickable(self) -> bool:
+ """
+ 控件是否可点击
+
+ Returns:
+ bool: 可点击状态
+ """
+ return self._get_bool_attr("clickable")
+
+ @property
+ def long_clickable(self) -> bool:
+ """
+ 控件是否可长按
+
+ Returns:
+ bool: 可长按状态
+ """
+ return self._get_bool_attr("longClickable")
+
+ @property
+ def scrollable(self) -> bool:
+ """
+ 控件是否可滚动
+
+ Returns:
+ bool: 可滚动状态
+ """
+ return self._get_bool_attr("scrollable")
@delay
- def click(self):
+ def click(self) -> None:
+ """
+ 点击控件中心位置。
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+
+ Note:
+ 该操作会自动添加延迟以确保操作的稳定性
+ """
+ self._verify()
x, y = self.center.x, self.center.y
+ logger.debug(f"点击坐标: ({x}, {y})")
self._d.click(x, y)
@delay
- def click_if_exists(self):
-
+ def click_if_exists(self) -> bool:
+ """
+ 如果控件存在则点击。
+
+ Returns:
+ bool: 点击成功返回True,控件不存在返回False
+
+ Note:
+ 该方法不会在控件不存在时抛出异常
+ """
if not self.exists():
- logger.debug("click_exist: xpath not found")
- return
+ logger.debug("控件不存在,跳过点击操作")
+ return False
x, y = self.center.x, self.center.y
+ logger.debug(f"点击坐标: ({x}, {y})")
self._d.click(x, y)
+ return True
@delay
- def double_click(self):
+ def double_click(self) -> None:
+ """
+ 双击控件中心位置。
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
x, y = self.center.x, self.center.y
+ logger.debug(f"双击坐标: ({x}, {y})")
self._d.double_click(x, y)
@delay
- def long_click(self):
+ def long_click(self) -> None:
+ """
+ 长按控件中心位置。
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+ """
+ self._verify()
x, y = self.center.x, self.center.y
+ logger.debug(f"长按坐标: ({x}, {y})")
self._d.long_click(x, y)
@delay
- def input_text(self, text):
+ def input_text(self, text: str) -> None:
+ """
+ 在控件中输入文本。
+
+ Args:
+ text: 要输入的文本内容
+
+ Raises:
+ XmlElementNotFoundError: 当控件不存在时抛出
+
+ Note:
+ 会先点击控件获取焦点,然后进行文本输入
+ """
+ self._verify()
+ logger.debug(f"输入文本: {text}")
self.click()
- self._d.input_text(text)
\ No newline at end of file
+ self._d.input_text(text)
diff --git a/hmdriver2/assets/so/arm64-v8a/agent.so b/hmdriver2/assets/so/arm64-v8a/agent.so
new file mode 100644
index 0000000..6d4539a
Binary files /dev/null and b/hmdriver2/assets/so/arm64-v8a/agent.so differ
diff --git a/hmdriver2/assets/so/x86_64/agent.so b/hmdriver2/assets/so/x86_64/agent.so
new file mode 100644
index 0000000..904f0f5
Binary files /dev/null and b/hmdriver2/assets/so/x86_64/agent.so differ
diff --git a/hmdriver2/assets/uitest_agent_v1.0.7.so b/hmdriver2/assets/uitest_agent_v1.0.7.so
deleted file mode 100644
index 8298f2c..0000000
Binary files a/hmdriver2/assets/uitest_agent_v1.0.7.so and /dev/null differ
diff --git a/hmdriver2/assets/uitest_agent_v1.1.0.so b/hmdriver2/assets/uitest_agent_v1.1.0.so
deleted file mode 100644
index e71a700..0000000
Binary files a/hmdriver2/assets/uitest_agent_v1.1.0.so and /dev/null differ
diff --git a/hmdriver2/assets/web_debug_tools/chromedriver_114/chromedriver.exe b/hmdriver2/assets/web_debug_tools/chromedriver_114/chromedriver.exe
new file mode 100644
index 0000000..015e261
Binary files /dev/null and b/hmdriver2/assets/web_debug_tools/chromedriver_114/chromedriver.exe differ
diff --git a/hmdriver2/assets/web_debug_tools/chromedriver_140/chromedriver.exe b/hmdriver2/assets/web_debug_tools/chromedriver_140/chromedriver.exe
new file mode 100644
index 0000000..4771f54
Binary files /dev/null and b/hmdriver2/assets/web_debug_tools/chromedriver_140/chromedriver.exe differ
diff --git a/hmdriver2/device_manager.py b/hmdriver2/device_manager.py
new file mode 100644
index 0000000..d988f98
--- /dev/null
+++ b/hmdriver2/device_manager.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+
+"""
+设备管理器模块
+
+提供统一的设备发现、状态管理和缓存功能
+"""
+
+import time
+from typing import List, Optional, Set
+from . import logger
+from .hdc import _execute_command, _build_hdc_prefix
+from .exception import HdcError
+
+
+class DeviceManager:
+ """
+ 设备管理器单例
+
+ 统一管理所有设备的发现、状态缓存和变更检测
+ 避免重复的设备查询调用,提供高效的设备管理
+ """
+
+ _instance: Optional['DeviceManager'] = None
+
+ def __new__(cls) -> 'DeviceManager':
+ """确保单例模式"""
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """初始化设备管理器(仅执行一次)"""
+ if self._initialized:
+ return
+
+ self._devices: List[str] = []
+ self._last_update: float = 0
+ self._cache_duration: float = 5.0 # 缓存5秒
+ self._hdc_prefix = _build_hdc_prefix()
+ self._initialized = True
+
+ logger.debug("DeviceManager 初始化完成")
+
+ def get_devices(self, force_refresh: bool = False) -> List[str]:
+ """
+ 获取设备列表
+
+ Args:
+ force_refresh: 是否强制刷新,忽略缓存
+
+ Returns:
+ List[str]: 设备序列号列表
+
+ Raises:
+ HdcError: HDC 命令执行失败
+ """
+ current_time = time.time()
+
+ # 检查是否需要更新
+ if force_refresh or not self._devices or (current_time - self._last_update) > self._cache_duration:
+ self._refresh_devices()
+
+ return self._devices.copy()
+
+ def _refresh_devices(self):
+ """刷新设备列表"""
+ try:
+ result = _execute_command(f"{self._hdc_prefix} list targets")
+
+ if result.exit_code != 0:
+ raise HdcError("HDC 错误", result.error)
+
+ devices = []
+ if result.output:
+ lines = result.output.strip().split('\n')
+ for line in lines:
+ line = line.strip()
+ if line and 'Empty' not in line:
+ devices.append(line)
+
+ # 检测设备变更
+ old_devices = set(self._devices)
+ new_devices = set(devices)
+
+ added = new_devices - old_devices
+ removed = old_devices - new_devices
+
+ if added:
+ logger.debug(f"检测到新设备: {list(added)}")
+ if removed:
+ logger.debug(f"设备已断开: {list(removed)}")
+
+ self._devices = devices
+ self._last_update = time.time()
+
+ except Exception as e:
+ logger.error(f"刷新设备列表失败: {e}")
+ raise
+
+ def has_device(self, serial: str, auto_refresh: bool = True) -> bool:
+ """
+ 检查指定设备是否存在
+
+ Args:
+ serial: 设备序列号
+ auto_refresh: 如果设备不存在,是否自动刷新设备列表
+
+ Returns:
+ bool: 设备存在返回 True
+ """
+ devices = self.get_devices()
+
+ if serial in devices:
+ return True
+
+ if auto_refresh:
+ # 设备不存在时强制刷新一次
+ devices = self.get_devices(force_refresh=True)
+ return serial in devices
+
+ return False
+
+ def get_first_device(self) -> Optional[str]:
+ """
+ 获取第一个可用设备
+
+ Returns:
+ Optional[str]: 第一个设备序列号,没有设备时返回 None
+ """
+ devices = self.get_devices()
+ return devices[0] if devices else None
+
+ def set_cache_duration(self, duration: float):
+ """设置缓存持续时间(秒)"""
+ self._cache_duration = max(0.1, duration)
+ logger.debug(f"设备缓存时间已设置为 {self._cache_duration} 秒")
+
+
+# 全局单例实例
+device_manager = DeviceManager()
diff --git a/hmdriver2/driver.py b/hmdriver2/driver.py
index 179d10e..323e297 100644
--- a/hmdriver2/driver.py
+++ b/hmdriver2/driver.py
@@ -2,148 +2,319 @@
import json
import uuid
-import re
-from typing import Type, Any, Tuple, Dict, Union, List, Optional
from functools import cached_property # python3.8+
+from typing import Type, Tuple, Dict, Union, List, Optional, Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ._webdriver import WebDriver
from . import logger
-from .utils import delay
from ._client import HmClient
from ._uiobject import UiObject
-from .hdc import list_devices
from .exception import DeviceNotFoundError
+from .hdc import list_devices
from .proto import HypiumResponse, KeyCode, Point, DisplayRotation, DeviceInfo, CommandResult
+from .utils import delay
class Driver:
+ """
+ Harmony OS 设备驱动类
+
+ 提供设备控制、应用管理、UI 操作等功能的主要接口。
+ 采用单例模式,每个设备序列号对应一个实例。
+ """
+
+ # 单例存储字典
_instance: Dict[str, "Driver"] = {}
def __new__(cls: Type["Driver"], serial: Optional[str] = None) -> "Driver":
"""
- Ensure that only one instance of Driver exists per serial.
- If serial is None, use the first serial from list_devices().
+ 确保每个设备序列号只创建一个 Driver 实例
+
+ 如果 serial 为 None,使用 list_devices() 的第一个设备
+
+ Args:
+ serial: 设备序列号,为 None 时使用第一个可用设备
+
+ Returns:
+ Driver: 对应序列号的 Driver 实例
"""
serial = cls._prepare_serial(serial)
if serial not in cls._instance:
instance = super().__new__(cls)
cls._instance[serial] = instance
- # Temporarily store the serial in the instance for initialization
+ # 临时存储序列号用于初始化
instance._serial_for_init = serial
return cls._instance[serial]
def __init__(self, serial: Optional[str] = None):
"""
- Initialize the Driver instance. Only initialize if `_initialized` is not set.
+ 初始化 Driver 实例
+
+ 只有在实例未初始化时才执行初始化
+
+ Args:
+ serial: 设备序列号,为 None 时使用第一个可用设备
+
+ Raises:
+ ValueError: 初始化时缺少序列号
"""
if hasattr(self, "_initialized") and self._initialized:
return
- # Use the serial prepared in `__new__`
+ # 使用在 __new__ 中准备的序列号
serial = getattr(self, "_serial_for_init", serial)
if serial is None:
- raise ValueError("Serial number is required for initialization.")
+ raise ValueError("初始化时需要设备序列号")
self.serial = serial
self._client = HmClient(self.serial)
self.hdc = self._client.hdc
self._init_hmclient()
- self._initialized = True # Mark the instance as initialized
- del self._serial_for_init # Clean up temporary attribute
+ self._initialized = True # 标记实例已初始化
+ self._closed = False # 标记实例未关闭
+ del self._serial_for_init # 清理临时属性
@classmethod
- def _prepare_serial(cls, serial: str = None) -> str:
- """
- Prepare the serial. Use the first available device if serial is None.
+ def _prepare_serial(cls, serial: Optional[str] = None) -> str:
"""
- devices = list_devices()
+ 准备设备序列号
+
+ 如果未提供序列号,使用第一个可用设备
+
+ Args:
+ serial: 设备序列号,为 None 时使用第一个可用设备
+
+ Returns:
+ str: 准备好的设备序列号
+
+ Raises:
+ DeviceNotFoundError: 未找到设备或指定的设备不存在
+ """
+ from .device_manager import device_manager
+
+ devices = device_manager.get_devices()
if not devices:
- raise DeviceNotFoundError("No devices found. Please connect a device.")
+ raise DeviceNotFoundError("未找到设备,请连接设备")
if serial is None:
- logger.info(f"No serial provided, using the first device: {devices[0]}")
- return devices[0]
- if serial not in devices:
- raise DeviceNotFoundError(f"Device [{serial}] not found")
+ first_device = devices[0]
+ logger.info(f"未提供序列号,使用第一个设备: {first_device}")
+ return first_device
+
+ if not device_manager.has_device(serial, auto_refresh=True):
+ raise DeviceNotFoundError(f"未找到设备 [{serial}]")
+
return serial
def __call__(self, **kwargs) -> UiObject:
-
+ """
+ 将 Driver 实例作为函数调用,返回 UiObject 实例
+
+ Args:
+ **kwargs: 传递给 UiObject 构造函数的参数,支持 Match 匹配模式
+
+ Examples:
+ # 完全匹配(默认)
+ d(text="确定")
+
+ # 包含匹配
+ d(text="搜索", match=Match.IN)
+
+ # 正则匹配
+ d(text="app_.*", match=Match.RE)
+
+ # 元组格式:(值, 匹配模式)
+ d(text=("^设置.*", Match.RE))
+
+ Returns:
+ UiObject: 创建的 UiObject 实例
+ """
return UiObject(self._client, **kwargs)
def __del__(self):
- Driver._instance.clear()
- if hasattr(self, '_client') and self._client:
- self._client.release()
+ """
+ 析构函数,清理资源
+
+ 自动调用 close() 方法释放资源
+ """
+ try:
+ self.close()
+ except Exception:
+ pass # 析构函数中忽略所有异常
+
+ def __enter__(self):
+ """上下文管理器进入"""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """上下文管理器退出时自动清理资源"""
+ self.close()
+ return False # 不抑制异常
+
+ def close(self) -> None:
+ """
+ 手动释放 Driver 资源
+
+ 主动关闭连接、清理端口转发和缓存。
+ 建议在使用完毕后手动调用此方法,而不仅依赖析构函数。
+ 此方法可以安全地重复调用。
+
+ Examples:
+ d = Driver()
+ # ... 使用 Driver
+ d.close() # 主动释放资源
+ """
+ # 防止重复调用
+ if getattr(self, '_closed', False):
+ return
+
+ logger.info(f"关闭 Driver [{getattr(self, 'serial', 'unknown')}]")
+
+ try:
+ # 释放客户端资源(包括端口转发)
+ if hasattr(self, '_client') and self._client:
+ self._client.release()
+ except Exception as e:
+ logger.warning(f"释放客户端资源时出错: {e}")
+
+ # 从实例缓存中移除当前实例
+ try:
+ if hasattr(self, 'serial') and self.serial in Driver._instance:
+ del Driver._instance[self.serial]
+ except Exception as e:
+ logger.warning(f"清理实例缓存时出错: {e}")
+
+ # 标记为已关闭
+ self._closed = True
def _init_hmclient(self):
+ """初始化 HmClient 连接"""
self._client.start()
- def _invoke(self, api: str, args: List = []) -> HypiumResponse:
+ def _invoke(self, api: str, args: Optional[List[Any]] = None) -> HypiumResponse:
+ """
+ 调用 Hypium API
+
+ Args:
+ api: API 名称
+ args: API 参数列表,默认为空列表
+
+ Returns:
+ HypiumResponse: API 调用响应
+ """
+ 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: 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`.
-
+ 启动应用
+
+ 如果未提供 page_name,将通过 get_app_main_ability 获取主 Ability
+
Args:
- package_name (str): The package name of the application.
- page_name (Optional[str]): Ability Name within the application to start.
+ package_name: 应用包名
+ page_name: Ability 名称,默认为 None
"""
if not page_name:
page_name = self.get_app_main_ability(package_name).get('name', 'MainAbility')
self.hdc.start_app(package_name, page_name)
def force_start_app(self, package_name: str, page_name: Optional[str] = None):
+ """
+ 强制启动应用
+
+ 先返回主屏幕,停止应用,然后启动应用
+
+ Args:
+ package_name: 应用包名
+ page_name: Ability 名称,默认为 None
+ """
self.go_home()
self.stop_app(package_name)
self.start_app(package_name, page_name)
def stop_app(self, package_name: str):
+ """
+ 停止应用
+
+ Args:
+ package_name: 应用包名
+ """
self.hdc.stop_app(package_name)
def clear_app(self, package_name: str):
"""
- Clear the application's cache and data.
+ 清除应用缓存和数据
+
+ Args:
+ package_name: 应用包名
"""
- 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.hdc.shell(f"bm clean -n {package_name} -c") # 清除缓存
+ self.hdc.shell(f"bm clean -n {package_name} -d") # 清除数据
def install_app(self, apk_path: str):
+ """
+ 安装应用
+
+ Args:
+ apk_path: 应用安装包路径
+ """
self.hdc.install(apk_path)
def uninstall_app(self, package_name: str):
+ """
+ 卸载应用
+
+ Args:
+ package_name: 应用包名
+ """
self.hdc.uninstall(package_name)
def list_apps(self) -> List:
+ """
+ 列出设备上的应用
+
+ Returns:
+ List: 应用列表
+ """
return self.hdc.list_apps()
def has_app(self, package_name: str) -> bool:
+ """
+ 检查设备上是否安装了指定应用
+
+ Args:
+ package_name: 应用包名
+
+ Returns:
+ bool: 应用存在返回 True,否则返回 False
+ """
return self.hdc.has_app(package_name)
def current_app(self) -> Tuple[str, str]:
"""
- Get the current foreground application information.
-
+ 获取当前前台应用信息
+
Returns:
- Tuple[str, str]: A tuple contain the package_name andpage_name of the foreground application.
- If no foreground application is found, returns (None, None).
+ Tuple[str, str]: 包含应用包名和页面名称的元组
+ 如果未找到前台应用,返回 (None, None)
"""
-
return self.hdc.current_app()
def get_app_info(self, package_name: str) -> Dict:
"""
- Get detailed information about a specific application.
-
+ 获取应用详细信息
+
Args:
- package_name (str): The package name of the application to retrieve information for.
-
+ package_name: 应用包名
+
Returns:
- Dict: A dictionary containing the application information. If an error occurs during parsing,
- an empty dictionary is returned.
+ Dict: 包含应用信息的字典,解析错误时返回空字典
"""
app_info = {}
data: CommandResult = self.hdc.shell(f"bm dump -n {package_name}")
@@ -155,88 +326,112 @@ 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}")
+ logger.error(f"解析应用信息时出错: {e}")
return app_info
def get_app_abilities(self, package_name: str) -> List[Dict]:
"""
- Get the abilities of an application.
-
+ 获取应用的 Abilities
+
Args:
- package_name (str): The package name of the application.
-
+ package_name: 应用包名
+
Returns:
- List[Dict]: A list of dictionaries containing the abilities of the application.
+ List[Dict]: 包含应用 Abilities 信息的字典列表
"""
result = []
app_info = self.get_app_info(package_name)
- hap_module_infos = app_info.get("hapModuleInfos")
+ hap_module_infos = app_info.get("hapModuleInfos", [])
main_entry = app_info.get("mainEntry")
for hap_module_info in hap_module_infos:
- # 尝试读取moduleInfo
+ # 尝试读取 moduleInfo
try:
- ability_infos = hap_module_info.get("abilityInfos")
- module_main = hap_module_info["mainAbility"]
+ ability_infos = hap_module_info.get("abilityInfos", [])
+ module_main = hap_module_info.get("mainAbility", "")
except Exception as e:
- logger.warning(f"Fail to parse moduleInfo item, {repr(e)}")
+ logger.warning(f"解析 moduleInfo 失败: {repr(e)}")
continue
- # 尝试读取abilityInfo
+ # 尝试读取 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"]:
+ skills = ability_info.get('skills', [])
+ if skills and "action.system.home" in skills[0].get("actions", []):
is_launcher_ability = True
icon_ability_info = {
- "name": ability_info["name"],
- "moduleName": ability_info["moduleName"],
+ "name": ability_info.get("name", ""),
+ "moduleName": ability_info.get("moduleName", ""),
"moduleMainAbility": module_main,
"mainModule": main_entry,
"isLauncherAbility": is_launcher_ability
}
result.append(icon_ability_info)
except Exception as e:
- logger.warning(f"Fail to parse ability_info item, {repr(e)}")
+ logger.warning(f"解析 ability_info 失败: {repr(e)}")
continue
- logger.debug(f"all abilities: {result}")
+ logger.debug(f"所有 abilities: {result}")
return result
def get_app_main_ability(self, package_name: str) -> Dict:
"""
- Get the main ability of an application.
-
+ 获取应用的主 Ability
+
Args:
- package_name (str): The package name of the application to retrieve information for.
-
+ package_name: 应用包名
+
Returns:
- Dict: A dictionary containing the main ability of the application.
-
+ Dict: 包含应用主 Ability 信息的字典,未找到时返回空字典
"""
- if not (abilities := self.get_app_abilities(package_name)):
+ abilities = self.get_app_abilities(package_name)
+ if not abilities:
return {}
for item in abilities:
score = 0
- if (name := item["name"]) and name == item["moduleMainAbility"]:
+ name = item.get("name", "")
+ if name and name == item.get("moduleMainAbility", ""):
score += 1
- if (module_name := item["moduleName"]) and module_name == item["mainModule"]:
+ module_name = item.get("moduleName", "")
+ if module_name and module_name == item.get("mainModule", ""):
score += 1
item["score"] = score
- abilities.sort(key=lambda x: (not x["isLauncherAbility"], -x["score"]))
- logger.debug(f"main ability: {abilities[0]}")
+ abilities.sort(key=lambda x: (not x.get("isLauncherAbility", False), -x.get("score", 0)))
+ logger.debug(f"主 ability: {abilities[0]}")
return abilities[0]
@cached_property
def toast_watcher(self):
-
+ """
+ 获取 Toast 监视器
+
+ Returns:
+ _Watcher: Toast 监视器实例
+ """
obj = self
class _Watcher:
+ """Toast 监视器内部类"""
+
def start(self) -> bool:
+ """
+ 开始监视 Toast
+
+ Returns:
+ bool: 成功返回 True
+ """
api = "Driver.uiEventObserverOnce"
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) -> Optional[str]:
+ """
+ 获取 Toast 内容
+
+ Args:
+ timeout: 超时时间(秒),默认为 3
+
+ Returns:
+ Optional[str]: Toast 内容,未捕获到返回 None
+ """
api = "Driver.getRecentUiEvent"
resp: HypiumResponse = obj._invoke(api, args=[timeout])
if resp.result:
@@ -247,31 +442,84 @@ def get_toast(self, timeout: int = 3) -> str:
@delay
def go_back(self):
+ """按返回键"""
self.hdc.send_key(KeyCode.BACK)
@delay
def go_home(self):
+ """按主页键"""
self.hdc.send_key(KeyCode.HOME)
+ @delay
+ def go_recent(self):
+ """打开最近任务"""
+ self.press_keys(KeyCode.META_LEFT, KeyCode.TAB)
+
@delay
def press_key(self, key_code: Union[KeyCode, int]):
+ """
+ 按下单个按键
+
+ Args:
+ key_code: 按键代码,可以是 KeyCode 枚举或整数
+ """
self.hdc.send_key(key_code)
+ @delay
+ def press_keys(self, key_code1: Union[KeyCode, int], key_code2: Union[KeyCode, int]):
+ """
+ 按下组合键
+
+ Args:
+ key_code1: 第一个按键代码
+ key_code2: 第二个按键代码
+ """
+ code1 = key_code1.value if isinstance(key_code1, KeyCode) else key_code1
+ code2 = key_code2.value if isinstance(key_code2, KeyCode) else key_code2
+
+ api = "Driver.triggerCombineKeys"
+ self._invoke(api, args=[code1, code2])
+
def screen_on(self):
+ """唤醒屏幕"""
self.hdc.wakeup()
def screen_off(self):
+ """关闭屏幕"""
self.hdc.wakeup()
self.press_key(KeyCode.POWER)
@delay
def unlock(self):
+ """
+ 解锁屏幕
+
+ 在运行时先点亮屏幕,然后判断是否需要解锁,如果需要则执行解锁手势
+ """
+
+ # 先点亮屏幕
self.screen_on()
- w, h = self.display_size
- self.swipe(0.5 * w, 0.8 * h, 0.5 * w, 0.2 * h, speed=6000)
+
+ # 检查屏幕是否锁定
+ if self.hdc.is_screen_locked():
+ w, h = self.display_size
+ x = w // 2
+ start_y = h * 7 // 8
+ end_y = h // 3
+ # 使用 hdc.swipe 方法执行解锁滑动,持续时间 500ms
+ self.hdc.swipe(x, start_y, x, end_y, 500)
+ logger.info("屏幕已解锁")
+ else:
+ logger.info("屏幕未锁屏")
@cached_property
def display_size(self) -> Tuple[int, int]:
+ """
+ 获取屏幕尺寸
+
+ Returns:
+ Tuple[int, int]: 屏幕宽度和高度
+ """
api = "Driver.getDisplaySize"
resp: HypiumResponse = self._invoke(api)
w, h = resp.result.get("x"), resp.result.get("y")
@@ -279,16 +527,22 @@ def display_size(self) -> Tuple[int, int]:
@cached_property
def display_rotation(self) -> DisplayRotation:
+ """
+ 获取屏幕旋转状态
+
+ Returns:
+ DisplayRotation: 屏幕旋转状态枚举值
+ """
api = "Driver.getDisplayRotation"
value = self._invoke(api).result
return DisplayRotation.from_value(value)
def set_display_rotation(self, rotation: DisplayRotation):
"""
- Sets the display rotation to the specified orientation.
-
+ 设置屏幕旋转状态
+
Args:
- rotation (DisplayRotation): display rotation.
+ rotation: 屏幕旋转状态枚举值
"""
api = "Driver.setDisplayRotation"
self._invoke(api, args=[rotation.value])
@@ -296,10 +550,10 @@ def set_display_rotation(self, rotation: DisplayRotation):
@cached_property
def device_info(self) -> DeviceInfo:
"""
- Get detailed information about the device.
-
+ 获取设备详细信息
+
Returns:
- DeviceInfo: An object containing various properties of the device.
+ DeviceInfo: 包含设备各种属性的对象
"""
hdc = self.hdc
return DeviceInfo(
@@ -315,78 +569,107 @@ def device_info(self) -> DeviceInfo:
@delay
def open_url(self, url: str, system_browser: bool = True):
+ """
+ 打开 URL
+
+ Args:
+ url: 要打开的 URL
+ system_browser: 是否使用系统浏览器,默认为 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}")
else:
- # Default method
+ # 默认方法
self.hdc.shell(f"aa start -U {url}")
def pull_file(self, rpath: str, lpath: str):
"""
- Pull a file from the device to the local machine.
-
+ 从设备拉取文件到本地
+
Args:
- rpath (str): The remote path of the file on the device.
- lpath (str): The local path where the file should be saved.
+ rpath: 设备上的文件路径
+ lpath: 本地保存路径
"""
self.hdc.recv_file(rpath, lpath)
def push_file(self, lpath: str, rpath: str):
"""
- Push a file from the local machine to the device.
-
+ 推送本地文件到设备
+
Args:
- lpath (str): The local path of the file.
- rpath (str): The remote path where the file should be saved on the device.
+ lpath: 本地文件路径
+ rpath: 设备上的保存路径
"""
self.hdc.send_file(lpath, rpath)
def screenshot(self, path: str) -> str:
"""
- Take a screenshot of the device display.
-
+ 截取设备屏幕
+
Args:
- path (str): The local path to save the screenshot.
-
+ path: 本地保存路径
+
Returns:
- str: The path where the screenshot is saved.
+ str: 截图保存路径
"""
_uuid = uuid.uuid4().hex
_tmp_path = f"/data/local/tmp/_tmp_{_uuid}.jpeg"
self.shell(f"snapshot_display -f {_tmp_path}")
self.pull_file(_tmp_path, path)
- self.shell(f"rm -rf {_tmp_path}") # remove local path
+ self.shell(f"rm -rf {_tmp_path}") # 删除临时文件
return path
def shell(self, cmd) -> CommandResult:
+ """
+ 执行 Shell 命令
+
+ Args:
+ cmd: 要执行的命令
+
+ Returns:
+ CommandResult: 命令执行结果
+ """
return self.hdc.shell(cmd)
def _to_abs_pos(self, x: Union[int, float], y: Union[int, float]) -> Point:
"""
- Convert percentages to absolute screen coordinates.
-
+ 将百分比坐标转换为绝对屏幕坐标
+
Args:
- x (Union[int, float]): X coordinate as a percentage or absolute value.
- y (Union[int, float]): Y coordinate as a percentage or absolute value.
-
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+
Returns:
- Point: A Point object with absolute screen coordinates.
- """
- assert x >= 0
- assert y >= 0
-
- w, h = self.display_size
-
- if x < 1:
- x = int(w * x)
- if y < 1:
- y = int(h * y)
+ Point: 包含绝对屏幕坐标的 Point 对象
+
+ Raises:
+ AssertionError: 坐标为负数时抛出
+ """
+ assert x >= 0, "X 坐标不能为负数"
+ assert y >= 0, "Y 坐标不能为负数"
+
+ # 只有在需要时才获取显示尺寸
+ if x < 1 or y < 1:
+ w, h = self.display_size
+
+ if x < 1:
+ x = w * x
+ if y < 1:
+ y = h * y
+
+ # 只进行一次整数转换
return Point(int(x), int(y))
@delay
def click(self, x: Union[int, float], y: Union[int, float]):
-
+ """
+ 点击屏幕
+
+ Args:
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+ """
# self.hdc.tap(point.x, point.y)
point = self._to_abs_pos(x, y)
api = "Driver.click"
@@ -394,35 +677,49 @@ def click(self, x: Union[int, float], y: Union[int, float]):
@delay
def double_click(self, x: Union[int, float], y: Union[int, float]):
+ """
+ 双击屏幕
+
+ Args:
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+ """
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]):
+ """
+ 长按屏幕
+
+ Args:
+ x: X 坐标,可以是百分比(0-1)或绝对值
+ y: Y 坐标,可以是百分比(0-1)或绝对值
+ """
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):
+ def swipe(self, x1: Union[int, float], y1: Union[int, float],
+ x2: Union[int, float], y2: Union[int, float], speed: int = 2000):
"""
- Perform a swipe action on the device screen.
-
+ 在屏幕上滑动
+
Args:
- x1 (float): The start X coordinate as a percentage or absolute value.
- 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.
+ x1: 起始 X 坐标,可以是百分比(0-1)或绝对值
+ y1: 起始 Y 坐标,可以是百分比(0-1)或绝对值
+ x2: 结束 X 坐标,可以是百分比(0-1)或绝对值
+ y2: 结束 Y 坐标,可以是百分比(0-1)或绝对值
+ speed: 滑动速度(像素/秒),默认为 2000,范围:200-40000
+ 如果超出范围,将设为默认值 2000
"""
-
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.")
+ logger.warning("`speed` 不在范围 [200-40000] 内,设置为默认值 2000")
speed = 2000
api = "Driver.swipe"
@@ -431,8 +728,14 @@ def swipe(self, x1, y1, x2, y2, speed=2000):
@cached_property
def swipe_ext(self):
"""
+ 获取扩展滑动功能
+
+ 用法示例:
d.swipe_ext("up")
d.swipe_ext("up", box=(0.2, 0.2, 0.8, 0.8))
+
+ Returns:
+ SwipeExt: 扩展滑动功能实例
"""
from ._swipe import SwipeExt
return SwipeExt(self)
@@ -440,41 +743,58 @@ def swipe_ext(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) -> str:
"""
- 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").result
+ if isinstance(result, str):
+ return result
+ return json.dumps(result, ensure_ascii=False)
@cached_property
def gesture(self):
+ """
+ 获取手势操作功能
+
+ Returns:
+ _Gesture: 手势操作功能实例
+ """
from ._gesture import _Gesture
return _Gesture(self)
@cached_property
def screenrecord(self):
+ """
+ 获取屏幕录制功能
+
+ Returns:
+ RecordClient: 屏幕录制功能实例
+ """
from ._screenrecord import RecordClient
return RecordClient(self.serial, self)
- def _invalidate_cache(self, attribute_name):
+ def _invalidate_cache(self, attribute_name: str):
"""
- Invalidate the cached property.
-
+ 使缓存的属性失效
+
Args:
- attribute_name (str): The name of the attribute to invalidate.
+ attribute_name: 要使失效的属性名
"""
if attribute_name in self.__dict__:
del self.__dict__[attribute_name]
@@ -482,7 +802,40 @@ def _invalidate_cache(self, attribute_name):
@cached_property
def xpath(self):
"""
+ 获取 XPath 查询功能
+
+ 用法示例:
d.xpath("//*[@text='Hello']").click()
+
+ Returns:
+ _XPath: XPath 查询功能实例
"""
from ._xpath import _XPath
return _XPath(self)
+
+ @cached_property
+ def webdriver(self) -> 'WebDriver':
+ """
+ 获取 WebDriver 功能
+
+ 用于调试和控制 HarmonyOS 设备上的 WebView
+
+ 用法示例:
+ # 连接应用的 WebView
+ wd = d.webdriver.connect("com.huawei.browser")
+
+ # 导航到网页
+ wd.get("https://www.baidu.com")
+
+ # 查找元素并操作
+ element = wd.find_element(By.ID, "kw")
+ element.send_keys("HarmonyOS")
+
+ # 关闭连接
+ d.webdriver.close()
+
+ Returns:
+ WebDriver: WebDriver 管理实例
+ """
+ from ._webdriver import WebDriver
+ return WebDriver(self)
diff --git a/hmdriver2/exception.py b/hmdriver2/exception.py
index b1ee62a..a46894d 100644
--- a/hmdriver2/exception.py
+++ b/hmdriver2/exception.py
@@ -38,3 +38,28 @@ class InjectGestureError(Exception):
class ScreenRecordError(Exception):
pass
+
+
+class WebDriverError(Exception):
+ """WebDriver 相关错误的基类"""
+ pass
+
+
+class WebDriverSetupError(WebDriverError):
+ """WebDriver 设置错误"""
+ pass
+
+
+class WebDriverConnectionError(WebDriverError):
+ """WebDriver 连接错误"""
+ pass
+
+
+class ChromeDriverError(WebDriverError):
+ """ChromeDriver 进程相关错误"""
+ pass
+
+
+class WebViewNotFoundError(WebDriverError):
+ """WebView 未找到错误"""
+ pass
diff --git a/hmdriver2/hdc.py b/hmdriver2/hdc.py
index 000133f..0dcc2b2 100644
--- a/hmdriver2/hdc.py
+++ b/hmdriver2/hdc.py
@@ -1,20 +1,38 @@
# -*- coding: utf-8 -*-
-import tempfile
+
import json
-import uuid
-import shlex
-import re
import os
+import re
+import shlex
import subprocess
-from typing import Union, List, Dict, Tuple
+import tempfile
+import uuid
+from typing import Union, List, Dict, Tuple, Optional, Any
from . import logger
-from .utils import FreePort
-from .proto import CommandResult, KeyCode
from .exception import HdcError, DeviceNotFoundError
+from .proto import CommandResult, KeyCode
+from .utils import FreePort
+
+# HDC 命令相关常量
+HDC_CMD = "hdc"
+HDC_SERVER_HOST_ENV = "HDC_SERVER_HOST"
+HDC_SERVER_PORT_ENV = "HDC_SERVER_PORT"
+
+# 键码相关常量
+MAX_KEY_CODE = 3200
def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult:
+ """
+ 执行命令并返回结果
+
+ Args:
+ cmdargs: 要执行的命令,可以是字符串或命令参数列表
+
+ Returns:
+ CommandResult: 命令执行结果对象
+ """
if isinstance(cmdargs, (list, tuple)):
cmdline: str = ' '.join(list(map(shlex.quote, cmdargs)))
elif isinstance(cmdargs, str):
@@ -29,6 +47,7 @@ def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult:
error = error.decode('utf-8')
exit_code = process.returncode
+ # 检查输出中是否包含错误信息
if 'error:' in output.lower() or '[fail]' in output.lower():
return CommandResult("", output, -1)
@@ -39,159 +58,332 @@ def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult:
def _build_hdc_prefix() -> str:
"""
- Construct the hdc command prefix based on environment variables.
+ 根据环境变量构建 HDC 命令前缀
+
+ 如果设置了 HDC_SERVER_HOST 和 HDC_SERVER_PORT 环境变量,
+ 则使用这些值构建带有服务器连接信息的命令前缀。
+
+ Returns:
+ str: HDC 命令前缀
"""
- host = os.getenv("HDC_SERVER_HOST")
- port = os.getenv("HDC_SERVER_PORT")
+ host = os.getenv(HDC_SERVER_HOST_ENV)
+ port = os.getenv(HDC_SERVER_PORT_ENV)
if host and port:
- logger.debug(f"HDC_SERVER_HOST: {host}, HDC_SERVER_PORT: {port}")
- return f"hdc -s {host}:{port}"
- return "hdc"
-
-
-def list_devices() -> List[str]:
- devices = []
- 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:
- if line.__contains__('Empty'):
- continue
- devices.append(line.strip())
+ logger.debug(f"{HDC_SERVER_HOST_ENV}: {host}, {HDC_SERVER_PORT_ENV}: {port}")
+ return f"{HDC_CMD} -s {host}:{port}"
+ return HDC_CMD
- if result.exit_code != 0:
- raise HdcError("HDC error", result.error)
- return devices
+def list_devices(force_refresh: bool = False) -> List[str]:
+ """
+ 列出所有已连接的设备
+
+ 通过设备管理器获取连接的设备序列号列表。
+ 使用缓存机制避免重复的 HDC 查询。
+
+ Args:
+ force_refresh: 是否强制刷新设备列表,忽略缓存
+
+ Returns:
+ List[str]: 设备序列号列表
+
+ Raises:
+ HdcError: HDC 命令执行失败时抛出
+ """
+ from .device_manager import device_manager
+ return device_manager.get_devices(force_refresh=force_refresh)
class HdcWrapper:
+ """
+ HDC 命令包装类
+
+ 提供对 HDC 命令的封装,简化与 Harmony OS 设备的交互。
+ """
+
def __init__(self, serial: str) -> None:
+ """
+ 初始化 HDC 包装器
+
+ Args:
+ serial: 设备序列号
+
+ Raises:
+ DeviceNotFoundError: 设备未找到时抛出
+ """
self.serial = serial
self.hdc_prefix = _build_hdc_prefix()
if not self.is_online():
- raise DeviceNotFoundError(f"Device [{self.serial}] not found")
+ raise DeviceNotFoundError(f"未找到设备 [{self.serial}]")
- def is_online(self):
- _serials = list_devices()
- return True if self.serial in _serials else False
+ def is_online(self) -> bool:
+ """
+ 检查设备是否在线
+
+ Returns:
+ bool: 设备在线返回 True,否则返回 False
+ """
+ from .device_manager import device_manager
+ return device_manager.has_device(self.serial, auto_refresh=False)
def forward_port(self, rport: int) -> int:
+ """
+ 设置端口转发
+
+ 将设备上的端口转发到本地端口
+
+ Args:
+ rport: 设备端口
+
+ Returns:
+ int: 本地端口
+
+ Raises:
+ HdcError: 端口转发失败时抛出
+ """
lport: int = FreePort().get()
result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport tcp:{lport} tcp:{rport}")
if result.exit_code != 0:
- raise HdcError("HDC forward port error", result.error)
+ raise HdcError("HDC 端口转发错误", result.error)
return lport
def rm_forward(self, lport: int, rport: int) -> int:
+ """
+ 移除端口转发
+
+ Args:
+ lport: 本地端口
+ rport: 设备端口
+
+ Returns:
+ int: 本地端口
+
+ Raises:
+ HdcError: 移除端口转发失败时抛出
+ """
result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport rm tcp:{lport} tcp:{rport}")
if result.exit_code != 0:
- raise HdcError("HDC rm forward error", result.error)
+ raise HdcError("HDC 移除端口转发错误", result.error)
return lport
- def list_fport(self) -> List:
+ def list_fport(self) -> List[str]:
"""
- eg.['tcp:10001 tcp:8012', 'tcp:10255 tcp:8012']
+ 列出所有端口转发
+
+ Returns:
+ List[str]: 端口转发列表,例如 ['tcp:10001 tcp:8012', 'tcp:10255 tcp:8012']
+
+ Raises:
+ HdcError: 列出端口转发失败时抛出
"""
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)
+ raise HdcError("HDC 列出端口转发错误", result.error)
pattern = re.compile(r"tcp:\d+ tcp:\d+")
return pattern.findall(result.output)
- def send_file(self, lpath: str, rpath: str):
+ def send_file(self, lpath: str, rpath: str) -> CommandResult:
+ """
+ 发送文件到设备
+
+ Args:
+ lpath: 本地文件路径
+ rpath: 设备上的目标路径
+
+ Returns:
+ CommandResult: 命令执行结果
+
+ Raises:
+ HdcError: 发送文件失败时抛出
+ """
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)
+ raise HdcError("HDC 发送文件错误", result.error)
return result
- def recv_file(self, rpath: str, lpath: str):
+ def recv_file(self, rpath: str, lpath: str) -> CommandResult:
+ """
+ 从设备接收文件
+
+ Args:
+ rpath: 设备上的文件路径
+ lpath: 本地保存路径
+
+ Returns:
+ CommandResult: 命令执行结果
+
+ Raises:
+ HdcError: 接收文件失败时抛出
+ """
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)
+ raise HdcError("HDC 接收文件错误", result.error)
return result
- def shell(self, cmd: str, error_raise=True) -> CommandResult:
- # ensure the command is wrapped in double quotes
+ def shell(self, cmd: str, error_raise: bool = True) -> CommandResult:
+ """
+ 在设备上执行 Shell 命令
+
+ Args:
+ cmd: 要执行的 Shell 命令
+ error_raise: 命令失败时是否抛出异常,默认为 True
+
+ Returns:
+ CommandResult: 命令执行结果
+
+ Raises:
+ HdcError: 命令执行失败且 error_raise 为 True 时抛出
+ """
+ # 确保命令用双引号包裹
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}")
+ raise HdcError("HDC Shell 命令错误", f"{cmd}\n{result.output}\n{result.error}")
return result
- def uninstall(self, bundlename: str):
+ def uninstall(self, bundlename: str) -> CommandResult:
+ """
+ 卸载应用
+
+ Args:
+ bundlename: 应用包名
+
+ Returns:
+ CommandResult: 命令执行结果
+
+ Raises:
+ HdcError: 卸载应用失败时抛出
+ """
result = _execute_command(f"{self.hdc_prefix} -t {self.serial} uninstall {bundlename}")
if result.exit_code != 0:
- raise HdcError("HDC uninstall error", result.output)
+ raise HdcError("HDC 卸载应用错误", result.output)
return result
- def install(self, apkpath: str):
- # Ensure the path is properly quoted for Windows
+ def install(self, apkpath: str) -> CommandResult:
+ """
+ 安装应用
+
+ Args:
+ apkpath: 应用安装包路径
+
+ Returns:
+ CommandResult: 命令执行结果
+
+ Raises:
+ HdcError: 安装应用失败时抛出
+ """
+ # 确保路径正确引用,特别是在 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)
+ raise HdcError("HDC 安装应用错误", result.error)
return result
def list_apps(self) -> List[str]:
+ """
+ 列出设备上的所有应用
+
+ Returns:
+ List[str]: 应用列表
+ """
result = self.shell("bm dump -a")
raw = result.output.split('\n')
- return [item.strip() for item in raw]
+ return [item.strip() for item in raw if item.strip()]
def has_app(self, package_name: str) -> bool:
+ """
+ 检查设备上是否安装了指定应用
+
+ Args:
+ package_name: 应用包名
+
+ Returns:
+ bool: 应用存在返回 True,否则返回 False
+ """
data = self.shell("bm dump -a").output
- return True if package_name in data else False
+ return package_name in data
- def start_app(self, package_name: str, ability_name: str):
+ def start_app(self, package_name: str, ability_name: str) -> CommandResult:
+ """
+ 启动应用
+
+ Args:
+ package_name: 应用包名
+ ability_name: Ability 名称
+
+ Returns:
+ CommandResult: 命令执行结果
+ """
return self.shell(f"aa start -a {ability_name} -b {package_name}")
- def stop_app(self, package_name: str):
+ def stop_app(self, package_name: str) -> CommandResult:
+ """
+ 停止应用
+
+ Args:
+ package_name: 应用包名
+
+ Returns:
+ CommandResult: 命令执行结果
+ """
return self.shell(f"aa force-stop {package_name}")
- def current_app(self) -> Tuple[str, str]:
+ def current_app(self) -> Tuple[Optional[str], Optional[str]]:
"""
- Get the current foreground application information.
-
+ 获取当前前台应用信息
+
+ 1) 通过 WindowManagerService 获取焦点窗口ID
+ 2) 通过 AbilityManagerService 任务列表匹配包名与ability
+
Returns:
- Tuple[str, str]: A tuple contain the package_name andpage_name of the foreground application.
- If no foreground application is found, returns (None, None).
+ Tuple[Optional[str], Optional[str]]: (package_name, ability_name)
"""
-
- def __extract_info(output: str):
- results = []
-
- mission_blocks = re.findall(r'Mission ID #[\s\S]*?isKeepAlive: false\s*}', output)
- if not mission_blocks:
- 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)
- 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))
-
- return results
-
- data: CommandResult = self.shell("aa dump -l")
- output = data.output
- results = __extract_info(output)
- return results[0] if results else (None, None)
-
- def wakeup(self):
+ try:
+ # 获取焦点窗口ID
+ wms_output = self.shell("hidumper -s WindowManagerService -a '-a'").output
+ focus_match = re.search(r"Focus window: (\d+)", wms_output)
+ if not focus_match:
+ return None, None
+ focus_id = focus_match.group(1)
+
+ # 获取任务列表并匹配焦点窗口
+ ams_output = self.shell("hidumper -s AbilityManagerService -a -l").output
+ mission_pattern = r"Mission ID #(\d+)\s+mission name #\[(.*?)\]"
+
+ # 优先从 AMS 输出匹配
+ for mission_id, mission_name in re.findall(mission_pattern, ams_output):
+ if mission_id == focus_id and ":" in mission_name:
+ package, ability = mission_name.split(":", 1)
+ return package.replace("#", ""), ability
+
+ # 兜底从 WMS 输出匹配
+ for mission_id, mission_name in re.findall(mission_pattern, wms_output):
+ if mission_id == focus_id and ":" in mission_name:
+ package, ability = mission_name.split(":", 1)
+ return package.replace("#", ""), ability
+
+ return None, None
+
+ except Exception as e:
+ logger.warning(f"获取前台应用失败: {e}")
+ return None, None
+
+ def wakeup(self) -> None:
+ """唤醒设备"""
self.shell("power-shell wakeup")
- def screen_state(self) -> str:
+ def screen_state(self) -> Optional[str]:
"""
- ["INACTIVE", "SLEEP, AWAKE"]
+ 获取屏幕状态
+
+ Returns:
+ Optional[str]: 屏幕状态,可能的值包括 "INACTIVE"、"SLEEP"、"AWAKE" 等
+ 如果无法获取状态,返回 None
"""
data = self.shell("hidumper -s PowerManagerService -a -s").output
pattern = r"Current State:\s*(\w+)"
@@ -199,39 +391,117 @@ def screen_state(self) -> str:
return match.group(1) if match else None
- def wlan_ip(self) -> Union[str, None]:
+ def is_screen_locked(self) -> bool:
+ """
+ 检查屏幕是否锁定
+
+ Returns:
+ bool: 屏幕锁定返回 True,否则返回 False
+ """
+ try:
+ data = self.shell("hidumper -s ScreenlockService -a '-all'").output
+ # 查找 screenLocked 状态
+ pattern = r"screenLocked\s+(\w+)"
+ match = re.search(pattern, data)
+
+ if match:
+ status = match.group(1).lower()
+ return status == "true"
+ return False
+ except Exception as e:
+ logger.warning(f"获取屏幕锁定状态失败: {e}")
+ return False
+
+ def wlan_ip(self) -> Optional[str]:
+ """
+ 获取设备的 WLAN IP 地址
+
+ Returns:
+ Optional[str]: WLAN IP 地址,如果未找到则返回 None
+ """
data = self.shell("ifconfig").output
matches = re.findall(r'inet addr:(?!127)(\d+\.\d+\.\d+\.\d+)', data)
return matches[0] if matches else None
- def __split_text(self, text: str) -> str:
+ def __split_text(self, text: Optional[str]) -> Optional[str]:
+ """
+ 从文本中提取第一行并去除前后空白
+
+ Args:
+ text: 输入文本
+
+ Returns:
+ Optional[str]: 处理后的文本,如果输入为 None 则返回 None
+ """
return text.split("\n")[0].strip() if text else None
- def sdk_version(self) -> str:
+ def sdk_version(self) -> Optional[str]:
+ """
+ 获取设备 SDK 版本
+
+ Returns:
+ Optional[str]: SDK 版本
+ """
data = self.shell("param get const.ohos.apiversion").output
return self.__split_text(data)
- def sys_version(self) -> str:
+ def sys_version(self) -> Optional[str]:
+ """
+ 获取设备系统版本
+
+ Returns:
+ Optional[str]: 系统版本
+ """
data = self.shell("param get const.product.software.version").output
return self.__split_text(data)
- def model(self) -> str:
+ def model(self) -> Optional[str]:
+ """
+ 获取设备型号
+
+ Returns:
+ Optional[str]: 设备型号
+ """
data = self.shell("param get const.product.model").output
return self.__split_text(data)
- def brand(self) -> str:
+ def brand(self) -> Optional[str]:
+ """
+ 获取设备品牌
+
+ Returns:
+ Optional[str]: 设备品牌
+ """
data = self.shell("param get const.product.brand").output
return self.__split_text(data)
- def product_name(self) -> str:
+ def product_name(self) -> Optional[str]:
+ """
+ 获取设备产品名称
+
+ Returns:
+ Optional[str]: 产品名称
+ """
data = self.shell("param get const.product.name").output
return self.__split_text(data)
- def cpu_abi(self) -> str:
+ def cpu_abi(self) -> Optional[str]:
+ """
+ 获取设备 CPU ABI
+
+ Returns:
+ Optional[str]: CPU ABI
+ """
data = self.shell("param get const.product.cpu.abilist").output
return self.__split_text(data)
def display_size(self) -> Tuple[int, int]:
+ """
+ 获取设备屏幕尺寸
+
+ Returns:
+ Tuple[int, int]: 屏幕宽度和高度,如果无法获取则返回 (0, 0)
+ """
data = self.shell("hidumper -s RenderService -a screen").output
match = re.search(r'activeMode:\s*(\d+)x(\d+),\s*refreshrate=\d+', data)
@@ -242,33 +512,84 @@ def display_size(self) -> Tuple[int, int]:
return (0, 0)
def send_key(self, key_code: Union[KeyCode, int]) -> None:
+ """
+ 发送按键事件
+
+ Args:
+ key_code: 按键代码,可以是 KeyCode 枚举或整数
+
+ Raises:
+ HdcError: 按键代码无效时抛出
+ """
if isinstance(key_code, KeyCode):
key_code = key_code.value
- MAX = 3200
- if key_code > MAX:
- raise HdcError("Invalid HDC keycode")
+ if key_code > MAX_KEY_CODE:
+ raise HdcError("无效的 HDC 按键代码")
- self.shell(f"uitest uiInput keyEvent {key_code}")
+ # 使用 uinput 替代 uitest,-K 表示按键事件
+ self.shell(f"uinput -K {key_code}")
def tap(self, x: int, y: int) -> None:
- self.shell(f"uitest uiInput click {x} {y}")
+ """
+ 点击屏幕
+
+ Args:
+ x: X 坐标
+ y: Y 坐标
+ """
+ # 使用 uinput 替代 uitest,-T 表示触摸,-c 表示点击
+ 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}")
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, speed: int = 500) -> None:
+ """
+ 在屏幕上滑动
+
+ Args:
+ x1: 起始 X 坐标
+ y1: 起始 Y 坐标
+ x2: 结束 X 坐标
+ y2: 结束 Y 坐标
+ speed: 滑动持续时间(毫秒),默认为 500
+ """
+ # 使用 uinput 替代 uitest,-T 表示触摸,-m 表示移动/滑动
+ self.shell(f"uinput -T -m {x1} {y1} {x2} {y2} {speed}")
- def input_text(self, x: int, y: int, text: str):
+ def input_text(self, x: int, y: int, text: str) -> None:
+ """
+ 在指定位置输入文本
+
+ Args:
+ x: X 坐标
+ y: Y 坐标
+ text: 要输入的文本
+ """
self.shell(f"uitest uiInput inputText {x} {y} {text}")
def screenshot(self, path: str) -> str:
+ """
+ 截取屏幕
+
+ Args:
+ path: 本地保存路径
+
+ Returns:
+ str: 截图保存路径
+ """
_uuid = uuid.uuid4().hex
_tmp_path = f"/data/local/tmp/_tmp_{_uuid}.jpeg"
self.shell(f"snapshot_display -f {_tmp_path}")
self.recv_file(_tmp_path, path)
- self.shell(f"rm -rf {_tmp_path}") # remove local path
+ self.shell(f"rm -rf {_tmp_path}") # 删除临时文件
return path
- def dump_hierarchy(self) -> Dict:
+ def dump_hierarchy(self) -> Dict[str, Any]:
+ """
+ 导出界面层次结构
+
+ Returns:
+ Dict[str, Any]: 界面层次结构数据,如果解析失败则返回空字典
+ """
_tmp_path = f"/data/local/tmp/{self.serial}_tmp.json"
self.shell(f"uitest dumpLayout -p {_tmp_path}")
@@ -280,7 +601,7 @@ def dump_hierarchy(self) -> Dict:
with open(path, 'r', encoding='utf8') as file:
data = json.load(file)
except Exception as e:
- logger.error(f"Error loading JSON file: {e}")
+ logger.error(f"加载 JSON 文件时出错: {e}")
data = {}
return data
diff --git a/hmdriver2/proto.py b/hmdriver2/proto.py
index e987cc9..9f108f5 100644
--- a/hmdriver2/proto.py
+++ b/hmdriver2/proto.py
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
import json
-from enum import Enum
-from typing import Union, List
from dataclasses import dataclass, asdict
+from enum import Enum
+from typing import Union, List, Dict
@dataclass
@@ -64,8 +64,8 @@ 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
@dataclass
@@ -160,51 +160,51 @@ class KeyCode(Enum):
MUTE = 23 # 话筒静音键
BRIGHTNESS_UP = 40 # 亮度调节按键调亮
BRIGHTNESS_DOWN = 41 # 亮度调节按键调暗
- NUM_0 = 2000 # 按键’0’
- NUM_1 = 2001 # 按键’1’
- NUM_2 = 2002 # 按键’2’
- NUM_3 = 2003 # 按键’3’
- NUM_4 = 2004 # 按键’4’
- NUM_5 = 2005 # 按键’5’
- NUM_6 = 2006 # 按键’6’
- NUM_7 = 2007 # 按键’7’
- NUM_8 = 2008 # 按键’8’
- NUM_9 = 2009 # 按键’9’
- STAR = 2010 # 按键’*’
- POUND = 2011 # 按键’#’
+ NUM_0 = 2000 # 按键'0'
+ NUM_1 = 2001 # 按键'1'
+ NUM_2 = 2002 # 按键'2'
+ NUM_3 = 2003 # 按键'3'
+ NUM_4 = 2004 # 按键'4'
+ NUM_5 = 2005 # 按键'5'
+ NUM_6 = 2006 # 按键'6'
+ NUM_7 = 2007 # 按键'7'
+ NUM_8 = 2008 # 按键'8'
+ NUM_9 = 2009 # 按键'9'
+ STAR = 2010 # 按键'*'
+ POUND = 2011 # 按键'#'
DPAD_UP = 2012 # 导航键向上
DPAD_DOWN = 2013 # 导航键向下
DPAD_LEFT = 2014 # 导航键向左
DPAD_RIGHT = 2015 # 导航键向右
DPAD_CENTER = 2016 # 导航键确定键
- A = 2017 # 按键’A’
- B = 2018 # 按键’B’
- C = 2019 # 按键’C’
- D = 2020 # 按键’D’
- E = 2021 # 按键’E’
- F = 2022 # 按键’F’
- G = 2023 # 按键’G’
- H = 2024 # 按键’H’
- I = 2025 # 按键’I’
- J = 2026 # 按键’J’
- K = 2027 # 按键’K’
- L = 2028 # 按键’L’
- M = 2029 # 按键’M’
- N = 2030 # 按键’N’
- O = 2031 # 按键’O’
- P = 2032 # 按键’P’
- Q = 2033 # 按键’Q’
- R = 2034 # 按键’R’
- S = 2035 # 按键’S’
- T = 2036 # 按键’T’
- U = 2037 # 按键’U’
- V = 2038 # 按键’V’
- W = 2039 # 按键’W’
- X = 2040 # 按键’X’
- Y = 2041 # 按键’Y’
- Z = 2042 # 按键’Z’
- COMMA = 2043 # 按键’,’
- PERIOD = 2044 # 按键’.’
+ A = 2017 # 按键'A'
+ B = 2018 # 按键'B'
+ C = 2019 # 按键'C'
+ D = 2020 # 按键'D'
+ E = 2021 # 按键'E'
+ F = 2022 # 按键'F'
+ G = 2023 # 按键'G'
+ H = 2024 # 按键'H'
+ I = 2025 # 按键'I'
+ J = 2026 # 按键'J'
+ K = 2027 # 按键'K'
+ L = 2028 # 按键'L'
+ M = 2029 # 按键'M'
+ N = 2030 # 按键'N'
+ O = 2031 # 按键'O'
+ P = 2032 # 按键'P'
+ Q = 2033 # 按键'Q'
+ R = 2034 # 按键'R'
+ S = 2035 # 按键'S'
+ T = 2036 # 按键'T'
+ U = 2037 # 按键'U'
+ V = 2038 # 按键'V'
+ W = 2039 # 按键'W'
+ X = 2040 # 按键'X'
+ Y = 2041 # 按键'Y'
+ Z = 2042 # 按键'Z'
+ COMMA = 2043 # 按键','
+ PERIOD = 2044 # 按键'.'
ALT_LEFT = 2045 # 左Alt键
ALT_RIGHT = 2046 # 右Alt键
SHIFT_LEFT = 2047 # 左Shift键
@@ -216,17 +216,17 @@ class KeyCode(Enum):
ENVELOPE = 2053 # 电子邮件功能键,此键用于启动电子邮件应用程序。
ENTER = 2054 # 回车键
DEL = 2055 # 退格键
- GRAVE = 2056 # 按键’`’
- MINUS = 2057 # 按键’-’
- EQUALS = 2058 # 按键’=’
- LEFT_BRACKET = 2059 # 按键’[’
- RIGHT_BRACKET = 2060 # 按键’]’
- BACKSLASH = 2061 # 按键’\’
- SEMICOLON = 2062 # 按键’;’
- APOSTROPHE = 2063 # 按键’‘’(单引号)
- SLASH = 2064 # 按键’/’
- AT = 2065 # 按键’@’
- PLUS = 2066 # 按键’+’
+ GRAVE = 2056 # 按键'`'
+ MINUS = 2057 # 按键'-'
+ EQUALS = 2058 # 按键'='
+ LEFT_BRACKET = 2059 # 按键'['
+ RIGHT_BRACKET = 2060 # 按键']'
+ BACKSLASH = 2061 # 按键'\'
+ SEMICOLON = 2062 # 按键';'
+ APOSTROPHE = 2063 # 按键'''
+ SLASH = 2064 # 按键'/'
+ AT = 2065 # 按键'@'
+ PLUS = 2066 # 按键'+'
MENU = 2067 # 菜单键
PAGE_UP = 2068 # 向上翻页键
PAGE_DOWN = 2069 # 向下翻页键
@@ -250,39 +250,39 @@ class KeyCode(Enum):
MEDIA_CLOSE = 2087 # 多媒体键关闭
MEDIA_EJECT = 2088 # 多媒体键弹出
MEDIA_RECORD = 2089 # 多媒体键录音
- F1 = 2090 # 按键’F1’
- F2 = 2091 # 按键’F2’
- F3 = 2092 # 按键’F3’
- F4 = 2093 # 按键’F4’
- F5 = 2094 # 按键’F5’
- F6 = 2095 # 按键’F6’
- F7 = 2096 # 按键’F7’
- F8 = 2097 # 按键’F8’
- F9 = 2098 # 按键’F9’
- F10 = 2099 # 按键’F10’
- F11 = 2100 # 按键’F11’
- F12 = 2101 # 按键’F12’
+ F1 = 2090 # 按键'F1'
+ F2 = 2091 # 按键'F2'
+ F3 = 2092 # 按键'F3'
+ F4 = 2093 # 按键'F4'
+ F5 = 2094 # 按键'F5'
+ F6 = 2095 # 按键'F6'
+ F7 = 2096 # 按键'F7'
+ F8 = 2097 # 按键'F8'
+ F9 = 2098 # 按键'F9'
+ F10 = 2099 # 按键'F10'
+ F11 = 2100 # 按键'F11'
+ F12 = 2101 # 按键'F12'
NUM_LOCK = 2102 # 小键盘锁
- NUMPAD_0 = 2103 # 小键盘按键’0’
- NUMPAD_1 = 2104 # 小键盘按键’1’
- NUMPAD_2 = 2105 # 小键盘按键’2’
- NUMPAD_3 = 2106 # 小键盘按键’3’
- NUMPAD_4 = 2107 # 小键盘按键’4’
- NUMPAD_5 = 2108 # 小键盘按键’5’
- NUMPAD_6 = 2109 # 小键盘按键’6’
- NUMPAD_7 = 2110 # 小键盘按键’7’
- NUMPAD_8 = 2111 # 小键盘按键’8’
- NUMPAD_9 = 2112 # 小键盘按键’9’
- NUMPAD_DIVIDE = 2113 # 小键盘按键’/’
- NUMPAD_MULTIPLY = 2114 # 小键盘按键’*’
- NUMPAD_SUBTRACT = 2115 # 小键盘按键’-’
- NUMPAD_ADD = 2116 # 小键盘按键’+’
- NUMPAD_DOT = 2117 # 小键盘按键’.’
- NUMPAD_COMMA = 2118 # 小键盘按键’,’
+ NUMPAD_0 = 2103 # 小键盘按键'0'
+ NUMPAD_1 = 2104 # 小键盘按键'1'
+ NUMPAD_2 = 2105 # 小键盘按键'2'
+ NUMPAD_3 = 2106 # 小键盘按键'3'
+ NUMPAD_4 = 2107 # 小键盘按键'4'
+ NUMPAD_5 = 2108 # 小键盘按键'5'
+ NUMPAD_6 = 2109 # 小键盘按键'6'
+ NUMPAD_7 = 2110 # 小键盘按键'7'
+ NUMPAD_8 = 2111 # 小键盘按键'8'
+ NUMPAD_9 = 2112 # 小键盘按键'9'
+ NUMPAD_DIVIDE = 2113 # 小键盘按键'/'
+ NUMPAD_MULTIPLY = 2114 # 小键盘按键'*'
+ NUMPAD_SUBTRACT = 2115 # 小键盘按键'-'
+ NUMPAD_ADD = 2116 # 小键盘按键'+'
+ NUMPAD_DOT = 2117 # 小键盘按键'.'
+ NUMPAD_COMMA = 2118 # 小键盘按键','
NUMPAD_ENTER = 2119 # 小键盘按键回车
- NUMPAD_EQUALS = 2120 # 小键盘按键’=’
- NUMPAD_LEFT_PAREN = 2121 # 小键盘按键’(’
- NUMPAD_RIGHT_PAREN = 2122 # 小键盘按键’)’
+ NUMPAD_EQUALS = 2120 # 小键盘按键'='
+ NUMPAD_LEFT_PAREN = 2121 # 小键盘按键'('
+ NUMPAD_RIGHT_PAREN = 2122 # 小键盘按键')'
VIRTUAL_MULTITASK = 2210 # 虚拟多任务键
SLEEP = 2600 # 睡眠键
ZENKAKU_HANKAKU = 2601 # 日文全宽/半宽键
@@ -431,18 +431,18 @@ class KeyCode(Enum):
EJECTCLOSECD = 2813 # 弹出CD键
ISO = 2814 # ISO键
MOVE = 2815 # 移动键
- F13 = 2816 # 按键’F13’
- F14 = 2817 # 按键’F14’
- F15 = 2818 # 按键’F15’
- F16 = 2819 # 按键’F16’
- F17 = 2820 # 按键’F17’
- F18 = 2821 # 按键’F18’
- F19 = 2822 # 按键’F19’
- F20 = 2823 # 按键’F20’
- F21 = 2824 # 按键’F21’
- F22 = 2825 # 按键’F22’
- F23 = 2826 # 按键’F23’
- F24 = 2827 # 按键’F24’
+ F13 = 2816 # 按键'F13'
+ F14 = 2817 # 按键'F14'
+ F15 = 2818 # 按键'F15'
+ F16 = 2819 # 按键'F16'
+ F17 = 2820 # 按键'F17'
+ F18 = 2821 # 按键'F18'
+ F19 = 2822 # 按键'F19'
+ F20 = 2823 # 按键'F20'
+ F21 = 2824 # 按键'F21'
+ F22 = 2825 # 按键'F22'
+ F23 = 2826 # 按键'F23'
+ F24 = 2827 # 按键'F24'
PROG3 = 2828 # 程序键3
PROG4 = 2829 # 程序键4
DASHBOARD = 2830 # 仪表板
diff --git a/hmdriver2/utils.py b/hmdriver2/utils.py
index cf4f75a..8e53465 100644
--- a/hmdriver2/utils.py
+++ b/hmdriver2/utils.py
@@ -1,60 +1,124 @@
# -*- coding: utf-8 -*-
-
-import time
-import socket
import re
+import socket
+import time
from functools import wraps
-from typing import Union
+from typing import Optional, Callable, Any, TypeVar
from .proto import Bounds
+# 默认 UI 操作后的延迟时间(秒)
+DEFAULT_DELAY_TIME = 0.6
+
+# 端口范围
+PORT_RANGE_START = 10000
+PORT_RANGE_END = 20000
+
+# 类型变量定义,用于泛型函数
+F = TypeVar('F', bound=Callable[..., Any])
-def delay(func):
+
+def delay(func: F) -> F:
"""
- After each UI operation, it is necessary to wait for a while to ensure the stability of the UI,
- so as not to affect the next UI operation.
+ UI 操作后的延迟装饰器
+
+ 在每次 UI 操作后需要等待一段时间,确保 UI 稳定,
+ 避免影响下一次 UI 操作。
+
+ Args:
+ func: 要装饰的函数
+
+ Returns:
+ 装饰后的函数
"""
- DELAY_TIME = 0.6
-
@wraps(func)
- def wrapper(*args, **kwargs):
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
result = func(*args, **kwargs)
- time.sleep(DELAY_TIME)
+ time.sleep(DEFAULT_DELAY_TIME)
return result
- return wrapper
+
+ return wrapper # type: ignore
class FreePort:
- def __init__(self):
- self._start = 10000
- self._end = 20000
+ """
+ 空闲端口管理类
+
+ 用于获取系统中未被占用的网络端口
+ """
+
+ def __init__(self) -> None:
+ """初始化端口管理器"""
+ self._start = PORT_RANGE_START
+ self._end = PORT_RANGE_END
self._now = self._start - 1
def get(self) -> int:
- while True:
- self._now += 1
- if self._now > self._end:
- self._now = self._start
- if not self.is_port_in_use(self._now):
- return self._now
+ """
+ 获取一个空闲端口(系统自动分配)
+
+ 通过绑定 ('127.0.0.1', 0) 让系统返回可用端口号,避免扫描导致的阻塞。
+
+ Returns:
+ int: 可用的端口号
+ """
+ start_ts = time.time()
+ port = 0
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ # 绑定到0表示让系统分配可用端口
+ s.bind(('127.0.0.1', 0))
+ port = s.getsockname()[1]
+ finally:
+ try:
+ s.close()
+ except Exception:
+ pass
+
+ # 保持实现简洁,不额外输出调试日志
+ return port
@staticmethod
def is_port_in_use(port: int) -> bool:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- return s.connect_ex(('localhost', port)) == 0
+ """
+ 检查端口是否被占用
+
+ Args:
+ port: 要检查的端口号
+
+ Returns:
+ bool: 端口被占用返回 True,否则返回 False
+ """
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ return s.connect_ex(('localhost', port)) == 0
+ except (socket.error, OSError):
+ # 如果发生错误,保守地认为端口被占用
+ return True
-def parse_bounds(bounds: str) -> Union[Bounds, None]:
+def parse_bounds(bounds: str) -> Optional[Bounds]:
"""
- Parse bounds string to Bounds.
- bounds is str, like: "[832,1282][1125,1412]"
+ 解析边界字符串为 Bounds 对象
+
+ Args:
+ bounds: 边界字符串,格式如 "[832,1282][1125,1412]"
+
+ Returns:
+ Optional[Bounds]: 解析成功返回 Bounds 对象,否则返回 None
"""
+ if not bounds:
+ return None
+
result = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if result:
g = result.groups()
- return Bounds(int(g[0]),
- int(g[1]),
- int(g[2]),
- int(g[3]))
- return None
\ No newline at end of file
+ try:
+ return Bounds(int(g[0]),
+ int(g[1]),
+ int(g[2]),
+ int(g[3]))
+ except (ValueError, IndexError):
+ return None
+ return None
diff --git a/install_hmdriver2.md b/install_hmdriver2.md
new file mode 100644
index 0000000..0abdaa9
--- /dev/null
+++ b/install_hmdriver2.md
@@ -0,0 +1,115 @@
+# hmdriver2 自动化安装脚本
+
+## 概述
+
+`install_hmdriver2.sh` 是一个自动化安装脚本,用于安装 hmdriver2 的最新源码编译版本到 Python 的 site-packages 目录,让你可以在任意项目中使用。
+
+## 核心功能
+
+- **独立安装** - 安装到site-packages,可在任意项目中使用
+- **智能替换** - 自动检测并替换已有版本
+- **跨平台支持** - Linux、macOS、Windows (Git Bash/WSL)
+- **自动验证** - 确保安装到正确位置
+
+## 重要:Python环境选择
+
+### **关键原则:安装时使用的Python = 其他项目使用的Python**
+
+```bash
+# 1. 确认你在其他项目中使用的Python解释器
+which python # Linux/macOS
+where python # Windows
+
+# 2. 使用相同的Python运行安装脚本
+python --version # 确认版本一致
+bash install_hmdriver2.sh
+```
+
+## 安装步骤
+
+### 1. 下载项目
+```bash
+git clone https://github.com/your-repo/hmdriver2.git
+cd hmdriver2
+```
+
+### 2. 运行安装脚本
+```bash
+bash install_hmdriver2.sh
+```
+
+### 3. 确认安装
+在提示时输入 `y` 确认安装
+
+## 安装完成后的输出
+
+安装成功后,脚本会显示:
+
+```bash
+[i] 重要提醒:
+ 在其他项目中必须使用此Python解释器:
+ C:\Users\zzh\AppData\Local\Programs\Python\Python312\python.exe
+
+[✓] 独立版本 v1.5.0 安装完成!现在可以在任意项目中使用 hmdriver2
+```
+
+### **关键信息说明**
+
+**脚本显示的Python解释器路径非常重要!**
+
+- 这是安装hmdriver2的Python解释器
+- 在其他项目中必须使用相同的解释器
+- IDE项目设置中也要选择这个解释器
+
+## 验证安装
+
+安装完成后,验证是否成功:
+
+```bash
+# 方法1:检查pip包列表
+pip show hmdriver2
+
+# 方法2:在其他目录测试导入
+cd /tmp # 或任意其他目录
+python -c "import hmdriver2; print(f'版本: {hmdriver2.__version__}')"
+```
+
+## 常见问题
+
+### Q: 在其他项目中导入失败?
+
+**A**: 最常见原因是Python解释器不一致:
+
+1. **检查Python解释器**:确保使用安装脚本输出的解释器路径
+2. **IDE设置**:
+ - **PyCharm**: `File → Settings → Project → Python Interpreter`
+ - **VSCode**: `Ctrl+Shift+P → Python: Select Interpreter`
+3. **验证安装**:运行 `pip show hmdriver2`
+
+### Q: 如何确认使用了正确的Python?
+
+**A**: 比对解释器路径:
+
+```bash
+# 查看当前Python解释器
+python -c "import sys; print(sys.executable)"
+
+# 应该与安装脚本输出的路径一致
+```
+
+### Q: Windows用户注意事项
+
+**A**:
+- 必须使用 **Git Bash** 或 **WSL** 运行脚本
+- 如遇权限问题,以管理员身份运行Git Bash
+- 确保Python已正确安装并在PATH中
+
+### Q: 如何回到发布版本?
+
+**A**:
+```bash
+pip uninstall hmdriver2
+pip install hmdriver2
+```
+
+---
diff --git a/install_hmdriver2.sh b/install_hmdriver2.sh
new file mode 100644
index 0000000..0434b93
--- /dev/null
+++ b/install_hmdriver2.sh
@@ -0,0 +1,416 @@
+#!/bin/bash
+
+# =============================================================================
+# hmdriver2 自动化安装脚本 - 三端通用安装工具
+# 支持 Linux, macOS, Windows (Git Bash/WSL)
+# =============================================================================
+
+set -e # 遇到错误立即退出
+
+# 颜色定义(加粗和亮色)
+RED='\033[1;31m' # 亮红色
+GREEN='\033[1;32m' # 亮绿色
+YELLOW='\033[1;33m' # 亮黄色
+BLUE='\033[1;34m' # 亮蓝色
+CYAN='\033[1;36m' # 亮青色
+GRAY='\033[0;37m' # 灰色
+NC='\033[0m' # 无颜色
+
+# 简化图标
+SUCCESS="[✓]"
+ERROR="[✗]"
+WARNING="[!]"
+INFO="[i]"
+
+# 全局变量
+PYTHON_CMD=""
+PIP_CMD=""
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# =============================================================================
+# 工具函数
+# =============================================================================
+
+print_banner() {
+ echo -e "${CYAN}"
+ echo "================================================================="
+ echo -e " ${GREEN}hmdriver2 自动化安装工具${CYAN}"
+ echo -e " ${GRAY}三端通用 (Linux/macOS/Windows)${CYAN}"
+ echo "================================================================="
+ echo -e "${NC}"
+}
+
+print_step() {
+ echo -e "${BLUE}▶ $1${NC}"
+}
+
+print_success() {
+ echo -e "${GREEN}${SUCCESS} $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}${ERROR} $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}${WARNING} $1${NC}"
+}
+
+print_info() {
+ echo -e "${CYAN}${INFO} $1${NC}"
+}
+
+# 检测操作系统
+detect_os() {
+ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
+ echo "Linux"
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
+ echo "macOS"
+ elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then
+ echo "Windows"
+ else
+ echo "Unknown"
+ fi
+}
+
+# 检测Python命令
+detect_python() {
+ local os_type=$(detect_os)
+
+ # 在Windows上,优先使用python而不是python3
+ if [[ "$os_type" == "Windows" ]] && command -v python &> /dev/null; then
+ # 检查Python版本
+ local version=$(python --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
+ local major=$(echo $version | cut -d. -f1)
+ if [[ $major -ge 3 ]]; then
+ PYTHON_CMD="python"
+ PIP_CMD="pip"
+ return 0
+ fi
+ fi
+
+ if command -v python3 &> /dev/null; then
+ PYTHON_CMD="python3"
+ PIP_CMD="pip3"
+ elif command -v python &> /dev/null; then
+ # 检查Python版本
+ local version=$(python --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
+ local major=$(echo $version | cut -d. -f1)
+ if [[ $major -ge 3 ]]; then
+ PYTHON_CMD="python"
+ PIP_CMD="pip"
+ else
+ return 1
+ fi
+ else
+ return 1
+ fi
+
+ return 0
+}
+
+# 检查Python版本
+check_python_version() {
+ local version=$($PYTHON_CMD --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
+ local major=$(echo $version | cut -d. -f1)
+ local minor=$(echo $version | cut -d. -f2)
+
+ if [[ $major -lt 3 ]] || [[ $major -eq 3 && $minor -lt 8 ]]; then
+ print_error "Python版本过低: $version (需要 >= 3.8)"
+ return 1
+ fi
+
+ print_success "Python版本检查通过: $version"
+ return 0
+}
+
+# 获取项目版本
+get_project_version() {
+ local version
+ if [[ -f "$SCRIPT_DIR/pyproject.toml" ]]; then
+ version=$(grep '^version = ' "$SCRIPT_DIR/pyproject.toml" | sed 's/version = "\(.*\)"/\1/' | tr -d '"')
+ if [[ -n "$version" ]]; then
+ echo "$version"
+ return 0
+ fi
+ fi
+ echo "unknown"
+ return 1
+}
+
+# 检查项目文件
+check_project_files() {
+ if [[ ! -f "$SCRIPT_DIR/pyproject.toml" ]]; then
+ print_error "未找到 pyproject.toml,请确保在项目根目录运行此脚本"
+ return 1
+ fi
+
+ if [[ ! -d "$SCRIPT_DIR/hmdriver2" ]]; then
+ print_error "未找到 hmdriver2 源码目录"
+ return 1
+ fi
+
+ local project_version=$(get_project_version)
+ print_success "项目文件检查通过"
+ print_info "项目版本: $project_version"
+
+ return 0
+}
+
+# 安装依赖
+install_dependencies() {
+ print_step "安装基础依赖..."
+
+ if $PIP_CMD install "lxml>=5.3.0"; then
+ print_success "基础依赖安装完成"
+ return 0
+ else
+ print_error "基础依赖安装失败"
+ return 1
+ fi
+}
+
+# 检查并处理已安装的hmdriver2
+check_and_handle_existing() {
+ print_step "检查已安装的hmdriver2..."
+
+ # 检查是否通过pip安装
+ local pip_info
+ pip_info=$($PIP_CMD show hmdriver2 2>/dev/null)
+
+ if [[ -n "$pip_info" ]]; then
+ local version=$(echo "$pip_info" | grep "^Version:" | cut -d' ' -f2)
+ print_warning "发现已安装的hmdriver2 (版本: ${version:-unknown})"
+ print_info "将替换为当前源码版本"
+
+ # 卸载现有版本
+ if $PIP_CMD uninstall hmdriver2 -y &>/dev/null; then
+ print_success "原版本卸载完成"
+ else
+ print_warning "卸载时出现问题,但会继续安装"
+ fi
+ else
+ print_info "未发现pip安装的hmdriver2,将进行全新安装"
+ fi
+
+ return 0
+}
+
+# 从源码安装
+install_from_source() {
+ print_step "从源码构建并安装..."
+
+ cd "$SCRIPT_DIR"
+
+ # 安装构建工具
+ print_info "安装构建工具..."
+ if ! $PIP_CMD install build &>/dev/null; then
+ print_error "安装构建工具失败"
+ return 1
+ fi
+
+ # 构建wheel包
+ print_info "构建独立安装包..."
+ if $PYTHON_CMD -m build &>/dev/null; then
+ print_success "构建完成"
+
+ # 查找生成的wheel文件
+ local wheel_file=$(find dist -name "*.whl" -type f | head -1)
+ if [[ -n "$wheel_file" ]]; then
+ print_info "安装独立版本..."
+ if $PIP_CMD install "$wheel_file" --force-reinstall; then
+ print_success "独立版本安装完成"
+
+ # 清理构建文件
+ rm -rf build/ dist/ *.egg-info/ 2>/dev/null
+ return 0
+ else
+ print_error "独立版本安装失败"
+ return 1
+ fi
+ else
+ print_error "未找到构建的wheel文件"
+ return 1
+ fi
+ else
+ print_error "构建wheel包失败,请检查项目配置"
+ return 1
+ fi
+}
+
+# 验证安装
+verify_installation() {
+ print_step "验证安装..."
+
+ local result
+ result=$($PYTHON_CMD -c "
+import os
+import sys
+import tempfile
+
+# 保存原始工作目录
+original_cwd = os.getcwd()
+
+try:
+ # 创建临时目录并切换到该目录
+ temp_dir = tempfile.mkdtemp()
+ os.chdir(temp_dir)
+
+ try:
+ # 确保不导入当前项目中的hmdriver2
+ import hmdriver2
+ print('SUCCESS')
+ print(f'版本: {getattr(hmdriver2, \"__version__\", \"unknown\")}')
+ print(f'路径: {hmdriver2.__file__}')
+
+ # 检查是否在site-packages中
+ if 'site-packages' in hmdriver2.__file__:
+ print('位置: site-packages (正确)')
+ else:
+ print('位置: 非site-packages (可能有问题)')
+
+ finally:
+ # 恢复原始工作目录
+ os.chdir(original_cwd)
+
+ # 尝试清理临时目录,失败也不影响验证结果
+ try:
+ import shutil
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ except:
+ pass # 忽略清理错误
+
+except ImportError as e:
+ print(f'IMPORT_ERROR: {e}')
+except Exception as e:
+ print(f'OTHER_ERROR: {e}')
+" 2>&1)
+
+ if echo "$result" | grep -q "SUCCESS"; then
+ print_success "安装验证通过"
+ echo -e "${GREEN}$result${NC}" | grep -v "SUCCESS"
+
+ # 额外检查:确认安装在site-packages
+ if echo "$result" | grep -q "site-packages"; then
+ print_success "✅ 已安装到site-packages,可在任意项目中使用"
+ else
+ print_warning "⚠️ 安装位置可能有问题,建议检查Python环境"
+ fi
+ return 0
+ else
+ print_error "安装验证失败"
+ echo -e "${RED}$result${NC}"
+ return 1
+ fi
+}
+
+
+# 显示使用说明
+show_usage_info() {
+ local project_version=$(get_project_version)
+
+ # 获取Python解释器的完整路径
+ local python_full_path
+ python_full_path=$($PYTHON_CMD -c "import sys; print(sys.executable)" 2>/dev/null)
+
+ echo ""
+ echo -e "${CYAN}${INFO} 重要提醒:${NC}"
+ echo -e " ${RED}在其他项目中必须使用此Python解释器:${NC}"
+ echo -e " ${YELLOW}${python_full_path:-$PYTHON_CMD}${NC}"
+ echo ""
+
+ echo -e "${GREEN}${SUCCESS} 独立版本 v$project_version 安装完成!现在可以在任意项目中使用 hmdriver2${NC}"
+}
+
+# 清理函数
+cleanup() {
+ if [[ $? -ne 0 ]]; then
+ print_error "安装失败"
+ print_info "你可以尝试重新运行脚本或安装发布版本: pip install hmdriver2"
+ fi
+}
+
+
+# =============================================================================
+# 主程序
+# =============================================================================
+
+main() {
+ # 检查命令行参数
+ if [[ -n "${1:-}" ]]; then
+ print_error "此脚本不需要任何参数,直接运行即可"
+ print_info "用法: $0"
+ exit 1
+ fi
+
+ # 设置错误处理
+ trap cleanup EXIT
+
+ print_banner
+
+ print_info "操作系统: $(detect_os)"
+
+ # 1. 检测Python环境
+ print_step "检测Python环境..."
+ if ! detect_python; then
+ print_error "未找到Python 3.8+,请先安装Python"
+ exit 1
+ fi
+
+ print_success "Python命令: $PYTHON_CMD"
+ print_success "Pip命令: $PIP_CMD"
+
+ # 2. 检查Python版本
+ if ! check_python_version; then
+ exit 1
+ fi
+
+ # 3. 检查项目文件
+ if ! check_project_files; then
+ exit 1
+ fi
+
+ # 4. 确认安装
+ echo ""
+ local project_version=$(get_project_version)
+ echo -e "${CYAN}─────────────────────────────────────────────────────────────${NC}"
+ print_info "准备安装hmdriver2独立版本 ${GREEN}v$project_version${NC}"
+ print_warning "这将替换任何现有的hmdriver2安装"
+ echo -e "${CYAN}─────────────────────────────────────────────────────────────${NC}"
+
+ read -p "$(echo -e ${YELLOW}"确认继续安装?(y/N): "${NC})" confirm_install
+ if [[ ! "$confirm_install" =~ ^[Yy]$ ]]; then
+ print_info "安装已取消"
+ exit 0
+ fi
+
+ echo ""
+ print_step "开始安装..."
+
+ # 5. 检查并处理已安装版本
+ check_and_handle_existing
+
+ # 6. 安装依赖
+ if ! install_dependencies; then
+ exit 1
+ fi
+
+ # 7. 从源码安装
+ if ! install_from_source; then
+ exit 1
+ fi
+
+ # 8. 验证安装
+ if ! verify_installation; then
+ exit 1
+ fi
+
+ # 9. 显示使用说明
+ show_usage_info
+
+ # 禁用错误处理,安装成功
+ trap - EXIT
+}
+
+# 运行主程序
+main "$@"
diff --git a/pyproject.toml b/pyproject.toml
index 5026ad2..3a10363 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "hmdriver2"
-version = "1.0.0"
+version = "1.5.1"
description = "UI Automation Framework for Harmony Next"
authors = ["codematrixer "]
license = "MIT"
diff --git a/tests/demo.py b/tests/demo.py
new file mode 100644
index 0000000..3e8f8c7
--- /dev/null
+++ b/tests/demo.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+HMDriver2 使用示例:正则 + WebDriver
+"""
+
+import pytest
+from hmdriver2.driver import Driver
+from hmdriver2._uiobject import Match
+from selenium.webdriver.common.by import By
+
+
+@pytest.fixture
+def d():
+ """设备连接 fixture"""
+ d = Driver("2UCUT24109029868")
+ yield d
+ d.close()
+
+
+def test_regex_demo(d):
+ """匹配模式示例 - 注意:RE 和 REI 暂时无效"""
+ print(f"设备: {d.serial}")
+
+ # 四种匹配方式对比
+ print(f"完全匹配 EQ: {d(text='设置').exists()}") # 默认 EQ 模式
+ print(f"包含匹配 IN: {d(text=('设置', Match.IN)).exists()}")
+ print(f"开头匹配 SW: {d(text=('设置', Match.SW)).exists()}") # 以"设置"开头
+ print(f"结尾匹配 EW: {d(text=('确定', Match.EW)).exists()}") # 以"确定"结尾
+
+ # 正则匹配示例(暂时无效)
+ print(f"正则匹配 RE: {d(text=('设.*', Match.RE)).exists()}") # 正则模式
+ print(f"忽略大小写 REI: {d(text=('SET', Match.REI)).exists()}") # 忽略大小写
+
+ print("匹配模式示例完成(注意:RE 和 REI 功能暂时无效)")
+
+
+def test_webdriver_demo(d):
+ """WebDriver 示例"""
+ # 启动浏览器
+ d.start_app("com.huawei.hmos.browser")
+
+ # 连接浏览器
+ wd = d.webdriver.connect("com.huawei.hmos.browser")
+
+ # 访问网页
+ wd.get("https://www.baidu.com")
+ print(f"页面标题: {wd.title}")
+
+ # 查找元素
+ search_box = wd.find_element(By.ID, "index-kw")
+ search_box.send_keys("HarmonyOS")
+
+ # 关闭
+ d.webdriver.close()
+ print("WebDriver 示例完成")
+
+