├── .flake8 ├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── DEVELOP.md ├── hierarchy.json └── img │ ├── arch.png │ ├── gesture.gif │ └── ui-viewer.png ├── example.py ├── hmdriver2 ├── __init__.py ├── _client.py ├── _gesture.py ├── _screenrecord.py ├── _swipe.py ├── _uiobject.py ├── _xpath.py ├── assets │ ├── uitest_agent_v1.0.7.so │ └── uitest_agent_v1.1.0.so ├── driver.py ├── exception.py ├── hdc.py ├── proto.py └── utils.py ├── pyproject.toml ├── runtest.sh └── tests ├── __init__.py ├── test_client.py ├── test_driver.py └── test_element.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 150 3 | ignore = 4 | E501 5 | F401 6 | E402 7 | W292 8 | F403 9 | F821 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | # on: 4 | # release: 5 | # types: [created] 6 | on: 7 | push: 8 | tags: 9 | - '*.*.*' 10 | 11 | jobs: 12 | build-n-publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine poetry 26 | poetry lock 27 | poetry install 28 | 29 | - name: Get the version from the tag 30 | id: get_version 31 | run: echo "::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}" 32 | 33 | - name: Update version in pyproject.toml 34 | run: | 35 | version=${{ steps.get_version.outputs.VERSION }} 36 | poetry version $version 37 | 38 | - name: Build a binary wheel and a source tarball 39 | run: poetry build 40 | 41 | - name: Publish distribution 📦 to PyPI 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | with: 44 | password: ${{ secrets.PYPI_API_TOKEN }} 45 | packages-dir: dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */__pycache__/* 3 | *.egg 4 | *.egg-info 5 | .idea 6 | .vscode 7 | .DS_Store 8 | .python-version 9 | dist 10 | build 11 | imgs 12 | eggs 13 | bin 14 | venv 15 | pubilc 16 | tmp 17 | logs 18 | *.ipa 19 | .mp4 20 | *.apk 21 | *.zip 22 | *.csv 23 | *.log 24 | .vscode* 25 | poetry.lock 26 | .pytest.cache 27 | hypium_api*.md 28 | example-dev.py 29 | test*.mp4 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 matrixer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hmdriver2 2 | [![github actions](https://github.com/codematrixer/hmdriver2/actions/workflows/release.yml/badge.svg)](https://github.com/codematrixer/hmdriver2/actions) 3 | [![pypi version](https://img.shields.io/pypi/v/hmdriver2.svg)](https://pypi.python.org/pypi/hmdriver2) 4 | ![python](https://img.shields.io/pypi/pyversions/hmdriver2.svg) 5 | [![downloads](https://pepy.tech/badge/hmdriver2)](https://pepy.tech/project/hmdriver2) 6 | 7 | 8 | > 写这个项目前github上已有个叫`hmdriver`的项目,但它是侵入式(需要提前在手机端安装一个testRunner app);另外鸿蒙官方提供的hypium自动化框架,使用较为复杂,依赖繁杂。于是决定重写一套。 9 | 10 | 11 | **hmdriver2** 是一款支持`HarmonyOS NEXT`系统的UI自动化框架,**无侵入式**,提供应用管理,UI操作,元素定位等功能,轻量高效,上手简单,快速实现鸿蒙应用自动化测试需求。 12 | 13 | ![arch](https://i.ibb.co/d603wQn/arch.png) 14 | 15 | 16 | *微信交流群(永久生效)* 17 | 18 | wechat 19 | 20 | # Key idea 21 | - **无侵入式** 22 | - 无需提前在手机端安装testRunner APP(类似atx app) 23 | - **易上手** 24 | - 在PC端编写Python脚本实现自动化 25 | - 对齐Android端 [uiautomator2](https://github.com/openatx/uiautomator2) 的脚本编写姿势 26 | - **轻量高效** 27 | - 摒弃复杂依赖(几乎0依赖),即插即用 28 | - 操作响应快,低延时 29 | 30 | # Feature 31 | - 支持应用管理 32 | - 应用启动,停止 33 | - 应用安装,卸载 34 | - 应用数据清理 35 | - 获取应用列表,应用详情等 36 | - 支持设备操作 37 | - 获取设备信息,分辨率,旋转状态等 38 | - 屏幕解锁,亮屏,息屏 39 | - Key Events 40 | - 文件操作 41 | - 屏幕截图 42 | - 屏幕录屏 43 | - 手势操作(点击,滑动,输入,复杂手势) 44 | - 支持控件操作 45 | - 控件查找(联合查找,模糊查找,相对查找,xpath查找) 46 | - 控件信息获取 47 | - 控件点击,长按,拖拽,缩放 48 | - 文本输入,清除 49 | - 获取控件树 50 | - 支持Toast获取 51 | - UI Inspector 52 | - [TODO] 全场景弹窗处理 53 | - [TODO] 操作标记 54 | 55 | 56 | 57 | # QUICK START 58 | 59 | 1. 配置鸿蒙`HDC`环境 60 | 1. 下载 [Command Line Tools](https://developer.huawei.com/consumer/cn/download/) 并解压 61 | 2. `hdc`文件在`command-line-tools/sdk/default/openharmony/toolchains`目录下 62 | 3. 配置环境变量,macOS为例,在~/.bash_profile 或者 ~/.zshrc文件中添加 63 | ```bash 64 | export HM_SDK_HOME="/Users/develop/command-line-tools/sdk/default" //请以sdk实际安装目录为准 65 | export PATH=$PATH:$HM_SDK_HOME/hms/toolchains:$HM_SDK_HOME/openharmony/toolchains 66 | export HDC_SERVER_PORT=7035 67 | ``` 68 | 69 | 2. 电脑插上手机,开启USB调试,确保执行`hdc list targets` 可以看到设备序列号 70 | 71 | 72 | 3. 安装`hmdirver2` 基础库 73 | ```bash 74 | pip3 install -U hmdriver2 75 | ``` 76 | 如果需要使用[屏幕录屏](#屏幕录屏) 功能,则需要安装额外依赖`opencv-python` 77 | (由于`opencv-python`比较大,因此没有写入到主依赖中,按需安装) 78 | 79 | ```bash 80 | pip3 install -U "hmdriver2[opencv-python]" 81 | ``` 82 | 83 | 84 | 4. 接下来就可以愉快的进行脚本开发了 😊😊 85 | ```python 86 | from hmdriver2.driver import Driver 87 | 88 | d = Driver() 89 | 90 | print(d.device_info) 91 | # ouput: DeviceInfo(productName='HUAWEI Mate 60 Pro', model='ALN-AL00', sdkVersion='12', sysVersion='ALN-AL00 5.0.0.60(SP12DEVC00E61R4P9log)', cpuAbi='arm64-v8a', wlanIp='172.31.125.111', displaySize=(1260, 2720), displayRotation=) 92 | 93 | d.start_app("com.kuaishou.hmapp") 94 | d(text="精选").click() 95 | d.swipe(0.5, 0.8, 0.5, 0.4) 96 | ... 97 | ``` 98 | 99 | # UI inspector 100 | UI 控件树可视化工具,查看控件树层级,获取控件详情。 101 | 102 | ![ui-viewer](https://i.ibb.co/82BrJ1H/harmony.png) 103 | 104 | 105 | 详细介绍请看 [ui-viewer](https://github.com/codematrixer/ui-viewer) 106 | 107 | 108 | # Environment 109 | 如何需要连接远端的HDC Server来实现操作远端设备执行自动化,运行脚本前需要设置环境变量 110 | ```bash 111 | export HDC_SERVER_HOST=127.0.0.1 # Replace with the remote host 112 | export HDC_SERVER_PORT=8710 113 | ``` 114 | 115 | PS 如果需要移除环境变量,执行以下命令 116 | ```bash 117 | unset HDC_SERVER_HOST 118 | unset HDC_SERVER_PORT 119 | ``` 120 | 121 | --- 122 | 123 | # API Documents 124 | 125 | - [API Documents](#api-documents) 126 | - [初始化Driver](#初始化driver) 127 | - [App管理](#app管理) 128 | - [安装App](#安装app) 129 | - [卸载App](#卸载app) 130 | - [启动App](#启动app) 131 | - [停止App](#停止app) 132 | - [清除App数据](#清除app数据) 133 | - [获取App详情](#获取app详情) 134 | - [获取App main ability](#获取App-main-ability) 135 | - [设备操作](#设备操作) 136 | - [获取设备信息](#获取设备信息) 137 | - [获取设备分辨率](#获取设备分辨率) 138 | - [获取设备旋转状态](#获取设备旋转状态) 139 | - [设置设备旋转](#设置设备旋转) 140 | - [Home](#home) 141 | - [返回](#返回) 142 | - [亮屏](#亮屏) 143 | - [息屏](#息屏) 144 | - [屏幕解锁](#屏幕解锁) 145 | - [Key Events](#key-events) 146 | - [执行 HDC 命令](#执行-hdc-命令) 147 | - [打开URL (schema)](#打开url-schema) 148 | - [文件操作](#文件操作) 149 | - [屏幕截图](#屏幕截图) 150 | - [屏幕录屏](#屏幕录屏) 151 | - [Device Touch](#device-touch) 152 | - [单击](#单击) 153 | - [双击](#双击) 154 | - [长按](#长按) 155 | - [滑动](#滑动) 156 | - [滑动 ext](#滑动-ext) 157 | - [输入](#输入) 158 | - [复杂手势](#复杂手势) 159 | - [控件操作](#控件操作) 160 | - [常规选择器](#常规选择器) 161 | - [控件查找](#控件查找) 162 | - [控件信息](#控件信息) 163 | - [控件数量](#控件数量) 164 | - [控件点击](#控件点击) 165 | - [控件双击](#控件双击) 166 | - [控件长按](#控件长按) 167 | - [控件拖拽](#控件拖拽) 168 | - [控件缩放](#控件缩放) 169 | - [控件输入](#控件输入) 170 | - [文本清除](#文本清除) 171 | - [XPath选择器](#xpath选择器) 172 | - [xpath控件是否存在](#xpath控件是否存在) 173 | - [xpath控件点击](#xpath控件点击) 174 | - [xpath控件双击](#xpath控件双击) 175 | - [xpath控件长按](#xpath控件长按) 176 | - [xpath控件输入](#xpath控件输入) 177 | - [获取控件树](#获取控件树) 178 | - [获取Toast](#获取toast) 179 | 180 | 181 | ## 初始化Driver 182 | ```python 183 | from hmdriver2.driver import Driver 184 | 185 | d = Driver() 186 | # d = Driver("FMR0223C13000649") 187 | ``` 188 | 189 | 参数`serial` 通过`hdc list targets` 命令获取;如果不传serial,则默认读取`hdc list targets`的第一个设备 190 | 191 | 初始化driver后,下面所有的操作都是调用dirver实现 192 | 193 | ## App管理 194 | ### 安装App 195 | ```python 196 | d.install_app("/Users/develop/harmony_prj/demo.hap") 197 | ``` 198 | 199 | ### 卸载App 200 | ```python 201 | d.uninstall_app("com.kuaishou.hmapp") 202 | ``` 203 | 传入的参数是`package_name`,可通过hdc命令获取`hdc shell bm dump -a` 204 | 205 | ### 启动App 206 | 207 | ```python 208 | d.start_app("com.kuaishou.hmapp") 209 | 210 | d.start_app("com.kuaishou.hmapp", "EntryAbility") 211 | ``` 212 | `package_name`, `page_name`分别为包名和ability name,可以通过hdc命令获取:`hdc shell aa dump -l` 213 | 214 | 不传`page_name`时,默认会使用main ability作为`page_name` 215 | 216 | ### 停止App 217 | ```python 218 | d.stop_app("com.kuaishou.hmapp") 219 | ``` 220 | 221 | 222 | ### 清除App数据 223 | ```python 224 | d.clear_app("com.kuaishou.hmapp") 225 | ``` 226 | 该方法表示清除App数据和缓存 227 | 228 | ### 获取App详情 229 | ```python 230 | d.get_app_info("com.kuaishou.hmapp") 231 | ``` 232 | 输出的数据结构是Dict, 内容如下 233 | ```bash 234 | { 235 | "appId": "com.kuaishou.hmapp_BIS88rItfUAk+V9Y4WZp2HgIZ/JeOgvEBkwgB/YyrKiwrWhje9Xn2F6Q7WKFVM22RdIR4vFsG14A7ombgQmIIxU=", 236 | "appIdentifier": "5765880207853819885", 237 | "applicationInfo": { 238 | ... 239 | "bundleName": "com.kuaishou.hmapp", 240 | "codePath": "/data/app/el1/bundle/public/com.kuaishou.hmapp", 241 | "compileSdkType": "HarmonyOS", 242 | "compileSdkVersion": "4.1.0.73", 243 | "cpuAbi": "arm64-v8a", 244 | "deviceId": "PHONE-001", 245 | ... 246 | "vendor": "快手", 247 | "versionCode": 999999, 248 | "versionName": "12.2.40" 249 | }, 250 | "compatibleVersion": 40100011, 251 | "cpuAbi": "", 252 | "hapModuleInfos": [ 253 | ... 254 | ], 255 | "reqPermissions": [ 256 | "ohos.permission.ACCELEROMETER", 257 | "ohos.permission.GET_NETWORK_INFO", 258 | "ohos.permission.GET_WIFI_INFO", 259 | "ohos.permission.INTERNET", 260 | ... 261 | ], 262 | ... 263 | "vendor": "快手", 264 | "versionCode": 999999, 265 | "versionName": "12.2.40" 266 | } 267 | ``` 268 | 269 | ### 获取App main ability 270 | ```python 271 | d.get_app_main_ability("com.kuaishou.hmapp") 272 | ``` 273 | 274 | 输出的数据结构是Dict, 内容如下 275 | 276 | ``` 277 | { 278 | "name": "EntryAbility", 279 | "moduleName": "kwai", 280 | "moduleMainAbility": "EntryAbility", 281 | "mainModule": "kwai", 282 | "isLauncherAbility": true, 283 | "score": 2 284 | } 285 | ``` 286 | 287 | ## 设备操作 288 | ### 获取设备信息 289 | ```python 290 | from hmdriver2.proto import DeviceInfo 291 | 292 | info: DeviceInfo = d.device_info 293 | ``` 294 | 输入内容如下 295 | ```bash 296 | DeviceInfo(productName='HUAWEI Mate 60 Pro', model='ALN-AL00', sdkVersion='12', sysVersion='ALN-AL00 5.0.0.60(SP12DEVC00E61R4P9log)', cpuAbi='arm64-v8a', wlanIp='172.31.125.111', displaySize=(1260, 2720), displayRotation=) 297 | ``` 298 | 然后就可以获取你想要的值, 比如 299 | ```python 300 | info.productName 301 | info.model 302 | info.wlanIp 303 | info.sdkVersion 304 | info.sysVersion 305 | info.cpuAbi 306 | info.displaySize 307 | info.displayRotation 308 | ``` 309 | 310 | ### 获取设备分辨率 311 | ```python 312 | w, h = d.display_size 313 | 314 | # outout: (1260, 2720) 315 | ``` 316 | 317 | ### 获取设备旋转状态 318 | ```python 319 | from hmdriver2.proto import DisplayRotation 320 | 321 | rotation = d.display_rotation 322 | # ouput: DisplayRotation.ROTATION_0 323 | ``` 324 | 325 | 设备旋转状态包括: 326 | ```python 327 | ROTATION_0 = 0 # 未旋转 328 | ROTATION_90 = 1 # 顺时针旋转90度 329 | ROTATION_180 = 2 # 顺时针旋转180度 330 | ROTATION_270 = 3 # 顺时针旋转270度 331 | ``` 332 | 333 | ### 设置设备旋转 334 | ```python 335 | from hmdriver2.proto import DisplayRotation 336 | 337 | # 旋转180度 338 | d.set_display_rotation(DisplayRotation.ROTATION_180) 339 | ``` 340 | 341 | 342 | ### Home 343 | ```python 344 | d.go_home() 345 | ``` 346 | ### 返回 347 | ```python 348 | d.go_back() 349 | ``` 350 | ### 亮屏 351 | ```python 352 | d.screen_on() 353 | ``` 354 | 355 | ### 息屏 356 | ```python 357 | d.screen_off() 358 | ``` 359 | 360 | ### 屏幕解锁 361 | ```python 362 | d.unlock() 363 | ``` 364 | 365 | ### Key Events 366 | ```python 367 | from hmdriver2.proto import KeyCode 368 | 369 | d.press_key(KeyCode.POWER) 370 | ``` 371 | 详细的Key code请参考 [harmony key code](https://github.com/codematrixer/hmdriver2/blob/4d7bceaded947bd63d737de180064679ad4c77b8/hmdriver2/proto.py#L133) 372 | 373 | 374 | ### 执行 HDC 命令 375 | ```python 376 | data = d.shell("ls -l /data/local/tmp") 377 | 378 | print(data.output) 379 | ``` 380 | 这个方法等价于执行 `hdc shell ls -l /data/local/tmp` 381 | 382 | Notes: `HDC`详细的命令解释参考:[awesome-hdc](https://github.com/codematrixer/awesome-hdc) 383 | 384 | 385 | ### 打开URL (schema) 386 | ```python 387 | d.open_url("http://www.baidu.com") 388 | 389 | d.open_url("kwai://myprofile") 390 | 391 | ``` 392 | 393 | 394 | ### 文件操作 395 | ```python 396 | # 将手机端文件下载到本地电脑 397 | d.pull_file(rpath, lpath) 398 | 399 | # 将本地电脑文件推送到手机端 400 | d.push_file(lpath, rpath) 401 | ``` 402 | 参数`rpath`表示手机端文件路径,`lpath`表示本地电脑文件路径 403 | 404 | 405 | ### 屏幕截图 406 | ```python 407 | d.screenshot(path) 408 | 409 | ``` 410 | 参数`path`表示截图保存在本地电脑的文件路径 411 | 412 | ### 屏幕录屏 413 | 方式一 414 | ```python 415 | # 开启录屏 416 | d.screenrecord.start("test.mp4") 417 | 418 | # do somethings 419 | time.sleep(5) 420 | 421 | # 结束录屏 422 | d.screenrecord.stop() 423 | ``` 424 | 上述方式如果录屏过程中,脚本出现异常时,`stop`无法被调用,导致资源泄漏,需要加上try catch 425 | 426 | 【推荐】方式二 ⭐️⭐️⭐️⭐️⭐️ 427 | ```python 428 | with d.screenrecord.start("test2.mp4"): 429 | # do somethings 430 | time.sleep(5) 431 | ``` 432 | 通过上下文语法,在录屏结束时框架会自动调用`stop` 清理资源 433 | 434 | Notes: 使用屏幕录屏需要依赖`opencv-python` 435 | ```bash 436 | pip3 install -U "hmdriver[opencv-python]" 437 | ``` 438 | 439 | ### Device Touch 440 | #### 单击 441 | ```python 442 | d.click(x, y) 443 | 444 | # eg. 445 | d.click(200, 300) 446 | d.click(0.4, 0.6) 447 | ``` 448 | 参数`x`, `y`表示点击的坐标,可以为绝对坐标值,也可以为相当坐标(屏幕百分比) 449 | 450 | #### 双击 451 | ```python 452 | d.double_click(x, y) 453 | 454 | # eg. 455 | d.double_click(500, 1000) 456 | d.double_click(0.5, 0.4) 457 | ``` 458 | #### 长按 459 | ```python 460 | d.long_click(x, y) 461 | 462 | # eg. 463 | d.long_click(500, 1000) 464 | d.long_click(0.5, 0.4) 465 | ``` 466 | #### 滑动 467 | ```python 468 | d.swipe(x1, y1, x2, y2, spped) 469 | 470 | # eg. 471 | d.swipe(600, 2600, 600, 1200, speed=2000) # 上滑 472 | d.swipe(0.5, 0.8, 0.5, 0.4, speed=2000) 473 | ``` 474 | - `x1`, `y1`表示滑动的起始点,`x2`, `y2`表示滑动的终点 475 | - `speed`为滑动速率, 范围:200~40000, 不在范围内设为默认值为2000, 单位: 像素点/秒 476 | 477 | #### 滑动 ext 478 | ```python 479 | 480 | d.swipe_ext("up") # 向上滑动,"left", "right", "up", "down" 481 | d.swipe_ext("right", scale=0.8) # 向右滑动,滑动距离为屏幕宽度的80% 482 | d.swipe_ext("up", box=(0.2, 0.2, 0.8, 0.8)) # 在屏幕 (0.2, 0.2) -> (0.8, 0.8) 这个区域上滑 483 | 484 | # 使用枚举作为参数 485 | from hmdriver2.proto import SwipeDirection 486 | d.swipe_ext(SwipeDirection.DOWN) # 向下滑动 487 | ``` 488 | - `direction`表示滑动方向,可以为`up`, `down`, `left`, `right`, 也可以为`SwipeDirection`的枚举值 489 | - `scale`表示滑动距离百分比,范围:0.1~1.0, 默认值为0.8 490 | - `box`表示滑动区域,格式为`(x1, y1, x2, y2)`, 表示滑动区域的左上角和右下角的坐标,可以为绝对坐标值,也可以为相当坐标(屏幕百分比) 491 | 492 | Notes: `swipe_ext`和`swipe`的区别在于swipe_ext可以指定滑动区域,并且可以指定滑动方向,更简洁灵活 493 | 494 | 495 | #### 复杂手势 496 | 复杂手势就是手指按下`start`,移动`move`,暂停`pause`的集合,最后运行`action` 497 | 498 | ```python 499 | g = d.gesture 500 | 501 | g.start(x1, y1, interval=0.5) 502 | g.move(x2, y2) 503 | g.pause(interval=1) 504 | g.move(x3, y3) 505 | g.action() 506 | ``` 507 | 也支持链式调用(推荐) 508 | ```python 509 | d.gesture.start(x1, y1, interval=.5).move(x2, y2).pause(interval=1).move(x3, y3).action() 510 | ``` 511 | 512 | 参数`x`, `y`表示坐标位置,可以为绝对坐标值,也可以为相当坐标(屏幕百分比),`interval`表示手势持续的时间,单位秒。 513 | 514 | 如果只有start手势,则等价于点击: 515 | ```python 516 | d.gesture.start(x, y).action() # click 517 | 518 | # 等价于 519 | d.click(x, y) 520 | ``` 521 | 522 | *如下是一个复杂手势的效果展示* 523 | 524 | ![Watch the gif](https://i.ibb.co/PC76PRD/gesture.gif) 525 | 526 | 527 | #### 输入 528 | ```python 529 | d.input_text(text) 530 | 531 | # eg. 532 | d.input_text("adbcdfg") 533 | ``` 534 | 参数`x`, `y`表示输入的位置,`text`表示输入的文本 535 | 536 | 537 | ## 控件操作 538 | 539 | ### 常规选择器 540 | 控件查找支持这些`by`属性 541 | - `id` 542 | - `key` 543 | - `text` 544 | - `type` 545 | - `description` 546 | - `clickable` 547 | - `longClickable` 548 | - `scrollable` 549 | - `enabled` 550 | - `focused` 551 | - `selected` 552 | - `checked` 553 | - `checkable` 554 | - `isBefore` 555 | - `isAfter` 556 | 557 | Notes: 获取控件属性值可以配合 [UI inspector](https://github.com/codematrixer/ui-viewer) 工具查看 558 | 559 | **普通定位** 560 | ```python 561 | d(text="tab_recrod") 562 | 563 | d(id="drag") 564 | 565 | # 定位所有`type`为Button的元素,选中第0个 566 | d(type="Button", index=0) 567 | ``` 568 | Notes:当同一界面有多个属性相同的元素时,`index`属性非常实用 569 | 570 | **模糊定位TODO** 571 | 572 | **组合定位** 573 | 574 | 指定多个`by`属性进行元素定位 575 | ```python 576 | # 定位`type`为Button且`text`为tab_recrod的元素 577 | d(type="Button", text="tab_recrod") 578 | ``` 579 | 580 | **相对定位** 581 | ```python 582 | # 定位`text`为showToast的元素的前面一个元素 583 | d(text="showToast", isAfter=True) 584 | 585 | # 定位`id`为drag的元素的后面一个元素 586 | d(id="drag", isBefore=True) 587 | ``` 588 | 589 | #### 控件查找 590 | 结合上面讲的控件选择器,就可以进行元素的查找 591 | ```python 592 | d(text="tab_recrod").exists() 593 | d(type="Button", text="tab_recrod").exists() 594 | d(text="tab_recrod", isAfter=True).exists() 595 | 596 | # 返回 True or False 597 | 598 | d(text="tab_recrod").find_component() 599 | # 当没找到返回None 600 | ``` 601 | 602 | #### 控件信息 603 | 604 | ```python 605 | d(text="tab_recrod").info 606 | 607 | # output: 608 | { 609 | "id": "", 610 | "key": "", 611 | "type": "Button", 612 | "text": "tab_recrod", 613 | "description": "", 614 | "isSelected": False, 615 | "isChecked": False, 616 | "isEnabled": True, 617 | "isFocused": False, 618 | "isCheckable": False, 619 | "isClickable": True, 620 | "isLongClickable": False, 621 | "isScrollable": False, 622 | "bounds": { 623 | "left": 539, 624 | "top": 1282, 625 | "right": 832, 626 | "bottom": 1412 627 | }, 628 | "boundsCenter": { 629 | "x": 685, 630 | "y": 1347 631 | } 632 | } 633 | ``` 634 | 也可以单独调用对应的属性 635 | 636 | ```python 637 | d(text="tab_recrod").id 638 | d(text="tab_recrod").key 639 | d(text="tab_recrod").type 640 | d(text="tab_recrod").text 641 | d(text="tab_recrod").description 642 | d(text="tab_recrod").isSelected 643 | d(text="tab_recrod").isChecked 644 | d(text="tab_recrod").isEnabled 645 | d(text="tab_recrod").isFocused 646 | d(text="tab_recrod").isCheckable 647 | d(text="tab_recrod").isClickable 648 | d(text="tab_recrod").isLongClickable 649 | d(text="tab_recrod").isScrollable 650 | d(text="tab_recrod").bounds 651 | d(text="tab_recrod").boundsCenter 652 | ``` 653 | 654 | 655 | #### 控件数量 656 | ```python 657 | d(type="Button").count # 输出当前页面`type`为Button的元素数量 658 | 659 | # 也可以这样写 660 | len(d(type="Button")) 661 | ``` 662 | 663 | 664 | #### 控件点击 665 | ```python 666 | d(text="tab_recrod").click() 667 | d(type="Button", text="tab_recrod").click() 668 | 669 | d(text="tab_recrod").click_if_exists() 670 | 671 | ``` 672 | 以上两个方法有一定的区别 673 | - `click` 如果元素没找到,会报错`ElementNotFoundError` 674 | - `click_if_exists` 即使元素没有找到,也不会报错,相当于跳过 675 | 676 | #### 控件双击 677 | ```python 678 | d(text="tab_recrod").double_click() 679 | d(type="Button", text="tab_recrod").double_click() 680 | ``` 681 | 682 | #### 控件长按 683 | ```python 684 | d(text="tab_recrod").long_click() 685 | d(type="Button", text="tab_recrod").long_click() 686 | ``` 687 | 688 | 689 | #### 控件拖拽 690 | ```python 691 | from hmdriver2.proto import ComponentData 692 | 693 | componentB: ComponentData = d(type="ListItem", index=1).find_component() 694 | 695 | # 将元素拖动到元素B上 696 | d(type="ListItem").drag_to(componentB) 697 | 698 | ``` 699 | `drag_to`的参数`component`为`ComponentData`类型 700 | 701 | #### 控件缩放 702 | ```python 703 | # 将元素按指定的比例进行捏合缩小1倍 704 | d(text="tab_recrod").pinch_in(scale=0.5) 705 | 706 | # 将元素按指定的比例进行捏合放大2倍 707 | d(text="tab_recrod").pinch_out(scale=2) 708 | ``` 709 | 其中`scale`参数为放大和缩小比例 710 | 711 | 712 | #### 控件输入 713 | ```python 714 | d(text="tab_recrod").input_text("abc") 715 | ``` 716 | 717 | #### 文本清除 718 | ```python 719 | d(text="tab_recrod").clear_text() 720 | ``` 721 | 722 | ### XPath选择器 723 | xpath选择器基于标准的xpath规范,也可以使用`//*[@属性="属性值"]`的样式(xpath lite) 724 | ```python 725 | d.xpath('//root[1]/Row[1]/Column[1]/Row[1]/Button[3]') 726 | d.xpath('//*[@text="showDialog"]') 727 | ``` 728 | 729 | 控件xpath路径获取可以配合 [UI inspector](https://github.com/codematrixer/ui-viewer) 工具查看 730 | 731 | #### xpath控件是否存在 732 | ```python 733 | d.xpath('//*[@text="showDialog"]').exists() # 返回True/False 734 | d.xpath('//root[1]/Row[1]/Column[1]/Row[1]/Button[3]').exists() 735 | ``` 736 | 737 | #### xpath控件点击 738 | ```python 739 | d.xpath('//*[@text="showDialog"]').click() 740 | d.xpath('//root[1]/Row[1]/Column[1]/Row[1]/Button[3]').click_if_exists() 741 | ``` 742 | 以上两个方法有一定的区别 743 | - `click` 如果元素没找到,会报错`XmlElementNotFoundError` 744 | - `click_if_exists` 即使元素没有找到,也不会报错,相当于跳过 745 | 746 | #### xpath控件双击 747 | ```python 748 | d.xpath('//*[@text="showDialog"]').double_click() 749 | ``` 750 | 751 | #### xpath控件长按 752 | ```python 753 | d.xpath('//*[@text="showDialog"]').long_click() 754 | ``` 755 | 756 | #### xpath控件输入 757 | ```python 758 | d.xpath('//*[@text="showDialog"]').input_text("adb") 759 | ``` 760 | 761 | ## 获取控件树 762 | ```python 763 | d.dump_hierarchy() 764 | ``` 765 | 输出控件树格式参考 [hierarchy.json](/docs/hierarchy.json) 766 | 767 | 768 | ## 获取Toast 769 | ```python 770 | # 启动toast监控 771 | d.toast_watcher.start() 772 | 773 | # do something 比如触发toast的操作 774 | d(text="xx").click() 775 | 776 | # 获取toast 777 | toast = d.toast_watcher.get_toast() 778 | 779 | # output: 'testMessage' 780 | ``` 781 | 782 | # 鸿蒙Uitest协议 783 | 784 | See [DEVELOP.md](/docs/DEVELOP.md) 785 | 786 | 787 | # 拓展阅读 788 | [hmdriver2 发布:开启鸿蒙 NEXT 自动化新时代](https://testerhome.com/topics/40667) 789 | 790 | 791 | # Contributors 792 | [Contributors](https://github.com/codematrixer/hmdriver2/graphs/contributors) 793 | 794 | 795 | # Reference 796 | 797 | - https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/ut-V5 798 | - https://github.com/codematrixer/awesome-hdc 799 | - https://github.com/openatx/uiautomator2 800 | - https://github.com/mrx1203/hmdriver 801 | -------------------------------------------------------------------------------- /docs/DEVELOP.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 模块介绍 4 | ``` 5 | ├── README.md 6 | ├── docs 7 | │ ├── _hypium_api.md 8 | │ └── DEVELOP.md 9 | ├── hmdriver2 10 | │ ├── __init__.py 11 | │ ├── _client.py // 和鸿蒙uitest通信的客户端 12 | │ ├── _gesture.py // 复杂手势操作封装 13 | │ ├── _uiobject.py // ui控件对象, 提供操作控件和获取控件属性接口 14 | │ ├── asset 15 | │ │ └── agent.so // 鸿蒙uitest动态链路库 16 | │ ├── driver.py // ui自动化核心功能类, 提供设备点击/滑动操作, app启动停止等常用功能 17 | │ ├── exception.py 18 | │ ├── hdc.py // hdc命令封装 19 | │ └── proto.py 20 | │ └── utils.py 21 | ├── pyproject.toml 22 | ├── .flake8 23 | └── tests // 自测用例 24 | ├── __init__.py 25 | ├── test_client.py 26 | ├── test_driver.py 27 | └── test_element.py 28 | 29 | ``` 30 | 31 | 32 | # uitest协议 33 | 34 | ## By 35 | ### On.text 36 | 37 | **send** 38 | ``` 39 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"On.text","this":"On#seed","args":["精选"],"message_type":"hypium"},"request_id":"20240829202019513472","client":"127.0.0.1"} 40 | ``` 41 | **recv** 42 | ``` 43 | {"result":"On#1"} 44 | ``` 45 | 46 | ### On.id 47 | ### On.key 48 | ### On.type 49 | 50 | 51 | ### On.isAfter 52 | **send** 53 | ``` 54 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"On.isAfter","this":"On#seed","args":["On#3"],"message_type":"hypium"},"request_id":"20240830143213340263","client":"127.0.0.1"} 55 | ``` 56 | **recv** 57 | ``` 58 | {"result":"On#4"} 59 | ``` 60 | 61 | ### On.isBefore 62 | **send** 63 | ``` 64 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"On.isBefore","this":"On#seed","args":["On#3"],"message_type":"hypium"},"request_id":"20240830143213340263","client":"127.0.0.1"} 65 | ``` 66 | **recv** 67 | ``` 68 | {"result":"On#4"} 69 | ``` 70 | 71 | ## Driver 72 | ### create 73 | **send** 74 | ``` 75 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.create","this":null,"args":[],"message_type":"hypium"},"request_id":"20240830153517897539","client":"127.0.0.1"} 76 | ``` 77 | **recv** 78 | ``` 79 | {"result":["Component#0"]} 80 | ``` 81 | 82 | ### getDisplaySize 83 | **send** 84 | ``` 85 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.getDisplaySize","this":"Driver#0","args":[],"message_type":"hypium"},"request_id":"20240830151015274374","client":"127.0.0.1"} 86 | ``` 87 | **recv** 88 | ``` 89 | {"result":{"x":1260,"y":2720}} 90 | ``` 91 | 92 | ### getDisplayRotation 93 | **send** 94 | ``` 95 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.getDisplayRotation","this":"Driver#0","args":[],"message_type":"hypium"},"request_id":"20240830151015274374","client":"127.0.0.1"} 96 | ``` 97 | **recv** 98 | ``` 99 | {"result":0} 100 | {"result":1} 101 | {"result":2} 102 | {"result":3} 103 | ``` 104 | 105 | ### click 106 | **send** 107 | ``` 108 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.click","this":"Driver#0","args":[100,300],"message_type":"hypium"},"request_id":"20240830151533693140","client":"127.0.0.1"} 109 | ``` 110 | **recv** 111 | ``` 112 | {"result":null} 113 | ``` 114 | 115 | ### doubleClick 116 | **send** 117 | ``` 118 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.doubleClick","this":"Driver#0","args":[630,1360],"message_type":"hypium"},"request_id":"20240830152159243541","client":"127.0.0.1"} 119 | ``` 120 | **recv** 121 | ``` 122 | {"result":null} 123 | ``` 124 | 125 | ### longClick 126 | **send** 127 | ``` 128 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.longClick","this":"Driver#0","args":[630,1360],"message_type":"hypium"},"request_id":"20240830152159243541","client":"127.0.0.1"} 129 | ``` 130 | **recv** 131 | ``` 132 | {"result":null} 133 | ``` 134 | 135 | ### swipe 136 | **send** 137 | ``` 138 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.swipe","this":"Driver#0","args":[630,2176,630,1360,7344],"message_type":"hypium"},"request_id":"20240913123029322117","client":"127.0.0.1"} 139 | ``` 140 | **recv** 141 | ``` 142 | {"result":null} 143 | ``` 144 | 145 | 146 | ### findComponents 147 | **send** 148 | ``` 149 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.findComponents","this":"Driver#0","args":["On#1"],"message_type":"hypium"},"request_id":"20240830143210219186","client":"127.0.0.1"} 150 | ``` 151 | **recv** 152 | ``` 153 | {"result":["Component#7","Component#8"]} 154 | ``` 155 | 156 | ### findComponent 157 | **send** 158 | ``` 159 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.findComponent","this":"Driver#0","args":["On#2"],"message_type":"hypium"},"request_id":"20240830143211753489","client":"127.0.0.1"} 160 | ``` 161 | **recv** 162 | ``` 163 | {"result":"Component#1"} 164 | 165 | # {"result":null} 166 | ``` 167 | 168 | ### waitForComponent 169 | **send** 170 | ``` 171 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.waitForComponent","this":"Driver#0","args":["On#0",10000],"message_type":"hypium"},"request_id":"20240829202019518844","client":"127.0.0.1"} 172 | ``` 173 | **recv** 174 | ``` 175 | {"result":"Component#0"} 176 | ``` 177 | 178 | ### findWindow 179 | **send** 180 | ``` 181 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.findWindow","this":"Driver#0","args":[{"actived":true}],"message_type":"hypium"},"request_id":"20240829202019518844","client":"127.0.0.1"} 182 | ``` 183 | **recv** 184 | ``` 185 | {"result":"UiWindow#10"} 186 | ``` 187 | 188 | ### uiEventObserverOnce 189 | **send** 190 | ``` 191 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.uiEventObserverOnce","this":"Driver#0","args":["toastShow"],"message_type":"hypium"},"request_id":"20240905144543056211","client":"127.0.0.1"} 192 | ``` 193 | **recv** 194 | ``` 195 | {"result":true} 196 | ``` 197 | 198 | 199 | ### getRecentUiEvent 200 | **send** 201 | ``` 202 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.getRecentUiEvent","this":"Driver#0","args":[3000],"message_type":"hypium"},"request_id":"20240905143857794307","client":"127.0.0.1"} 203 | ``` 204 | **recv** 205 | ``` 206 | {"result":{"bundleName":"com.samples.test.uitest","text":"testMessage","type":"Toast"}} 207 | ``` 208 | 209 | ### PointerMatrix.create 210 | **send** 211 | ``` 212 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.create","this":null,"args":[1,104],"message_type":"hypium"},"request_id":"20240906204116056319"} 213 | ``` 214 | **recv** 215 | ``` 216 | {"result":"PointerMatrix#0"} 217 | ``` 218 | 219 | ### PointerMatrix.setPoint 220 | **send** 221 | ``` 222 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.setPoint","this":"PointerMatrix#0","args":[0,0,{"x":65536630,"y":984}],"message_type":"hypium"},"request_id":"20240906204116061416"} 223 | 224 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.setPoint","this":"PointerMatrix#0","args":[0,1,{"x":3277430,"y":984}],"message_type":"hypium"},"request_id":"20240906204116069343"} 225 | 226 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.setPoint","this":"PointerMatrix#0","args":[0,2,{"x":3277393,"y":994}],"message_type":"hypium"},"request_id":"20240906204116072723"} 227 | 228 | ... 229 | 230 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.setPoint","this":"PointerMatrix#0","args":[0,102,{"x":2622070,"y":1632}],"message_type":"hypium"},"request_id":"20240906204116359992"} 231 | 232 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"PointerMatrix.setPoint","this":"PointerMatrix#0","args":[0,103,{"x":633,"y":1632}],"message_type":"hypium"},"request_id":"20240906204116363228"} 233 | ``` 234 | **recv** 235 | ``` 236 | {"result":null} 237 | ``` 238 | 239 | ### injectMultiPointerAction 240 | **send** 241 | ``` 242 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Driver.injectMultiPointerAction","this":"Driver#0","args":["PointerMatrix#0",2000],"message_type":"hypium"},"request_id":"20240906204116366578"} 243 | ``` 244 | **recv** 245 | ``` 246 | {"result":true} 247 | ``` 248 | 249 | 250 | ## Component 251 | ### Component.getId 252 | **send** 253 | ``` 254 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.getId","this":"Component#1","args":[],"message_type":"hypium"},"request_id":"20240830143213283547","client":"127.0.0.1"} 255 | ``` 256 | **recv** 257 | ``` 258 | {"result":""} 259 | ``` 260 | ### Component.getKey (getId) 261 | ### Component.getType 262 | ### Component.getText 263 | ### Component.getDescription 264 | ### Component.isSelected 265 | **send** 266 | ``` 267 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.isSelected","this":"Component#28","args":[],"message_type":"hypium"},"request_id":"20240830200628395802","client":"127.0.0.1"} 268 | ``` 269 | **recv** 270 | ``` 271 | {"result":false} 272 | ``` 273 | ### Component.isChecked 274 | ### Component.isEnabled 275 | ### Component.isFocused 276 | ### Component.isCheckable 277 | ### Component.isClickable 278 | ### Component.isLongClickable 279 | ### Component.getBounds 280 | **send** 281 | ``` 282 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.getBounds","this":"Component#28","args":[],"message_type":"hypium"},"request_id":"20240830200628840692","client":"127.0.0.1"} 283 | ``` 284 | **recv** 285 | ``` 286 | {"result":{"bottom":1412,"left":832,"right":1125,"top":1282}} 287 | ``` 288 | ### Component.getBoundsCenter 289 | **send** 290 | ``` 291 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.getBoundsCenter","this":"Component#28","args":[],"message_type":"hypium"},"request_id":"20240830200628840692","client":"127.0.0.1"} 292 | ``` 293 | **recv** 294 | ``` 295 | {"result":{"x":978,"y":1347}} 296 | ``` 297 | 298 | ### Component.click 299 | **send** 300 | ``` 301 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.click","this":"Component#2","args":[],"message_type":"hypium"},"request_id":"20240903163157355953","client":"127.0.0.1"} 302 | ``` 303 | **recv** 304 | ``` 305 | {"result":null} 306 | ``` 307 | 308 | ### Component.doubleClick 309 | ### Component.longClick 310 | ### Component.dragTo 311 | **send** 312 | ``` 313 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.dragTo","this":"Component#2","args":["Component#3"],"message_type":"hypium"},"request_id":"20240903163204255727","client":"127.0.0.1"} 314 | ``` 315 | **recv** 316 | ``` 317 | {"result":null} 318 | ``` 319 | ### Component.inputText 320 | **send** 321 | ``` 322 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.inputText","this":"Component#1","args":["ccc"],"message_type":"hypium"},"request_id":"20240903162837676456","client":"127.0.0.1"} 323 | ``` 324 | **recv** 325 | ``` 326 | {"result":null} 327 | ``` 328 | ### Component.clearText 329 | **send** 330 | ``` 331 | {"module":"com.ohos.devicetest.hypiumApiHelper","method":"callHypiumApi","params":{"api":"Component.clearText","this":"Component#1","args":[],"message_type":"hypium"},"request_id":"20240903162837676456","client":"127.0.0.1"} 332 | ``` 333 | **recv** 334 | ``` 335 | {"result":null} 336 | ``` 337 | ### Component.pinchIn 338 | ### Component.pinchOut 339 | 340 | 341 | ## HDC 342 | https://github.com/codematrixer/awesome-hdc -------------------------------------------------------------------------------- /docs/img/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/hmdriver2/297537be84c8648fe5fed30baa9a7f148ac4495b/docs/img/arch.png -------------------------------------------------------------------------------- /docs/img/gesture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/hmdriver2/297537be84c8648fe5fed30baa9a7f148ac4495b/docs/img/gesture.gif -------------------------------------------------------------------------------- /docs/img/ui-viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/hmdriver2/297537be84c8648fe5fed30baa9a7f148ac4495b/docs/img/ui-viewer.png -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | from hmdriver2.driver import Driver 5 | from hmdriver2.proto import DeviceInfo, KeyCode, ComponentData, DisplayRotation 6 | 7 | 8 | # New driver 9 | d = Driver("FMR0223C13000649") 10 | 11 | # Device Info 12 | info: DeviceInfo = d.device_info 13 | # output: DeviceInfo(productName='HUAWEI Mate 60 Pro', model='ALN-AL00', sdkVersion='12', sysVersion='ALN-AL00 5.0.0.60(SP12DEVC00E61R4P9log)', cpuAbi='arm64-v8a', wlanIp='172.31.125.111', displaySize=(1260, 2720), displayRotation=) 14 | 15 | d.display_size 16 | d.display_rotation 17 | d.set_display_rotation(DisplayRotation.ROTATION_180) 18 | 19 | d.install_app("~/develop/harmony_prj/demo.hap") 20 | d.clear_app("com.samples.test.uitest") 21 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 22 | d.get_app_info("com.samples.test.uitest") 23 | 24 | # KeyCode: https://github.com/codematrixer/hmdriver2/blob/master/hmdriver2/proto.py 25 | d.press_key(KeyCode.POWER) 26 | 27 | d.go_back() 28 | d.go_home() 29 | d.screen_on() 30 | d.screen_off() 31 | d.unlock() 32 | 33 | # Execute HDC shell command 34 | d.shell("bm dump -n com.kuaishou.hmapp") 35 | 36 | # Open scheme 37 | d.open_url("http://www.baidu.com") 38 | 39 | # Push and pull files 40 | rpath = "/data/local/tmp/test.png" 41 | lpath = "./test.png" 42 | d.pull_file(rpath, lpath) 43 | d.push_file(lpath, rpath) 44 | 45 | # Device Screenshot 46 | d.screenshot("./test.png") 47 | 48 | # Device touch 49 | d.click(500, 1000) 50 | d.click(0.5, 0.4) # "If of type float, it represents percentage coordinates." 51 | d.double_click(500, 1000) 52 | d.double_click(0.5, 0.4) 53 | d.long_click(500, 1000) 54 | d.long_click(0.5, 0.4) 55 | d.swipe(0.5, 0.8, 0.5, 0.4, speed=2000) 56 | 57 | d.swipe_ext("up") # 向上滑动,"left", "right", "up", "down" 58 | d.swipe_ext("right", scale=0.8) # 向右滑动,滑动距离为屏幕宽度的80% 59 | d.swipe_ext("up", box=(0.2, 0.2, 0.8, 0.8)) # 在屏幕 (0.2, 0.2) -> (0.8, 0.8) 这个区域上滑 60 | 61 | from hmdriver2.proto import SwipeDirection 62 | d.swipe_ext(SwipeDirection.DOWN) # 向下滑动 63 | 64 | d.input_text("adbcdfg") 65 | 66 | # Device touch gersture 67 | d.gesture.start(630, 984, interval=.5).move(0.2, 0.4, interval=.5).pause(interval=1).move(0.5, 0.6, interval=.5).pause(interval=1).action() 68 | d.gesture.start(0.77, 0.49).action() 69 | 70 | 71 | # Toast Watcher 72 | d.toast_watcher.start() 73 | # do somethings 比如触发toast 74 | toast = d.toast_watcher.get_toast() 75 | print(toast) 76 | 77 | # Dump hierarchy 78 | d.dump_hierarchy() 79 | 80 | # Device Screen Recrod 81 | with d.screenrecord.start("test.mp4"): 82 | # do somethings 83 | time.sleep(5) 84 | 85 | 86 | # App Element 87 | d(id="swiper").exists() 88 | d(type="Button", text="tab_recrod").exists() 89 | d(text="tab_recrod", isAfter=True).exists() 90 | d(text="tab_recrod").click_if_exists() 91 | d(type="Button", index=3).click() 92 | d(text="tab_recrod").double_click() 93 | d(text="tab_recrod").long_click() 94 | 95 | component: ComponentData = d(type="ListItem", index=1).find_component() 96 | d(type="ListItem").drag_to(component) 97 | 98 | d(text="tab_recrod").input_text("abc") 99 | d(text="tab_recrod").clear_text() 100 | d(text="tab_recrod").pinch_in() 101 | d(text="tab_recrod").pinch_out() 102 | 103 | d(text="tab_recrod").info 104 | """ 105 | output: 106 | { 107 | "id": "", 108 | "key": "", 109 | "type": "Button", 110 | "text": "tab_recrod", 111 | "description": "", 112 | "isSelected": False, 113 | "isChecked": False, 114 | "isEnabled": True, 115 | "isFocused": False, 116 | "isCheckable": False, 117 | "isClickable": True, 118 | "isLongClickable": False, 119 | "isScrollable": False, 120 | "bounds": { 121 | "left": 539, 122 | "top": 1282, 123 | "right": 832, 124 | "bottom": 1412 125 | }, 126 | "boundsCenter": { 127 | "x": 685, 128 | "y": 1347 129 | } 130 | } 131 | """ 132 | 133 | # XPath 134 | d.xpath('//*[@text="showDialog"]').click() 135 | d.xpath('//*[@text="showDialog"]').click_if_exists() 136 | d.xpath('//root[1]/Row[1]/Column[1]/Row[1]/Button[3]').click() 137 | d.xpath('//*[@text="showDialog"]').input_text("xxx") 138 | -------------------------------------------------------------------------------- /hmdriver2/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | formatter = logging.Formatter('[%(asctime)s] %(filename)15s[line:%(lineno)4d] \ 6 | [%(levelname)s] %(message)s', 7 | datefmt='%Y-%m-%d %H:%M:%S') 8 | 9 | logger = logging.getLogger('hmdriver2') 10 | logger.setLevel(logging.DEBUG) 11 | 12 | console_handler = logging.StreamHandler() 13 | console_handler.setLevel(logging.DEBUG) 14 | console_handler.setFormatter(formatter) 15 | 16 | logger.addHandler(console_handler) 17 | 18 | 19 | __all__ = ['logger'] 20 | -------------------------------------------------------------------------------- /hmdriver2/_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import socket 3 | import json 4 | import time 5 | import os 6 | import hashlib 7 | import typing 8 | from typing import Optional 9 | from datetime import datetime 10 | from functools import cached_property 11 | 12 | from . import logger 13 | from .hdc import HdcWrapper 14 | from .proto import HypiumResponse, DriverData 15 | from .exception import InvokeHypiumError, InvokeCaptures 16 | 17 | 18 | UITEST_SERVICE_PORT = 8012 19 | SOCKET_TIMEOUT = 20 20 | 21 | 22 | class HmClient: 23 | """harmony uitest client""" 24 | def __init__(self, serial: str): 25 | self.hdc = HdcWrapper(serial) 26 | self.sock = None 27 | 28 | @cached_property 29 | def local_port(self): 30 | fports = self.hdc.list_fport() 31 | logger.debug(fports) if fports else None 32 | 33 | return self.hdc.forward_port(UITEST_SERVICE_PORT) 34 | 35 | def _rm_local_port(self): 36 | logger.debug("rm fport local port") 37 | self.hdc.rm_forward(self.local_port, UITEST_SERVICE_PORT) 38 | 39 | def _connect_sock(self): 40 | """Create socket and connect to the uiTEST server.""" 41 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 42 | self.sock.settimeout(SOCKET_TIMEOUT) 43 | self.sock.connect((("127.0.0.1", self.local_port))) 44 | 45 | def _send_msg(self, msg: typing.Dict): 46 | """Send an message to the server. 47 | Example: 48 | { 49 | "module": "com.ohos.devicetest.hypiumApiHelper", 50 | "method": "callHypiumApi", 51 | "params": { 52 | "api": "Driver.create", 53 | "this": null, 54 | "args": [], 55 | "message_type": "hypium" 56 | }, 57 | "request_id": "20240815161352267072", 58 | "client": "127.0.0.1" 59 | } 60 | """ 61 | msg = json.dumps(msg, ensure_ascii=False, separators=(',', ':')) 62 | logger.debug(f"sendMsg: {msg}") 63 | self.sock.sendall(msg.encode('utf-8') + b'\n') 64 | 65 | def _recv_msg(self, buff_size: int = 4096, decode=False, print=True) -> typing.Union[bytearray, str]: 66 | full_msg = bytearray() 67 | try: 68 | # FIXME 69 | relay = self.sock.recv(buff_size) 70 | if decode: 71 | relay = relay.decode() 72 | if print: 73 | logger.debug(f"recvMsg: {relay}") 74 | full_msg = relay 75 | 76 | except (socket.timeout, UnicodeDecodeError) as e: 77 | logger.warning(e) 78 | if decode: 79 | full_msg = "" 80 | 81 | return full_msg 82 | 83 | def invoke(self, api: str, this: str = "Driver#0", args: typing.List = []) -> HypiumResponse: 84 | """ 85 | Hypium invokes given API method with the specified arguments and handles exceptions. 86 | 87 | Args: 88 | api (str): The name of the API method to invoke. 89 | args (List, optional): A list of arguments to pass to the API method. Default is an empty list. 90 | 91 | Returns: 92 | HypiumResponse: The response from the API call. 93 | 94 | Raises: 95 | InvokeHypiumError: If the API call returns an exception in the response. 96 | """ 97 | 98 | request_id = datetime.now().strftime("%Y%m%d%H%M%S%f") 99 | params = { 100 | "api": api, 101 | "this": this, 102 | "args": args, 103 | "message_type": "hypium" 104 | } 105 | 106 | msg = { 107 | "module": "com.ohos.devicetest.hypiumApiHelper", 108 | "method": "callHypiumApi", 109 | "params": params, 110 | "request_id": request_id 111 | } 112 | 113 | self._send_msg(msg) 114 | raw_data = self._recv_msg(decode=True) 115 | data = HypiumResponse(**(json.loads(raw_data))) 116 | if data.exception: 117 | raise InvokeHypiumError(data.exception) 118 | return data 119 | 120 | def invoke_captures(self, api: str, args: typing.List = []) -> HypiumResponse: 121 | request_id = datetime.now().strftime("%Y%m%d%H%M%S%f") 122 | params = { 123 | "api": api, 124 | "args": args 125 | } 126 | 127 | msg = { 128 | "module": "com.ohos.devicetest.hypiumApiHelper", 129 | "method": "Captures", 130 | "params": params, 131 | "request_id": request_id 132 | } 133 | 134 | self._send_msg(msg) 135 | raw_data = self._recv_msg(decode=True) 136 | data = HypiumResponse(**(json.loads(raw_data))) 137 | if data.exception: 138 | raise InvokeCaptures(data.exception) 139 | return data 140 | 141 | def start(self): 142 | logger.info("Start HmClient connection") 143 | _UITestService(self.hdc).init() 144 | 145 | self._connect_sock() 146 | 147 | self._create_hdriver() 148 | 149 | def release(self): 150 | logger.info(f"Release {self.__class__.__name__} connection") 151 | try: 152 | if self.sock: 153 | self.sock.close() 154 | self.sock = None 155 | 156 | self._rm_local_port() 157 | 158 | except Exception as e: 159 | logger.error(f"An error occurred: {e}") 160 | 161 | def _create_hdriver(self) -> DriverData: 162 | logger.debug("Create uitest driver") 163 | resp: HypiumResponse = self.invoke("Driver.create") # {"result":"Driver#0"} 164 | hdriver: DriverData = DriverData(resp.result) 165 | return hdriver 166 | 167 | 168 | class _UITestService: 169 | def __init__(self, hdc: HdcWrapper): 170 | """Initialize the UITestService class.""" 171 | self.hdc = hdc 172 | 173 | def init(self): 174 | """ 175 | Initialize the UITest service: 176 | 1. Ensure agent.so is set up on the device. 177 | 2. Start the UITest daemon. 178 | 179 | Note: 'hdc shell aa test' will also start a uitest daemon. 180 | $ hdc shell ps -ef |grep uitest 181 | shell 44306 1 25 11:03:37 ? 00:00:16 uitest start-daemon singleness 182 | shell 44416 1 2 11:03:42 ? 00:00:01 uitest start-daemon com.hmtest.uitest@4x9@1" 183 | """ 184 | 185 | logger.debug("Initializing UITest service") 186 | local_path = self._get_local_agent_path() 187 | remote_path = "/data/local/tmp/agent.so" 188 | 189 | self._kill_uitest_service() # Stop the service if running 190 | self._setup_device_agent(local_path, remote_path) 191 | self._start_uitest_daemon() 192 | time.sleep(0.5) 193 | 194 | def _get_local_agent_path(self) -> str: 195 | """Return the local path of the agent file.""" 196 | target_agent = "uitest_agent_v1.1.0.so" 197 | return os.path.join(os.path.dirname(os.path.realpath(__file__)), "assets", target_agent) 198 | 199 | def _get_remote_md5sum(self, file_path: str) -> Optional[str]: 200 | """Get the MD5 checksum of a remote file.""" 201 | command = f"md5sum {file_path}" 202 | output = self.hdc.shell(command).output.strip() 203 | return output.split()[0] if output else None 204 | 205 | def _get_local_md5sum(self, file_path: str) -> str: 206 | """Get the MD5 checksum of a local file.""" 207 | hash_md5 = hashlib.md5() 208 | with open(file_path, "rb") as f: 209 | for chunk in iter(lambda: f.read(4096), b""): 210 | hash_md5.update(chunk) 211 | return hash_md5.hexdigest() 212 | 213 | def _is_remote_file_exists(self, file_path: str) -> bool: 214 | """Check if a file exists on the device.""" 215 | command = f"[ -f {file_path} ] && echo 'exists' || echo 'not exists'" 216 | result = self.hdc.shell(command).output.strip() 217 | return "exists" in result 218 | 219 | def _setup_device_agent(self, local_path: str, remote_path: str): 220 | """Ensure the remote agent file is correctly set up.""" 221 | if self._is_remote_file_exists(remote_path): 222 | local_md5 = self._get_local_md5sum(local_path) 223 | remote_md5 = self._get_remote_md5sum(remote_path) 224 | if local_md5 == remote_md5: 225 | logger.debug("Remote agent file is up-to-date") 226 | self.hdc.shell(f"chmod +x {remote_path}") 227 | return 228 | self.hdc.shell(f"rm {remote_path}") 229 | 230 | self.hdc.send_file(local_path, remote_path) 231 | self.hdc.shell(f"chmod +x {remote_path}") 232 | logger.debug("Updated remote agent file") 233 | 234 | def _get_uitest_pid(self) -> typing.List[str]: 235 | proc_pids = [] 236 | result = self.hdc.shell("ps -ef").output.strip() 237 | lines = result.splitlines() 238 | filtered_lines = [line for line in lines if 'uitest' in line and 'singleness' in line] 239 | for line in filtered_lines: 240 | if 'uitest start-daemon singleness' not in line: 241 | continue 242 | proc_pids.append(line.split()[1]) 243 | return proc_pids 244 | 245 | def _kill_uitest_service(self): 246 | for pid in self._get_uitest_pid(): 247 | self.hdc.shell(f"kill -9 {pid}") 248 | logger.debug(f"Killed uitest process with PID {pid}") 249 | 250 | def _start_uitest_daemon(self): 251 | """Start the UITest daemon.""" 252 | self.hdc.shell("uitest start-daemon singleness") 253 | logger.debug("Started UITest daemon") -------------------------------------------------------------------------------- /hmdriver2/_gesture.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import math 4 | from typing import List, Union 5 | from . import logger 6 | from .utils import delay 7 | from .driver import Driver 8 | from .proto import HypiumResponse, Point 9 | from .exception import InjectGestureError 10 | 11 | 12 | class _Gesture: 13 | SAMPLE_TIME_MIN = 10 14 | SAMPLE_TIME_NORMAL = 50 15 | SAMPLE_TIME_MAX = 100 16 | 17 | def __init__(self, d: Driver, sampling_ms=50): 18 | """ 19 | Initialize a gesture object. 20 | 21 | Args: 22 | d (Driver): The driver object to interact with. 23 | sampling_ms (int): Sampling time for gesture operation points in milliseconds. Default is 50. 24 | """ 25 | self.d = d 26 | self.steps: List[GestureStep] = [] 27 | self.sampling_ms = self._validate_sampling_time(sampling_ms) 28 | 29 | def _validate_sampling_time(self, sampling_time: int) -> int: 30 | """ 31 | Validate the input sampling time. 32 | 33 | Args: 34 | sampling_time (int): The given sampling time. 35 | 36 | Returns: 37 | int: Valid sampling time within allowed range. 38 | """ 39 | if _Gesture.SAMPLE_TIME_MIN <= sampling_time <= _Gesture.SAMPLE_TIME_MAX: 40 | return sampling_time 41 | return _Gesture.SAMPLE_TIME_NORMAL 42 | 43 | def _release(self): 44 | self.steps = [] 45 | 46 | def start(self, x: Union[int, float], y: Union[int, float], interval: float = 0.5) -> '_Gesture': 47 | """ 48 | Start gesture operation. 49 | 50 | Args: 51 | x: oordinate as a percentage or absolute value. 52 | y: coordinate as a percentage or absolute value. 53 | interval (float, optional): Duration to hold at start position in seconds. Default is 0.5. 54 | 55 | Returns: 56 | Gesture: Self instance to allow method chaining. 57 | """ 58 | self._ensure_can_start() 59 | self._add_step(x, y, "start", interval) 60 | return self 61 | 62 | def move(self, x: Union[int, float], y: Union[int, float], interval: float = 0.5) -> '_Gesture': 63 | """ 64 | Move to specified position. 65 | 66 | Args: 67 | x: coordinate as a percentage or absolute value. 68 | y: coordinate as a percentage or absolute value. 69 | interval (float, optional): Duration of move in seconds. Default is 0.5. 70 | 71 | Returns: 72 | Gesture: Self instance to allow method chaining. 73 | """ 74 | self._ensure_started() 75 | self._add_step(x, y, "move", interval) 76 | return self 77 | 78 | def pause(self, interval: float = 1) -> '_Gesture': 79 | """ 80 | Pause at current position for specified duration. 81 | 82 | Args: 83 | interval (float, optional): Duration to pause in seconds. Default is 1. 84 | 85 | Returns: 86 | Gesture: Self instance to allow method chaining. 87 | """ 88 | self._ensure_started() 89 | pos = self.steps[-1].pos 90 | self.steps.append(GestureStep(pos, "pause", interval)) 91 | return self 92 | 93 | @delay 94 | def action(self): 95 | """ 96 | Execute the gesture action. 97 | """ 98 | logger.info(f">>>Gesture steps: {self.steps}") 99 | total_points = self._calculate_total_points() 100 | 101 | pointer_matrix = self._create_pointer_matrix(total_points) 102 | self._generate_points(pointer_matrix, total_points) 103 | 104 | self._inject_pointer_actions(pointer_matrix) 105 | 106 | self._release() 107 | 108 | def _create_pointer_matrix(self, total_points: int): 109 | """ 110 | Create a pointer matrix for the gesture. 111 | 112 | Args: 113 | total_points (int): Total number of points. 114 | 115 | Returns: 116 | PointerMatrix: Pointer matrix object. 117 | """ 118 | fingers = 1 119 | api = "PointerMatrix.create" 120 | data: HypiumResponse = self.d._client.invoke(api, this=None, args=[fingers, total_points]) 121 | return data.result 122 | 123 | def _inject_pointer_actions(self, pointer_matrix): 124 | """ 125 | Inject pointer actions into the driver. 126 | 127 | Args: 128 | pointer_matrix (PointerMatrix): Pointer matrix to inject. 129 | """ 130 | api = "Driver.injectMultiPointerAction" 131 | self.d._client.invoke(api, args=[pointer_matrix, 2000]) 132 | 133 | def _add_step(self, x: int, y: int, step_type: str, interval: float): 134 | """ 135 | Add a step to the gesture. 136 | 137 | Args: 138 | x (int): x-coordinate of the point. 139 | y (int): y-coordinate of the point. 140 | step_type (str): Type of step ("start", "move", or "pause"). 141 | interval (float): Interval duration in seconds. 142 | """ 143 | point: Point = self.d._to_abs_pos(x, y) 144 | step = GestureStep(point.to_tuple(), step_type, interval) 145 | self.steps.append(step) 146 | 147 | def _ensure_can_start(self): 148 | """ 149 | Ensure that the gesture can start. 150 | """ 151 | if self.steps: 152 | raise InjectGestureError("Can't start gesture twice") 153 | 154 | def _ensure_started(self): 155 | """ 156 | Ensure that the gesture has started. 157 | """ 158 | if not self.steps: 159 | raise InjectGestureError("Please call gesture.start first") 160 | 161 | def _generate_points(self, pointer_matrix, total_points): 162 | """ 163 | Generate points for the pointer matrix. 164 | 165 | Args: 166 | pointer_matrix (PointerMatrix): Pointer matrix to populate. 167 | total_points (int): Total points to generate. 168 | """ 169 | 170 | def set_point(point_index: int, point: Point, interval: int = None): 171 | """ 172 | Set a point in the pointer matrix. 173 | 174 | Args: 175 | point_index (int): Index of the point. 176 | point (Point): The point object. 177 | interval (int, optional): Interval duration. 178 | """ 179 | if interval is not None: 180 | point.x += 65536 * interval 181 | api = "PointerMatrix.setPoint" 182 | self.d._client.invoke(api, this=pointer_matrix, args=[0, point_index, point.to_dict()]) 183 | 184 | point_index = 0 185 | 186 | for index, step in enumerate(self.steps): 187 | if step.type == "start": 188 | point_index = self._generate_start_point(step, point_index, set_point) 189 | elif step.type == "move": 190 | point_index = self._generate_move_points(index, step, point_index, set_point) 191 | elif step.type == "pause": 192 | point_index = self._generate_pause_points(step, point_index, set_point) 193 | 194 | step = self.steps[-1] 195 | while point_index < total_points: 196 | set_point(point_index, Point(*step.pos)) 197 | point_index += 1 198 | 199 | def _generate_start_point(self, step, point_index, set_point): 200 | """ 201 | Generate start points. 202 | 203 | Args: 204 | step (GestureStep): Gesture step. 205 | point_index (int): Current point index. 206 | set_point (function): Function to set the point in pointer matrix. 207 | 208 | Returns: 209 | int: Updated point index. 210 | """ 211 | set_point(point_index, Point(*step.pos), step.interval) 212 | point_index += 1 213 | pos = step.pos[0], step.pos[1] 214 | set_point(point_index, Point(*pos)) 215 | return point_index + 1 216 | 217 | def _generate_move_points(self, index, step, point_index, set_point): 218 | """ 219 | Generate move points. 220 | 221 | Args: 222 | index (int): Step index. 223 | step (GestureStep): Gesture step. 224 | point_index (int): Current point index. 225 | set_point (function): Function to set the point in pointer matrix. 226 | 227 | Returns: 228 | int: Updated point index. 229 | """ 230 | last_step = self.steps[index - 1] 231 | offset_x = step.pos[0] - last_step.pos[0] 232 | offset_y = step.pos[1] - last_step.pos[1] 233 | distance = int(math.sqrt(offset_x ** 2 + offset_y ** 2)) 234 | interval_ms = step.interval 235 | cur_steps = self._calculate_move_step_points(distance, interval_ms) 236 | 237 | step_x = int(offset_x / cur_steps) 238 | step_y = int(offset_y / cur_steps) 239 | 240 | set_point(point_index - 1, Point(*last_step.pos), self.sampling_ms) 241 | x, y = last_step.pos[0], last_step.pos[1] 242 | for _ in range(cur_steps): 243 | x += step_x 244 | y += step_y 245 | set_point(point_index, Point(x, y), self.sampling_ms) 246 | point_index += 1 247 | return point_index 248 | 249 | def _generate_pause_points(self, step, point_index, set_point): 250 | """ 251 | Generate pause points. 252 | 253 | Args: 254 | step (GestureStep): Gesture step. 255 | point_index (int): Current point index. 256 | set_point (function): Function to set the point in pointer matrix. 257 | 258 | Returns: 259 | int: Updated point index. 260 | """ 261 | points = int(step.interval / self.sampling_ms) 262 | for _ in range(points): 263 | set_point(point_index, Point(*step.pos), int(step.interval / self.sampling_ms)) 264 | point_index += 1 265 | pos = step.pos[0] + 3, step.pos[1] 266 | set_point(point_index, Point(*pos)) 267 | return point_index + 1 268 | 269 | def _calculate_total_points(self) -> int: 270 | """ 271 | Calculate the total number of points needed for the gesture. 272 | 273 | Returns: 274 | int: Total points. 275 | """ 276 | total_points = 0 277 | for index, step in enumerate(self.steps): 278 | if step.type == "start": 279 | total_points += 2 280 | elif step.type == "move": 281 | total_points += self._calculate_move_step_points( 282 | *self._calculate_move_distance(step, index)) 283 | elif step.type == "pause": 284 | points = int(step.interval / self.sampling_ms) 285 | total_points += points + 1 286 | return total_points 287 | 288 | def _calculate_move_distance(self, step, index): 289 | """ 290 | Calculate move distance and interval. 291 | 292 | Args: 293 | step (GestureStep): Gesture step. 294 | index (int): Step index. 295 | 296 | Returns: 297 | tuple: Tuple (distance, interval_ms). 298 | """ 299 | last_step = self.steps[index - 1] 300 | offset_x = step.pos[0] - last_step.pos[0] 301 | offset_y = step.pos[1] - last_step.pos[1] 302 | distance = int(math.sqrt(offset_x ** 2 + offset_y ** 2)) 303 | interval_ms = step.interval 304 | return distance, interval_ms 305 | 306 | def _calculate_move_step_points(self, distance: int, interval_ms: float) -> int: 307 | """ 308 | Calculate the number of move step points based on distance and time. 309 | 310 | Args: 311 | distance (int): Distance to move. 312 | interval_ms (float): Move duration in milliseconds. 313 | 314 | Returns: 315 | int: Number of move step points. 316 | """ 317 | if interval_ms < self.sampling_ms or distance < 1: 318 | return 1 319 | nums = interval_ms / self.sampling_ms 320 | return distance if nums > distance else int(nums) 321 | 322 | 323 | class GestureStep: 324 | """Class to store each step of a gesture, not to be used directly, use via Gesture class""" 325 | 326 | def __init__(self, pos: tuple, step_type: str, interval: float): 327 | """ 328 | Initialize a gesture step. 329 | 330 | Args: 331 | pos (tuple): Tuple containing x and y coordinates. 332 | step_type (str): Type of step ("start", "move", "pause"). 333 | interval (float): Interval duration in seconds. 334 | """ 335 | self.pos = pos[0], pos[1] 336 | self.interval = int(interval * 1000) 337 | self.type = step_type 338 | 339 | def __repr__(self): 340 | return f"GestureStep(pos=({self.pos[0]}, {self.pos[1]}), type='{self.type}', interval={self.interval})" 341 | 342 | def __str__(self): 343 | return self.__repr__() -------------------------------------------------------------------------------- /hmdriver2/_screenrecord.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import typing 4 | import threading 5 | import numpy as np 6 | import queue 7 | from datetime import datetime 8 | 9 | import cv2 10 | 11 | from . import logger 12 | from ._client import HmClient 13 | from .driver import Driver 14 | from .exception import ScreenRecordError 15 | 16 | 17 | class RecordClient(HmClient): 18 | def __init__(self, serial: str, d: Driver): 19 | super().__init__(serial) 20 | self.d = d 21 | 22 | self.video_path = None 23 | self.jpeg_queue = queue.Queue() 24 | self.threads: typing.List[threading.Thread] = [] 25 | self.stop_event = threading.Event() 26 | 27 | def __enter__(self): 28 | return self 29 | 30 | def __exit__(self, exc_type, exc_val, exc_tb): 31 | self.stop() 32 | 33 | def _send_msg(self, api: str, args: list): 34 | _msg = { 35 | "module": "com.ohos.devicetest.hypiumApiHelper", 36 | "method": "Captures", 37 | "params": { 38 | "api": api, 39 | "args": args 40 | }, 41 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f") 42 | } 43 | super()._send_msg(_msg) 44 | 45 | def start(self, video_path: str): 46 | logger.info("Start RecordClient connection") 47 | 48 | self._connect_sock() 49 | 50 | self.video_path = video_path 51 | 52 | self._send_msg("startCaptureScreen", []) 53 | 54 | reply: str = self._recv_msg(1024, decode=True, print=False) 55 | if "true" in reply: 56 | record_th = threading.Thread(target=self._record_worker) 57 | writer_th = threading.Thread(target=self._video_writer) 58 | record_th.daemon = True 59 | writer_th.daemon = True 60 | record_th.start() 61 | writer_th.start() 62 | self.threads.extend([record_th, writer_th]) 63 | else: 64 | raise ScreenRecordError("Failed to start device screen capture.") 65 | 66 | return self 67 | 68 | def _record_worker(self): 69 | """Capture screen frames and save current frames.""" 70 | 71 | # JPEG start and end markers. 72 | start_flag = b'\xff\xd8' 73 | end_flag = b'\xff\xd9' 74 | buffer = bytearray() 75 | while not self.stop_event.is_set(): 76 | try: 77 | buffer += self._recv_msg(4096 * 1024, decode=False, print=False) 78 | except Exception as e: 79 | print(f"Error receiving data: {e}") 80 | break 81 | 82 | start_idx = buffer.find(start_flag) 83 | end_idx = buffer.find(end_flag) 84 | while start_idx != -1 and end_idx != -1 and end_idx > start_idx: 85 | # Extract one JPEG image 86 | jpeg_image: bytearray = buffer[start_idx:end_idx + 2] 87 | self.jpeg_queue.put(jpeg_image) 88 | 89 | buffer = buffer[end_idx + 2:] 90 | 91 | # Search for the next JPEG image in the buffer 92 | start_idx = buffer.find(start_flag) 93 | end_idx = buffer.find(end_flag) 94 | 95 | def _video_writer(self): 96 | """Write frames to video file.""" 97 | cv2_instance = None 98 | img = None 99 | while not self.stop_event.is_set(): 100 | try: 101 | jpeg_image = self.jpeg_queue.get(timeout=0.1) 102 | img = cv2.imdecode(np.frombuffer(jpeg_image, np.uint8), cv2.IMREAD_COLOR) 103 | except queue.Empty: 104 | pass 105 | if img is None or img.size == 0: 106 | continue 107 | if cv2_instance is None: 108 | height, width = img.shape[:2] 109 | fourcc = cv2.VideoWriter_fourcc(*'mp4v') 110 | cv2_instance = cv2.VideoWriter(self.video_path, fourcc, 10, (width, height)) 111 | 112 | cv2_instance.write(img) 113 | 114 | if cv2_instance: 115 | cv2_instance.release() 116 | 117 | def stop(self) -> str: 118 | try: 119 | self.stop_event.set() 120 | for t in self.threads: 121 | t.join() 122 | 123 | self._send_msg("stopCaptureScreen", []) 124 | self._recv_msg(1024, decode=True, print=False) 125 | 126 | self.release() 127 | 128 | # Invalidate the cached property 129 | self.d._invalidate_cache('screenrecord') 130 | 131 | except Exception as e: 132 | logger.error(f"An error occurred: {e}") 133 | 134 | return self.video_path 135 | -------------------------------------------------------------------------------- /hmdriver2/_swipe.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Union, Tuple 4 | 5 | from .driver import Driver 6 | from .proto import SwipeDirection 7 | 8 | 9 | class SwipeExt(object): 10 | def __init__(self, d: Driver): 11 | self._d = d 12 | 13 | def __call__(self, 14 | direction: Union[SwipeDirection, str], 15 | scale: float = 0.8, 16 | box: Union[Tuple, None] = None, 17 | speed=2000): 18 | """ 19 | Args: 20 | direction (str): one of "left", "right", "up", "bottom" or SwipeDirection.LEFT 21 | scale (float): percent of swipe, range (0, 1.0] 22 | box (Tuple): None or (x1, x1, y1, x2, y2) 23 | 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. 24 | Raises: 25 | ValueError 26 | """ 27 | def _swipe(_from, _to): 28 | self._d.swipe(_from[0], _from[1], _to[0], _to[1], speed=speed) 29 | 30 | if scale <= 0 or scale > 1.0 or not isinstance(scale, (float, int)): 31 | raise ValueError("scale must be in range (0, 1.0]") 32 | 33 | if box: 34 | x1, y1, x2, y2 = self._validate_and_convert_box(box) 35 | else: 36 | x1, y1 = 0, 0 37 | x2, y2 = self._d.display_size 38 | 39 | width, height = x2 - x1, y2 - y1 40 | 41 | h_offset = int(width * (1 - scale) / 2) 42 | v_offset = int(height * (1 - scale) / 2) 43 | 44 | if direction == SwipeDirection.LEFT: 45 | start = (x2 - h_offset, y1 + height // 2) 46 | end = (x1 + h_offset, y1 + height // 2) 47 | elif direction == SwipeDirection.RIGHT: 48 | start = (x1 + h_offset, y1 + height // 2) 49 | end = (x2 - h_offset, y1 + height // 2) 50 | elif direction == SwipeDirection.UP: 51 | start = (x1 + width // 2, y2 - v_offset) 52 | end = (x1 + width // 2, y1 + v_offset) 53 | elif direction == SwipeDirection.DOWN: 54 | start = (x1 + width // 2, y1 + v_offset) 55 | end = (x1 + width // 2, y2 - v_offset) 56 | else: 57 | raise ValueError("Unknown SwipeDirection:", direction) 58 | 59 | _swipe(start, end) 60 | 61 | def _validate_and_convert_box(self, box: Tuple) -> Tuple[int, int, int, int]: 62 | """ 63 | Validate and convert the box coordinates if necessay. 64 | 65 | Args: 66 | box (Tuple): The box coordinates as a tuple (x1, y1, x2, y2). 67 | 68 | Returns: 69 | Tuple[int, int, int, int]: The validated and converted box coordinates. 70 | """ 71 | if not isinstance(box, tuple) or len(box) != 4: 72 | raise ValueError("Box must be a tuple of length 4.") 73 | x1, y1, x2, y2 = box 74 | if not (x1 >= 0 and y1 >= 0 and x2 > 0 and y2 > 0): 75 | raise ValueError("Box coordinates must be greater than 0.") 76 | if not (x1 < x2 and y1 < y2): 77 | raise ValueError("Box coordinates must satisfy x1 < x2 and y1 < y2.") 78 | 79 | from .driver import Point 80 | p1: Point = self._d._to_abs_pos(x1, y1) 81 | p2: Point = self._d._to_abs_pos(x2, y2) 82 | x1, y1, x2, y2 = p1.x, p1.y, p2.x, p2.y 83 | 84 | return x1, y1, x2, y2 85 | -------------------------------------------------------------------------------- /hmdriver2/_uiobject.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import enum 4 | import time 5 | from typing import List, Union 6 | 7 | from . import logger 8 | from .utils import delay 9 | from ._client import HmClient 10 | from .exception import ElementNotFoundError 11 | from .proto import ComponentData, ByData, HypiumResponse, Point, Bounds, ElementInfo 12 | 13 | 14 | class ByType(enum.Enum): 15 | id = "id" 16 | key = "key" 17 | text = "text" 18 | type = "type" 19 | description = "description" 20 | clickable = "clickable" 21 | longClickable = "longClickable" 22 | scrollable = "scrollable" 23 | enabled = "enabled" 24 | focused = "focused" 25 | selected = "selected" 26 | checked = "checked" 27 | checkable = "checkable" 28 | isBefore = "isBefore" 29 | isAfter = "isAfter" 30 | 31 | @classmethod 32 | def verify(cls, value): 33 | return any(value == item.value for item in cls) 34 | 35 | 36 | class UiObject: 37 | DEFAULT_TIMEOUT = 2 38 | 39 | def __init__(self, client: HmClient, **kwargs) -> None: 40 | self._client = client 41 | self._raw_kwargs = kwargs 42 | 43 | self._index = kwargs.pop("index", 0) 44 | self._isBefore = kwargs.pop("isBefore", False) 45 | self._isAfter = kwargs.pop("isAfter", False) 46 | 47 | self._kwargs = kwargs 48 | self.__verify() 49 | 50 | self._component: Union[ComponentData, None] = None # cache 51 | 52 | def __str__(self) -> str: 53 | return f"UiObject [{self._raw_kwargs}" 54 | 55 | def __verify(self): 56 | for k, v in self._kwargs.items(): 57 | if not ByType.verify(k): 58 | raise ReferenceError(f"{k} is not allowed.") 59 | 60 | @property 61 | def count(self) -> int: 62 | eleements = self.__find_components() 63 | return len(eleements) if eleements else 0 64 | 65 | def __len__(self): 66 | return self.count 67 | 68 | def exists(self, retries: int = 2, wait_time=1) -> bool: 69 | obj = self.find_component(retries, wait_time) 70 | return True if obj else False 71 | 72 | def __set_component(self, component: ComponentData): 73 | self._component = component 74 | 75 | def find_component(self, retries: int = 1, wait_time=1) -> ComponentData: 76 | for attempt in range(retries): 77 | components = self.__find_components() 78 | if components and self._index < len(components): 79 | self.__set_component(components[self._index]) 80 | return self._component 81 | 82 | if attempt < retries: 83 | time.sleep(wait_time) 84 | logger.info(f"Retry found element {self}") 85 | 86 | return None 87 | 88 | # useless 89 | def __find_component(self) -> Union[ComponentData, None]: 90 | by: ByData = self.__get_by() 91 | resp: HypiumResponse = self._client.invoke("Driver.findComponent", args=[by.value]) 92 | if not resp.result: 93 | return None 94 | return ComponentData(resp.result) 95 | 96 | def __find_components(self) -> Union[List[ComponentData], None]: 97 | by: ByData = self.__get_by() 98 | resp: HypiumResponse = self._client.invoke("Driver.findComponents", args=[by.value]) 99 | if not resp.result: 100 | return None 101 | components: List[ComponentData] = [] 102 | for item in resp.result: 103 | components.append(ComponentData(item)) 104 | 105 | return components 106 | 107 | def __get_by(self) -> ByData: 108 | for k, v in self._kwargs.items(): 109 | api = f"On.{k}" 110 | this = "On#seed" 111 | resp: HypiumResponse = self._client.invoke(api, this, args=[v]) 112 | this = resp.result 113 | 114 | if self._isBefore: 115 | resp: HypiumResponse = self._client.invoke("On.isBefore", this="On#seed", args=[resp.result]) 116 | 117 | if self._isAfter: 118 | resp: HypiumResponse = self._client.invoke("On.isAfter", this="On#seed", args=[resp.result]) 119 | 120 | return ByData(resp.result) 121 | 122 | def __operate(self, api, args=[], retries: int = 2): 123 | if not self._component: 124 | if not self.find_component(retries): 125 | raise ElementNotFoundError(f"Element({self}) not found after {retries} retries") 126 | 127 | resp: HypiumResponse = self._client.invoke(api, this=self._component.value, args=args) 128 | return resp.result 129 | 130 | @property 131 | def id(self) -> str: 132 | return self.__operate("Component.getId") 133 | 134 | @property 135 | def key(self) -> str: 136 | return self.__operate("Component.getId") 137 | 138 | @property 139 | def type(self) -> str: 140 | return self.__operate("Component.getType") 141 | 142 | @property 143 | def text(self) -> str: 144 | return self.__operate("Component.getText") 145 | 146 | @property 147 | def description(self) -> str: 148 | return self.__operate("Component.getDescription") 149 | 150 | @property 151 | def isSelected(self) -> bool: 152 | return self.__operate("Component.isSelected") 153 | 154 | @property 155 | def isChecked(self) -> bool: 156 | return self.__operate("Component.isChecked") 157 | 158 | @property 159 | def isEnabled(self) -> bool: 160 | return self.__operate("Component.isEnabled") 161 | 162 | @property 163 | def isFocused(self) -> bool: 164 | return self.__operate("Component.isFocused") 165 | 166 | @property 167 | def isCheckable(self) -> bool: 168 | return self.__operate("Component.isCheckable") 169 | 170 | @property 171 | def isClickable(self) -> bool: 172 | return self.__operate("Component.isClickable") 173 | 174 | @property 175 | def isLongClickable(self) -> bool: 176 | return self.__operate("Component.isLongClickable") 177 | 178 | @property 179 | def isScrollable(self) -> bool: 180 | return self.__operate("Component.isScrollable") 181 | 182 | @property 183 | def bounds(self) -> Bounds: 184 | _raw = self.__operate("Component.getBounds") 185 | return Bounds(**_raw) 186 | 187 | @property 188 | def boundsCenter(self) -> Point: 189 | _raw = self.__operate("Component.getBoundsCenter") 190 | return Point(**_raw) 191 | 192 | @property 193 | def info(self) -> ElementInfo: 194 | return ElementInfo( 195 | id=self.id, 196 | key=self.key, 197 | type=self.type, 198 | text=self.text, 199 | description=self.description, 200 | isSelected=self.isSelected, 201 | isChecked=self.isChecked, 202 | isEnabled=self.isEnabled, 203 | isFocused=self.isFocused, 204 | isCheckable=self.isCheckable, 205 | isClickable=self.isClickable, 206 | isLongClickable=self.isLongClickable, 207 | isScrollable=self.isScrollable, 208 | bounds=self.bounds, 209 | boundsCenter=self.boundsCenter) 210 | 211 | @delay 212 | def click(self): 213 | return self.__operate("Component.click") 214 | 215 | @delay 216 | def click_if_exists(self): 217 | try: 218 | return self.__operate("Component.click") 219 | except ElementNotFoundError: 220 | pass 221 | 222 | @delay 223 | def double_click(self): 224 | return self.__operate("Component.doubleClick") 225 | 226 | @delay 227 | def long_click(self): 228 | return self.__operate("Component.longClick") 229 | 230 | @delay 231 | def drag_to(self, component: ComponentData): 232 | return self.__operate("Component.dragTo", [component.value]) 233 | 234 | @delay 235 | def input_text(self, text: str): 236 | return self.__operate("Component.inputText", [text]) 237 | 238 | @delay 239 | def clear_text(self): 240 | return self.__operate("Component.clearText") 241 | 242 | @delay 243 | def pinch_in(self, scale: float = 0.5): 244 | return self.__operate("Component.pinchIn", [scale]) 245 | 246 | @delay 247 | def pinch_out(self, scale: float = 2): 248 | return self.__operate("Component.pinchOut", [scale]) 249 | -------------------------------------------------------------------------------- /hmdriver2/_xpath.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | from typing import Dict 5 | from lxml import etree 6 | from functools import cached_property 7 | 8 | from . import logger 9 | from .proto import Bounds 10 | from .driver import Driver 11 | from .utils import delay, parse_bounds 12 | from .exception import XmlElementNotFoundError 13 | 14 | 15 | class _XPath: 16 | def __init__(self, d: Driver): 17 | self._d = d 18 | 19 | def __call__(self, xpath: str) -> '_XMLElement': 20 | 21 | hierarchy: Dict = self._d.dump_hierarchy() 22 | if not hierarchy: 23 | raise RuntimeError("hierarchy is empty") 24 | 25 | xml = _XPath._json2xml(hierarchy) 26 | result = xml.xpath(xpath) 27 | 28 | if len(result) > 0: 29 | node = result[0] 30 | raw_bounds: str = node.attrib.get("bounds") # [832,1282][1125,1412] 31 | bounds: Bounds = parse_bounds(raw_bounds) 32 | logger.debug(f"{xpath} Bounds: {bounds}") 33 | return _XMLElement(bounds, self._d) 34 | 35 | return _XMLElement(None, self._d) 36 | 37 | @staticmethod 38 | def _sanitize_text(text: str) -> str: 39 | """Remove XML-incompatible control characters.""" 40 | return re.sub(r'[\x00-\x1F\x7F]', '', text) 41 | 42 | @staticmethod 43 | def _json2xml(hierarchy: Dict) -> etree.Element: 44 | """Convert JSON-like hierarchy to XML.""" 45 | attributes = hierarchy.get("attributes", {}) 46 | 47 | # 过滤所有属性的值,确保无非法字符 48 | cleaned_attributes = {k: _XPath._sanitize_text(str(v)) for k, v in attributes.items()} 49 | 50 | tag = cleaned_attributes.get("type", "orgRoot") or "orgRoot" 51 | xml = etree.Element(tag, attrib=cleaned_attributes) 52 | 53 | children = hierarchy.get("children", []) 54 | for item in children: 55 | xml.append(_XPath._json2xml(item)) 56 | 57 | return xml 58 | 59 | 60 | class _XMLElement: 61 | def __init__(self, bounds: Bounds, d: Driver): 62 | self.bounds = bounds 63 | self._d = d 64 | 65 | def _verify(self): 66 | if not self.bounds: 67 | raise XmlElementNotFoundError("xpath not found") 68 | 69 | @cached_property 70 | def center(self): 71 | self._verify() 72 | return self.bounds.get_center() 73 | 74 | def exists(self) -> bool: 75 | return self.bounds is not None 76 | 77 | @delay 78 | def click(self): 79 | x, y = self.center.x, self.center.y 80 | self._d.click(x, y) 81 | 82 | @delay 83 | def click_if_exists(self): 84 | 85 | if not self.exists(): 86 | logger.debug("click_exist: xpath not found") 87 | return 88 | 89 | x, y = self.center.x, self.center.y 90 | self._d.click(x, y) 91 | 92 | @delay 93 | def double_click(self): 94 | x, y = self.center.x, self.center.y 95 | self._d.double_click(x, y) 96 | 97 | @delay 98 | def long_click(self): 99 | x, y = self.center.x, self.center.y 100 | self._d.long_click(x, y) 101 | 102 | @delay 103 | def input_text(self, text): 104 | self.click() 105 | self._d.input_text(text) -------------------------------------------------------------------------------- /hmdriver2/assets/uitest_agent_v1.0.7.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/hmdriver2/297537be84c8648fe5fed30baa9a7f148ac4495b/hmdriver2/assets/uitest_agent_v1.0.7.so -------------------------------------------------------------------------------- /hmdriver2/assets/uitest_agent_v1.1.0.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/hmdriver2/297537be84c8648fe5fed30baa9a7f148ac4495b/hmdriver2/assets/uitest_agent_v1.1.0.so -------------------------------------------------------------------------------- /hmdriver2/driver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import uuid 5 | import re 6 | from typing import Type, Any, Tuple, Dict, Union, List, Optional 7 | from functools import cached_property # python3.8+ 8 | 9 | from . import logger 10 | from .utils import delay 11 | from ._client import HmClient 12 | from ._uiobject import UiObject 13 | from .hdc import list_devices 14 | from .exception import DeviceNotFoundError 15 | from .proto import HypiumResponse, KeyCode, Point, DisplayRotation, DeviceInfo, CommandResult 16 | 17 | 18 | class Driver: 19 | _instance: Dict[str, "Driver"] = {} 20 | 21 | def __new__(cls: Type["Driver"], serial: Optional[str] = None) -> "Driver": 22 | """ 23 | Ensure that only one instance of Driver exists per serial. 24 | If serial is None, use the first serial from list_devices(). 25 | """ 26 | serial = cls._prepare_serial(serial) 27 | 28 | if serial not in cls._instance: 29 | instance = super().__new__(cls) 30 | cls._instance[serial] = instance 31 | # Temporarily store the serial in the instance for initialization 32 | instance._serial_for_init = serial 33 | return cls._instance[serial] 34 | 35 | def __init__(self, serial: Optional[str] = None): 36 | """ 37 | Initialize the Driver instance. Only initialize if `_initialized` is not set. 38 | """ 39 | if hasattr(self, "_initialized") and self._initialized: 40 | return 41 | 42 | # Use the serial prepared in `__new__` 43 | serial = getattr(self, "_serial_for_init", serial) 44 | if serial is None: 45 | raise ValueError("Serial number is required for initialization.") 46 | 47 | self.serial = serial 48 | self._client = HmClient(self.serial) 49 | self.hdc = self._client.hdc 50 | self._init_hmclient() 51 | self._initialized = True # Mark the instance as initialized 52 | del self._serial_for_init # Clean up temporary attribute 53 | 54 | @classmethod 55 | def _prepare_serial(cls, serial: str = None) -> str: 56 | """ 57 | Prepare the serial. Use the first available device if serial is None. 58 | """ 59 | devices = list_devices() 60 | if not devices: 61 | raise DeviceNotFoundError("No devices found. Please connect a device.") 62 | 63 | if serial is None: 64 | logger.info(f"No serial provided, using the first device: {devices[0]}") 65 | return devices[0] 66 | if serial not in devices: 67 | raise DeviceNotFoundError(f"Device [{serial}] not found") 68 | return serial 69 | 70 | def __call__(self, **kwargs) -> UiObject: 71 | 72 | return UiObject(self._client, **kwargs) 73 | 74 | def __del__(self): 75 | Driver._instance.clear() 76 | if hasattr(self, '_client') and self._client: 77 | self._client.release() 78 | 79 | def _init_hmclient(self): 80 | self._client.start() 81 | 82 | def _invoke(self, api: str, args: List = []) -> HypiumResponse: 83 | return self._client.invoke(api, this="Driver#0", args=args) 84 | 85 | @delay 86 | def start_app(self, package_name: str, page_name: Optional[str] = None): 87 | """ 88 | Start an application on the device. 89 | If the `package_name` is empty, it will retrieve main ability using `get_app_main_ability`. 90 | 91 | Args: 92 | package_name (str): The package name of the application. 93 | page_name (Optional[str]): Ability Name within the application to start. 94 | """ 95 | if not page_name: 96 | page_name = self.get_app_main_ability(package_name).get('name', 'MainAbility') 97 | self.hdc.start_app(package_name, page_name) 98 | 99 | def force_start_app(self, package_name: str, page_name: Optional[str] = None): 100 | self.go_home() 101 | self.stop_app(package_name) 102 | self.start_app(package_name, page_name) 103 | 104 | def stop_app(self, package_name: str): 105 | self.hdc.stop_app(package_name) 106 | 107 | def clear_app(self, package_name: str): 108 | """ 109 | Clear the application's cache and data. 110 | """ 111 | self.hdc.shell(f"bm clean -n {package_name} -c") # clear cache 112 | self.hdc.shell(f"bm clean -n {package_name} -d") # clear data 113 | 114 | def install_app(self, apk_path: str): 115 | self.hdc.install(apk_path) 116 | 117 | def uninstall_app(self, package_name: str): 118 | self.hdc.uninstall(package_name) 119 | 120 | def list_apps(self) -> List: 121 | return self.hdc.list_apps() 122 | 123 | def has_app(self, package_name: str) -> bool: 124 | return self.hdc.has_app(package_name) 125 | 126 | def current_app(self) -> Tuple[str, str]: 127 | """ 128 | Get the current foreground application information. 129 | 130 | Returns: 131 | Tuple[str, str]: A tuple contain the package_name andpage_name of the foreground application. 132 | If no foreground application is found, returns (None, None). 133 | """ 134 | 135 | return self.hdc.current_app() 136 | 137 | def get_app_info(self, package_name: str) -> Dict: 138 | """ 139 | Get detailed information about a specific application. 140 | 141 | Args: 142 | package_name (str): The package name of the application to retrieve information for. 143 | 144 | Returns: 145 | Dict: A dictionary containing the application information. If an error occurs during parsing, 146 | an empty dictionary is returned. 147 | """ 148 | app_info = {} 149 | data: CommandResult = self.hdc.shell(f"bm dump -n {package_name}") 150 | output = data.output 151 | try: 152 | json_start = output.find("{") 153 | json_end = output.rfind("}") + 1 154 | json_output = output[json_start:json_end] 155 | 156 | app_info = json.loads(json_output) 157 | except Exception as e: 158 | logger.error(f"An error occurred:{e}") 159 | return app_info 160 | 161 | def get_app_abilities(self, package_name: str) -> List[Dict]: 162 | """ 163 | Get the abilities of an application. 164 | 165 | Args: 166 | package_name (str): The package name of the application. 167 | 168 | Returns: 169 | List[Dict]: A list of dictionaries containing the abilities of the application. 170 | """ 171 | result = [] 172 | app_info = self.get_app_info(package_name) 173 | hap_module_infos = app_info.get("hapModuleInfos") 174 | main_entry = app_info.get("mainEntry") 175 | for hap_module_info in hap_module_infos: 176 | # 尝试读取moduleInfo 177 | try: 178 | ability_infos = hap_module_info.get("abilityInfos") 179 | module_main = hap_module_info["mainAbility"] 180 | except Exception as e: 181 | logger.warning(f"Fail to parse moduleInfo item, {repr(e)}") 182 | continue 183 | # 尝试读取abilityInfo 184 | for ability_info in ability_infos: 185 | try: 186 | is_launcher_ability = False 187 | skills = ability_info['skills'] 188 | if len(skills) > 0 or "action.system.home" in skills[0]["actions"]: 189 | is_launcher_ability = True 190 | icon_ability_info = { 191 | "name": ability_info["name"], 192 | "moduleName": ability_info["moduleName"], 193 | "moduleMainAbility": module_main, 194 | "mainModule": main_entry, 195 | "isLauncherAbility": is_launcher_ability 196 | } 197 | result.append(icon_ability_info) 198 | except Exception as e: 199 | logger.warning(f"Fail to parse ability_info item, {repr(e)}") 200 | continue 201 | logger.debug(f"all abilities: {result}") 202 | return result 203 | 204 | def get_app_main_ability(self, package_name: str) -> Dict: 205 | """ 206 | Get the main ability of an application. 207 | 208 | Args: 209 | package_name (str): The package name of the application to retrieve information for. 210 | 211 | Returns: 212 | Dict: A dictionary containing the main ability of the application. 213 | 214 | """ 215 | if not (abilities := self.get_app_abilities(package_name)): 216 | return {} 217 | for item in abilities: 218 | score = 0 219 | if (name := item["name"]) and name == item["moduleMainAbility"]: 220 | score += 1 221 | if (module_name := item["moduleName"]) and module_name == item["mainModule"]: 222 | score += 1 223 | item["score"] = score 224 | abilities.sort(key=lambda x: (not x["isLauncherAbility"], -x["score"])) 225 | logger.debug(f"main ability: {abilities[0]}") 226 | return abilities[0] 227 | 228 | @cached_property 229 | def toast_watcher(self): 230 | 231 | obj = self 232 | 233 | class _Watcher: 234 | def start(self) -> bool: 235 | api = "Driver.uiEventObserverOnce" 236 | resp: HypiumResponse = obj._invoke(api, args=["toastShow"]) 237 | return resp.result 238 | 239 | def get_toast(self, timeout: int = 3) -> str: 240 | api = "Driver.getRecentUiEvent" 241 | resp: HypiumResponse = obj._invoke(api, args=[timeout]) 242 | if resp.result: 243 | return resp.result.get("text") 244 | return None 245 | 246 | return _Watcher() 247 | 248 | @delay 249 | def go_back(self): 250 | self.hdc.send_key(KeyCode.BACK) 251 | 252 | @delay 253 | def go_home(self): 254 | self.hdc.send_key(KeyCode.HOME) 255 | 256 | @delay 257 | def press_key(self, key_code: Union[KeyCode, int]): 258 | self.hdc.send_key(key_code) 259 | 260 | def screen_on(self): 261 | self.hdc.wakeup() 262 | 263 | def screen_off(self): 264 | self.hdc.wakeup() 265 | self.press_key(KeyCode.POWER) 266 | 267 | @delay 268 | def unlock(self): 269 | self.screen_on() 270 | w, h = self.display_size 271 | self.swipe(0.5 * w, 0.8 * h, 0.5 * w, 0.2 * h, speed=6000) 272 | 273 | @cached_property 274 | def display_size(self) -> Tuple[int, int]: 275 | api = "Driver.getDisplaySize" 276 | resp: HypiumResponse = self._invoke(api) 277 | w, h = resp.result.get("x"), resp.result.get("y") 278 | return w, h 279 | 280 | @cached_property 281 | def display_rotation(self) -> DisplayRotation: 282 | api = "Driver.getDisplayRotation" 283 | value = self._invoke(api).result 284 | return DisplayRotation.from_value(value) 285 | 286 | def set_display_rotation(self, rotation: DisplayRotation): 287 | """ 288 | Sets the display rotation to the specified orientation. 289 | 290 | Args: 291 | rotation (DisplayRotation): display rotation. 292 | """ 293 | api = "Driver.setDisplayRotation" 294 | self._invoke(api, args=[rotation.value]) 295 | 296 | @cached_property 297 | def device_info(self) -> DeviceInfo: 298 | """ 299 | Get detailed information about the device. 300 | 301 | Returns: 302 | DeviceInfo: An object containing various properties of the device. 303 | """ 304 | hdc = self.hdc 305 | return DeviceInfo( 306 | productName=hdc.product_name(), 307 | model=hdc.model(), 308 | sdkVersion=hdc.sdk_version(), 309 | sysVersion=hdc.sys_version(), 310 | cpuAbi=hdc.cpu_abi(), 311 | wlanIp=hdc.wlan_ip(), 312 | displaySize=self.display_size, 313 | displayRotation=self.display_rotation 314 | ) 315 | 316 | @delay 317 | def open_url(self, url: str, system_browser: bool = True): 318 | if system_browser: 319 | # Use the system browser 320 | self.hdc.shell(f"aa start -A ohos.want.action.viewData -e entity.system.browsable -U {url}") 321 | else: 322 | # Default method 323 | self.hdc.shell(f"aa start -U {url}") 324 | 325 | def pull_file(self, rpath: str, lpath: str): 326 | """ 327 | Pull a file from the device to the local machine. 328 | 329 | Args: 330 | rpath (str): The remote path of the file on the device. 331 | lpath (str): The local path where the file should be saved. 332 | """ 333 | self.hdc.recv_file(rpath, lpath) 334 | 335 | def push_file(self, lpath: str, rpath: str): 336 | """ 337 | Push a file from the local machine to the device. 338 | 339 | Args: 340 | lpath (str): The local path of the file. 341 | rpath (str): The remote path where the file should be saved on the device. 342 | """ 343 | self.hdc.send_file(lpath, rpath) 344 | 345 | def screenshot(self, path: str) -> str: 346 | """ 347 | Take a screenshot of the device display. 348 | 349 | Args: 350 | path (str): The local path to save the screenshot. 351 | 352 | Returns: 353 | str: The path where the screenshot is saved. 354 | """ 355 | _uuid = uuid.uuid4().hex 356 | _tmp_path = f"/data/local/tmp/_tmp_{_uuid}.jpeg" 357 | self.shell(f"snapshot_display -f {_tmp_path}") 358 | self.pull_file(_tmp_path, path) 359 | self.shell(f"rm -rf {_tmp_path}") # remove local path 360 | return path 361 | 362 | def shell(self, cmd) -> CommandResult: 363 | return self.hdc.shell(cmd) 364 | 365 | def _to_abs_pos(self, x: Union[int, float], y: Union[int, float]) -> Point: 366 | """ 367 | Convert percentages to absolute screen coordinates. 368 | 369 | Args: 370 | x (Union[int, float]): X coordinate as a percentage or absolute value. 371 | y (Union[int, float]): Y coordinate as a percentage or absolute value. 372 | 373 | Returns: 374 | Point: A Point object with absolute screen coordinates. 375 | """ 376 | assert x >= 0 377 | assert y >= 0 378 | 379 | w, h = self.display_size 380 | 381 | if x < 1: 382 | x = int(w * x) 383 | if y < 1: 384 | y = int(h * y) 385 | return Point(int(x), int(y)) 386 | 387 | @delay 388 | def click(self, x: Union[int, float], y: Union[int, float]): 389 | 390 | # self.hdc.tap(point.x, point.y) 391 | point = self._to_abs_pos(x, y) 392 | api = "Driver.click" 393 | self._invoke(api, args=[point.x, point.y]) 394 | 395 | @delay 396 | def double_click(self, x: Union[int, float], y: Union[int, float]): 397 | point = self._to_abs_pos(x, y) 398 | api = "Driver.doubleClick" 399 | self._invoke(api, args=[point.x, point.y]) 400 | 401 | @delay 402 | def long_click(self, x: Union[int, float], y: Union[int, float]): 403 | point = self._to_abs_pos(x, y) 404 | api = "Driver.longClick" 405 | self._invoke(api, args=[point.x, point.y]) 406 | 407 | @delay 408 | def swipe(self, x1, y1, x2, y2, speed=2000): 409 | """ 410 | Perform a swipe action on the device screen. 411 | 412 | Args: 413 | x1 (float): The start X coordinate as a percentage or absolute value. 414 | y1 (float): The start Y coordinate as a percentage or absolute value. 415 | x2 (float): The end X coordinate as a percentage or absolute value. 416 | y2 (float): The end Y coordinate as a percentage or absolute value. 417 | speed (int, optional): The swipe speed in pixels per second. Default is 2000. Range: 200-40000, 418 | If not within the range, set to default value of 2000. 419 | """ 420 | 421 | point1 = self._to_abs_pos(x1, y1) 422 | point2 = self._to_abs_pos(x2, y2) 423 | 424 | if speed < 200 or speed > 40000: 425 | logger.warning("`speed` is not in the range[200-40000], Set to default value of 2000.") 426 | speed = 2000 427 | 428 | api = "Driver.swipe" 429 | self._invoke(api, args=[point1.x, point1.y, point2.x, point2.y, speed]) 430 | 431 | @cached_property 432 | def swipe_ext(self): 433 | """ 434 | d.swipe_ext("up") 435 | d.swipe_ext("up", box=(0.2, 0.2, 0.8, 0.8)) 436 | """ 437 | from ._swipe import SwipeExt 438 | return SwipeExt(self) 439 | 440 | @delay 441 | def input_text(self, text: str): 442 | """ 443 | Inputs text into the currently focused input field. 444 | 445 | Note: The input field must have focus before calling this method. 446 | 447 | Args: 448 | text (str): input value 449 | """ 450 | return self._invoke("Driver.inputText", args=[{"x": 1, "y": 1}, text]) 451 | 452 | def dump_hierarchy(self) -> Dict: 453 | """ 454 | Dump the UI hierarchy of the device screen. 455 | 456 | Returns: 457 | Dict: The dumped UI hierarchy as a dictionary. 458 | """ 459 | # return self._client.invoke_captures("captureLayout").result 460 | return self.hdc.dump_hierarchy() 461 | 462 | @cached_property 463 | def gesture(self): 464 | from ._gesture import _Gesture 465 | return _Gesture(self) 466 | 467 | @cached_property 468 | def screenrecord(self): 469 | from ._screenrecord import RecordClient 470 | return RecordClient(self.serial, self) 471 | 472 | def _invalidate_cache(self, attribute_name): 473 | """ 474 | Invalidate the cached property. 475 | 476 | Args: 477 | attribute_name (str): The name of the attribute to invalidate. 478 | """ 479 | if attribute_name in self.__dict__: 480 | del self.__dict__[attribute_name] 481 | 482 | @cached_property 483 | def xpath(self): 484 | """ 485 | d.xpath("//*[@text='Hello']").click() 486 | """ 487 | from ._xpath import _XPath 488 | return _XPath(self) 489 | -------------------------------------------------------------------------------- /hmdriver2/exception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class ElementNotFoundError(Exception): 4 | pass 5 | 6 | 7 | class ElementFoundTimeout(Exception): 8 | pass 9 | 10 | 11 | class XmlElementNotFoundError(Exception): 12 | pass 13 | 14 | 15 | class HmDriverError(Exception): 16 | pass 17 | 18 | 19 | class DeviceNotFoundError(Exception): 20 | pass 21 | 22 | 23 | class HdcError(Exception): 24 | pass 25 | 26 | 27 | class InvokeHypiumError(Exception): 28 | pass 29 | 30 | 31 | class InvokeCaptures(Exception): 32 | pass 33 | 34 | 35 | class InjectGestureError(Exception): 36 | pass 37 | 38 | 39 | class ScreenRecordError(Exception): 40 | pass 41 | -------------------------------------------------------------------------------- /hmdriver2/hdc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import tempfile 3 | import json 4 | import uuid 5 | import shlex 6 | import re 7 | import os 8 | import subprocess 9 | from typing import Union, List, Dict, Tuple 10 | 11 | from . import logger 12 | from .utils import FreePort 13 | from .proto import CommandResult, KeyCode 14 | from .exception import HdcError, DeviceNotFoundError 15 | 16 | 17 | def _execute_command(cmdargs: Union[str, List[str]]) -> CommandResult: 18 | if isinstance(cmdargs, (list, tuple)): 19 | cmdline: str = ' '.join(list(map(shlex.quote, cmdargs))) 20 | elif isinstance(cmdargs, str): 21 | cmdline = cmdargs 22 | 23 | logger.debug(cmdline) 24 | try: 25 | process = subprocess.Popen(cmdline, stdout=subprocess.PIPE, 26 | stderr=subprocess.PIPE, shell=True) 27 | output, error = process.communicate() 28 | output = output.decode('utf-8') 29 | error = error.decode('utf-8') 30 | exit_code = process.returncode 31 | 32 | if 'error:' in output.lower() or '[fail]' in output.lower(): 33 | return CommandResult("", output, -1) 34 | 35 | return CommandResult(output, error, exit_code) 36 | except Exception as e: 37 | return CommandResult("", str(e), -1) 38 | 39 | 40 | def _build_hdc_prefix() -> str: 41 | """ 42 | Construct the hdc command prefix based on environment variables. 43 | """ 44 | host = os.getenv("HDC_SERVER_HOST") 45 | port = os.getenv("HDC_SERVER_PORT") 46 | if host and port: 47 | logger.debug(f"HDC_SERVER_HOST: {host}, HDC_SERVER_PORT: {port}") 48 | return f"hdc -s {host}:{port}" 49 | return "hdc" 50 | 51 | 52 | def list_devices() -> List[str]: 53 | devices = [] 54 | hdc_prefix = _build_hdc_prefix() 55 | result = _execute_command(f"{hdc_prefix} list targets") 56 | if result.exit_code == 0 and result.output: 57 | lines = result.output.strip().split('\n') 58 | for line in lines: 59 | if line.__contains__('Empty'): 60 | continue 61 | devices.append(line.strip()) 62 | 63 | if result.exit_code != 0: 64 | raise HdcError("HDC error", result.error) 65 | 66 | return devices 67 | 68 | 69 | class HdcWrapper: 70 | def __init__(self, serial: str) -> None: 71 | self.serial = serial 72 | self.hdc_prefix = _build_hdc_prefix() 73 | 74 | if not self.is_online(): 75 | raise DeviceNotFoundError(f"Device [{self.serial}] not found") 76 | 77 | def is_online(self): 78 | _serials = list_devices() 79 | return True if self.serial in _serials else False 80 | 81 | def forward_port(self, rport: int) -> int: 82 | lport: int = FreePort().get() 83 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport tcp:{lport} tcp:{rport}") 84 | if result.exit_code != 0: 85 | raise HdcError("HDC forward port error", result.error) 86 | return lport 87 | 88 | def rm_forward(self, lport: int, rport: int) -> int: 89 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport rm tcp:{lport} tcp:{rport}") 90 | if result.exit_code != 0: 91 | raise HdcError("HDC rm forward error", result.error) 92 | return lport 93 | 94 | def list_fport(self) -> List: 95 | """ 96 | eg.['tcp:10001 tcp:8012', 'tcp:10255 tcp:8012'] 97 | """ 98 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} fport ls") 99 | if result.exit_code != 0: 100 | raise HdcError("HDC forward list error", result.error) 101 | pattern = re.compile(r"tcp:\d+ tcp:\d+") 102 | return pattern.findall(result.output) 103 | 104 | def send_file(self, lpath: str, rpath: str): 105 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} file send {lpath} {rpath}") 106 | if result.exit_code != 0: 107 | raise HdcError("HDC send file error", result.error) 108 | return result 109 | 110 | def recv_file(self, rpath: str, lpath: str): 111 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} file recv {rpath} {lpath}") 112 | if result.exit_code != 0: 113 | raise HdcError("HDC receive file error", result.error) 114 | return result 115 | 116 | def shell(self, cmd: str, error_raise=True) -> CommandResult: 117 | # ensure the command is wrapped in double quotes 118 | if cmd[0] != '\"': 119 | cmd = "\"" + cmd 120 | if cmd[-1] != '\"': 121 | cmd += '\"' 122 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} shell {cmd}") 123 | if result.exit_code != 0 and error_raise: 124 | raise HdcError("HDC shell error", f"{cmd}\n{result.output}\n{result.error}") 125 | return result 126 | 127 | def uninstall(self, bundlename: str): 128 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} uninstall {bundlename}") 129 | if result.exit_code != 0: 130 | raise HdcError("HDC uninstall error", result.output) 131 | return result 132 | 133 | def install(self, apkpath: str): 134 | # Ensure the path is properly quoted for Windows 135 | quoted_path = f'"{apkpath}"' 136 | 137 | result = _execute_command(f"{self.hdc_prefix} -t {self.serial} install {quoted_path}") 138 | if result.exit_code != 0: 139 | raise HdcError("HDC install error", result.error) 140 | return result 141 | 142 | def list_apps(self) -> List[str]: 143 | result = self.shell("bm dump -a") 144 | raw = result.output.split('\n') 145 | return [item.strip() for item in raw] 146 | 147 | def has_app(self, package_name: str) -> bool: 148 | data = self.shell("bm dump -a").output 149 | return True if package_name in data else False 150 | 151 | def start_app(self, package_name: str, ability_name: str): 152 | return self.shell(f"aa start -a {ability_name} -b {package_name}") 153 | 154 | def stop_app(self, package_name: str): 155 | return self.shell(f"aa force-stop {package_name}") 156 | 157 | def current_app(self) -> Tuple[str, str]: 158 | """ 159 | Get the current foreground application information. 160 | 161 | Returns: 162 | Tuple[str, str]: A tuple contain the package_name andpage_name of the foreground application. 163 | If no foreground application is found, returns (None, None). 164 | """ 165 | 166 | def __extract_info(output: str): 167 | results = [] 168 | 169 | mission_blocks = re.findall(r'Mission ID #[\s\S]*?isKeepAlive: false\s*}', output) 170 | if not mission_blocks: 171 | return results 172 | 173 | for block in mission_blocks: 174 | if 'state #FOREGROUND' in block: 175 | bundle_name_match = re.search(r'bundle name \[(.*?)\]', block) 176 | main_name_match = re.search(r'main name \[(.*?)\]', block) 177 | if bundle_name_match and main_name_match: 178 | package_name = bundle_name_match.group(1) 179 | page_name = main_name_match.group(1) 180 | results.append((package_name, page_name)) 181 | 182 | return results 183 | 184 | data: CommandResult = self.shell("aa dump -l") 185 | output = data.output 186 | results = __extract_info(output) 187 | return results[0] if results else (None, None) 188 | 189 | def wakeup(self): 190 | self.shell("power-shell wakeup") 191 | 192 | def screen_state(self) -> str: 193 | """ 194 | ["INACTIVE", "SLEEP, AWAKE"] 195 | """ 196 | data = self.shell("hidumper -s PowerManagerService -a -s").output 197 | pattern = r"Current State:\s*(\w+)" 198 | match = re.search(pattern, data) 199 | 200 | return match.group(1) if match else None 201 | 202 | def wlan_ip(self) -> Union[str, None]: 203 | data = self.shell("ifconfig").output 204 | matches = re.findall(r'inet addr:(?!127)(\d+\.\d+\.\d+\.\d+)', data) 205 | return matches[0] if matches else None 206 | 207 | def __split_text(self, text: str) -> str: 208 | return text.split("\n")[0].strip() if text else None 209 | 210 | def sdk_version(self) -> str: 211 | data = self.shell("param get const.ohos.apiversion").output 212 | return self.__split_text(data) 213 | 214 | def sys_version(self) -> str: 215 | data = self.shell("param get const.product.software.version").output 216 | return self.__split_text(data) 217 | 218 | def model(self) -> str: 219 | data = self.shell("param get const.product.model").output 220 | return self.__split_text(data) 221 | 222 | def brand(self) -> str: 223 | data = self.shell("param get const.product.brand").output 224 | return self.__split_text(data) 225 | 226 | def product_name(self) -> str: 227 | data = self.shell("param get const.product.name").output 228 | return self.__split_text(data) 229 | 230 | def cpu_abi(self) -> str: 231 | data = self.shell("param get const.product.cpu.abilist").output 232 | return self.__split_text(data) 233 | 234 | def display_size(self) -> Tuple[int, int]: 235 | data = self.shell("hidumper -s RenderService -a screen").output 236 | match = re.search(r'activeMode:\s*(\d+)x(\d+),\s*refreshrate=\d+', data) 237 | 238 | if match: 239 | w = int(match.group(1)) 240 | h = int(match.group(2)) 241 | return (w, h) 242 | return (0, 0) 243 | 244 | def send_key(self, key_code: Union[KeyCode, int]) -> None: 245 | if isinstance(key_code, KeyCode): 246 | key_code = key_code.value 247 | 248 | MAX = 3200 249 | if key_code > MAX: 250 | raise HdcError("Invalid HDC keycode") 251 | 252 | self.shell(f"uitest uiInput keyEvent {key_code}") 253 | 254 | def tap(self, x: int, y: int) -> None: 255 | self.shell(f"uitest uiInput click {x} {y}") 256 | 257 | def swipe(self, x1, y1, x2, y2, speed=1000): 258 | self.shell(f"uitest uiInput swipe {x1} {y1} {x2} {y2} {speed}") 259 | 260 | def input_text(self, x: int, y: int, text: str): 261 | self.shell(f"uitest uiInput inputText {x} {y} {text}") 262 | 263 | def screenshot(self, path: str) -> str: 264 | _uuid = uuid.uuid4().hex 265 | _tmp_path = f"/data/local/tmp/_tmp_{_uuid}.jpeg" 266 | self.shell(f"snapshot_display -f {_tmp_path}") 267 | self.recv_file(_tmp_path, path) 268 | self.shell(f"rm -rf {_tmp_path}") # remove local path 269 | return path 270 | 271 | def dump_hierarchy(self) -> Dict: 272 | _tmp_path = f"/data/local/tmp/{self.serial}_tmp.json" 273 | self.shell(f"uitest dumpLayout -p {_tmp_path}") 274 | 275 | with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as f: 276 | path = f.name 277 | self.recv_file(_tmp_path, path) 278 | 279 | try: 280 | with open(path, 'r', encoding='utf8') as file: 281 | data = json.load(file) 282 | except Exception as e: 283 | logger.error(f"Error loading JSON file: {e}") 284 | data = {} 285 | 286 | return data 287 | -------------------------------------------------------------------------------- /hmdriver2/proto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | from enum import Enum 5 | from typing import Union, List 6 | from dataclasses import dataclass, asdict 7 | 8 | 9 | @dataclass 10 | class CommandResult: 11 | output: str 12 | error: str 13 | exit_code: int 14 | 15 | 16 | class SwipeDirection(str, Enum): 17 | LEFT = "left" 18 | RIGHT = "right" 19 | UP = "up" 20 | DOWN = "down" 21 | 22 | 23 | class DisplayRotation(int, Enum): 24 | ROTATION_0 = 0 25 | ROTATION_90 = 1 26 | ROTATION_180 = 2 27 | ROTATION_270 = 3 28 | 29 | @classmethod 30 | def from_value(cls, value): 31 | for rotation in cls: 32 | if rotation.value == value: 33 | return rotation 34 | raise ValueError(f"No matching DisplayRotation for value: {value}") 35 | 36 | 37 | class AppState: 38 | INIT = 0 # 初始化状态,应用正在初始化 39 | READY = 1 # 就绪状态,应用已初始化完毕 40 | FOREGROUND = 2 # 前台状态,应用位于前台 41 | FOCUS = 3 # 获焦状态。(预留状态,当前暂不支持) 42 | BACKGROUND = 4 # 后台状态,应用位于后台 43 | EXIT = 5 # 退出状态,应用已退出 44 | 45 | 46 | @dataclass 47 | class DeviceInfo: 48 | productName: str 49 | model: str 50 | sdkVersion: str 51 | sysVersion: str 52 | cpuAbi: str 53 | wlanIp: str 54 | displaySize: tuple 55 | displayRotation: DisplayRotation 56 | 57 | 58 | @dataclass 59 | class HypiumResponse: 60 | """ 61 | Example: 62 | {"result":"On#1"} 63 | {"result":null} 64 | {"result":null,"exception":"Can not connect to AAMS, RET_ERR_CONNECTION_EXIST"} 65 | {"exception":{"code":401,"message":"(PreProcessing: APiCallInfoChecker)Illegal argument count"}} 66 | """ 67 | result: Union[List, bool, str, None] = None 68 | exception: Union[List, bool, str, None] = None 69 | 70 | 71 | @dataclass 72 | class ByData: 73 | value: str # "On#0" 74 | 75 | 76 | @dataclass 77 | class DriverData: 78 | value: str # "Driver#0" 79 | 80 | 81 | @dataclass 82 | class ComponentData: 83 | value: str # "Component#0" 84 | 85 | 86 | @dataclass 87 | class Point: 88 | x: int 89 | y: int 90 | 91 | def to_tuple(self): 92 | return self.x, self.y 93 | 94 | def to_dict(self): 95 | return { 96 | "x": self.x, 97 | "y": self.y 98 | } 99 | 100 | 101 | @dataclass 102 | class Bounds: 103 | left: int 104 | top: int 105 | right: int 106 | bottom: int 107 | 108 | def get_center(self) -> Point: 109 | return Point(int((self.left + self.right) / 2), 110 | int((self.top + self.bottom) / 2)) 111 | 112 | 113 | @dataclass 114 | class ElementInfo: 115 | id: str 116 | key: str 117 | type: str 118 | text: str 119 | description: str 120 | isSelected: bool 121 | isChecked: bool 122 | isEnabled: bool 123 | isFocused: bool 124 | isCheckable: bool 125 | isClickable: bool 126 | isLongClickable: bool 127 | isScrollable: bool 128 | bounds: Bounds 129 | boundsCenter: Point 130 | 131 | def __str__(self) -> str: 132 | return json.dumps(asdict(self), indent=4) 133 | 134 | def to_json(self) -> str: 135 | return json.dumps(asdict(self), indent=4) 136 | 137 | def to_dict(self) -> dict: 138 | return asdict(self) 139 | 140 | 141 | class KeyCode(Enum): 142 | """ 143 | Openharmony键盘码 144 | """ 145 | FN = 0 # 功能(Fn)键 146 | UNKNOWN = -1 # 未知按键 147 | HOME = 1 # 功能(Home)键 148 | BACK = 2 # 返回键 149 | MEDIA_PLAY_PAUSE = 10 # 多媒体键播放/暂停 150 | MEDIA_STOP = 11 # 多媒体键停止 151 | MEDIA_NEXT = 12 # 多媒体键下一首 152 | MEDIA_PREVIOUS = 13 # 多媒体键上一首 153 | MEDIA_REWIND = 14 # 多媒体键快退 154 | MEDIA_FAST_FORWARD = 15 # 多媒体键快进 155 | VOLUME_UP = 16 # 音量增加键 156 | VOLUME_DOWN = 17 # 音量减小键 157 | POWER = 18 # 电源键 158 | CAMERA = 19 # 拍照键 159 | VOLUME_MUTE = 22 # 扬声器静音键 160 | MUTE = 23 # 话筒静音键 161 | BRIGHTNESS_UP = 40 # 亮度调节按键调亮 162 | BRIGHTNESS_DOWN = 41 # 亮度调节按键调暗 163 | NUM_0 = 2000 # 按键’0’ 164 | NUM_1 = 2001 # 按键’1’ 165 | NUM_2 = 2002 # 按键’2’ 166 | NUM_3 = 2003 # 按键’3’ 167 | NUM_4 = 2004 # 按键’4’ 168 | NUM_5 = 2005 # 按键’5’ 169 | NUM_6 = 2006 # 按键’6’ 170 | NUM_7 = 2007 # 按键’7’ 171 | NUM_8 = 2008 # 按键’8’ 172 | NUM_9 = 2009 # 按键’9’ 173 | STAR = 2010 # 按键’*’ 174 | POUND = 2011 # 按键’#’ 175 | DPAD_UP = 2012 # 导航键向上 176 | DPAD_DOWN = 2013 # 导航键向下 177 | DPAD_LEFT = 2014 # 导航键向左 178 | DPAD_RIGHT = 2015 # 导航键向右 179 | DPAD_CENTER = 2016 # 导航键确定键 180 | A = 2017 # 按键’A’ 181 | B = 2018 # 按键’B’ 182 | C = 2019 # 按键’C’ 183 | D = 2020 # 按键’D’ 184 | E = 2021 # 按键’E’ 185 | F = 2022 # 按键’F’ 186 | G = 2023 # 按键’G’ 187 | H = 2024 # 按键’H’ 188 | I = 2025 # 按键’I’ 189 | J = 2026 # 按键’J’ 190 | K = 2027 # 按键’K’ 191 | L = 2028 # 按键’L’ 192 | M = 2029 # 按键’M’ 193 | N = 2030 # 按键’N’ 194 | O = 2031 # 按键’O’ 195 | P = 2032 # 按键’P’ 196 | Q = 2033 # 按键’Q’ 197 | R = 2034 # 按键’R’ 198 | S = 2035 # 按键’S’ 199 | T = 2036 # 按键’T’ 200 | U = 2037 # 按键’U’ 201 | V = 2038 # 按键’V’ 202 | W = 2039 # 按键’W’ 203 | X = 2040 # 按键’X’ 204 | Y = 2041 # 按键’Y’ 205 | Z = 2042 # 按键’Z’ 206 | COMMA = 2043 # 按键’,’ 207 | PERIOD = 2044 # 按键’.’ 208 | ALT_LEFT = 2045 # 左Alt键 209 | ALT_RIGHT = 2046 # 右Alt键 210 | SHIFT_LEFT = 2047 # 左Shift键 211 | SHIFT_RIGHT = 2048 # 右Shift键 212 | TAB = 2049 # Tab键 213 | SPACE = 2050 # 空格键 214 | SYM = 2051 # 符号修改器按键 215 | EXPLORER = 2052 # 浏览器功能键,此键用于启动浏览器应用程序。 216 | ENVELOPE = 2053 # 电子邮件功能键,此键用于启动电子邮件应用程序。 217 | ENTER = 2054 # 回车键 218 | DEL = 2055 # 退格键 219 | GRAVE = 2056 # 按键’`’ 220 | MINUS = 2057 # 按键’-’ 221 | EQUALS = 2058 # 按键’=’ 222 | LEFT_BRACKET = 2059 # 按键’[’ 223 | RIGHT_BRACKET = 2060 # 按键’]’ 224 | BACKSLASH = 2061 # 按键’\’ 225 | SEMICOLON = 2062 # 按键’;’ 226 | APOSTROPHE = 2063 # 按键’‘’(单引号) 227 | SLASH = 2064 # 按键’/’ 228 | AT = 2065 # 按键’@’ 229 | PLUS = 2066 # 按键’+’ 230 | MENU = 2067 # 菜单键 231 | PAGE_UP = 2068 # 向上翻页键 232 | PAGE_DOWN = 2069 # 向下翻页键 233 | ESCAPE = 2070 # ESC键 234 | FORWARD_DEL = 2071 # 删除键 235 | CTRL_LEFT = 2072 # 左Ctrl键 236 | CTRL_RIGHT = 2073 # 右Ctrl键 237 | CAPS_LOCK = 2074 # 大写锁定键 238 | SCROLL_LOCK = 2075 # 滚动锁定键 239 | META_LEFT = 2076 # 左元修改器键 240 | META_RIGHT = 2077 # 右元修改器键 241 | FUNCTION = 2078 # 功能键 242 | SYSRQ = 2079 # 系统请求/打印屏幕键 243 | BREAK = 2080 # Break/Pause键 244 | MOVE_HOME = 2081 # 光标移动到开始键 245 | MOVE_END = 2082 # 光标移动到末尾键 246 | INSERT = 2083 # 插入键 247 | FORWARD = 2084 # 前进键 248 | MEDIA_PLAY = 2085 # 多媒体键播放 249 | MEDIA_PAUSE = 2086 # 多媒体键暂停 250 | MEDIA_CLOSE = 2087 # 多媒体键关闭 251 | MEDIA_EJECT = 2088 # 多媒体键弹出 252 | MEDIA_RECORD = 2089 # 多媒体键录音 253 | F1 = 2090 # 按键’F1’ 254 | F2 = 2091 # 按键’F2’ 255 | F3 = 2092 # 按键’F3’ 256 | F4 = 2093 # 按键’F4’ 257 | F5 = 2094 # 按键’F5’ 258 | F6 = 2095 # 按键’F6’ 259 | F7 = 2096 # 按键’F7’ 260 | F8 = 2097 # 按键’F8’ 261 | F9 = 2098 # 按键’F9’ 262 | F10 = 2099 # 按键’F10’ 263 | F11 = 2100 # 按键’F11’ 264 | F12 = 2101 # 按键’F12’ 265 | NUM_LOCK = 2102 # 小键盘锁 266 | NUMPAD_0 = 2103 # 小键盘按键’0’ 267 | NUMPAD_1 = 2104 # 小键盘按键’1’ 268 | NUMPAD_2 = 2105 # 小键盘按键’2’ 269 | NUMPAD_3 = 2106 # 小键盘按键’3’ 270 | NUMPAD_4 = 2107 # 小键盘按键’4’ 271 | NUMPAD_5 = 2108 # 小键盘按键’5’ 272 | NUMPAD_6 = 2109 # 小键盘按键’6’ 273 | NUMPAD_7 = 2110 # 小键盘按键’7’ 274 | NUMPAD_8 = 2111 # 小键盘按键’8’ 275 | NUMPAD_9 = 2112 # 小键盘按键’9’ 276 | NUMPAD_DIVIDE = 2113 # 小键盘按键’/’ 277 | NUMPAD_MULTIPLY = 2114 # 小键盘按键’*’ 278 | NUMPAD_SUBTRACT = 2115 # 小键盘按键’-’ 279 | NUMPAD_ADD = 2116 # 小键盘按键’+’ 280 | NUMPAD_DOT = 2117 # 小键盘按键’.’ 281 | NUMPAD_COMMA = 2118 # 小键盘按键’,’ 282 | NUMPAD_ENTER = 2119 # 小键盘按键回车 283 | NUMPAD_EQUALS = 2120 # 小键盘按键’=’ 284 | NUMPAD_LEFT_PAREN = 2121 # 小键盘按键’(’ 285 | NUMPAD_RIGHT_PAREN = 2122 # 小键盘按键’)’ 286 | VIRTUAL_MULTITASK = 2210 # 虚拟多任务键 287 | SLEEP = 2600 # 睡眠键 288 | ZENKAKU_HANKAKU = 2601 # 日文全宽/半宽键 289 | ND = 2602 # 102nd按键 290 | RO = 2603 # 日文Ro键 291 | KATAKANA = 2604 # 日文片假名键 292 | HIRAGANA = 2605 # 日文平假名键 293 | HENKAN = 2606 # 日文转换键 294 | KATAKANA_HIRAGANA = 2607 # 日语片假名/平假名键 295 | MUHENKAN = 2608 # 日文非转换键 296 | LINEFEED = 2609 # 换行键 297 | MACRO = 2610 # 宏键 298 | NUMPAD_PLUSMINUS = 2611 # 数字键盘上的加号/减号键 299 | SCALE = 2612 # 扩展键 300 | HANGUEL = 2613 # 日文韩语键 301 | HANJA = 2614 # 日文汉语键 302 | YEN = 2615 # 日元键 303 | STOP = 2616 # 停止键 304 | AGAIN = 2617 # 重复键 305 | PROPS = 2618 # 道具键 306 | UNDO = 2619 # 撤消键 307 | COPY = 2620 # 复制键 308 | OPEN = 2621 # 打开键 309 | PASTE = 2622 # 粘贴键 310 | FIND = 2623 # 查找键 311 | CUT = 2624 # 剪切键 312 | HELP = 2625 # 帮助键 313 | CALC = 2626 # 计算器特殊功能键,用于启动计算器应用程序 314 | FILE = 2627 # 文件按键 315 | BOOKMARKS = 2628 # 书签键 316 | NEXT = 2629 # 下一个按键 317 | PLAYPAUSE = 2630 # 播放/暂停键 318 | PREVIOUS = 2631 # 上一个按键 319 | STOPCD = 2632 # CD停止键 320 | CONFIG = 2634 # 配置键 321 | REFRESH = 2635 # 刷新键 322 | EXIT = 2636 # 退出键 323 | EDIT = 2637 # 编辑键 324 | SCROLLUP = 2638 # 向上滚动键 325 | SCROLLDOWN = 2639 # 向下滚动键 326 | NEW = 2640 # 新建键 327 | REDO = 2641 # 恢复键 328 | CLOSE = 2642 # 关闭键 329 | PLAY = 2643 # 播放键 330 | BASSBOOST = 2644 # 低音增强键 331 | PRINT = 2645 # 打印键 332 | CHAT = 2646 # 聊天键 333 | FINANCE = 2647 # 金融键 334 | CANCEL = 2648 # 取消键 335 | KBDILLUM_TOGGLE = 2649 # 键盘灯光切换键 336 | KBDILLUM_DOWN = 2650 # 键盘灯光调亮键 337 | KBDILLUM_UP = 2651 # 键盘灯光调暗键 338 | SEND = 2652 # 发送键 339 | REPLY = 2653 # 答复键 340 | FORWARDMAIL = 2654 # 邮件转发键 341 | SAVE = 2655 # 保存键 342 | DOCUMENTS = 2656 # 文件键 343 | VIDEO_NEXT = 2657 # 下一个视频键 344 | VIDEO_PREV = 2658 # 上一个视频键 345 | BRIGHTNESS_CYCLE = 2659 # 背光渐变键 346 | BRIGHTNESS_ZERO = 2660 # 亮度调节为0键 347 | DISPLAY_OFF = 2661 # 显示关闭键 348 | BTN_MISC = 2662 # 游戏手柄上的各种按键 349 | GOTO = 2663 # 进入键 350 | INFO = 2664 # 信息查看键 351 | PROGRAM = 2665 # 程序键 352 | PVR = 2666 # 个人录像机(PVR)键 353 | SUBTITLE = 2667 # 字幕键 354 | FULL_SCREEN = 2668 # 全屏键 355 | KEYBOARD = 2669 # 键盘 356 | ASPECT_RATIO = 2670 # 屏幕纵横比调节键 357 | PC = 2671 # 端口控制键 358 | TV = 2672 # TV键 359 | TV2 = 2673 # TV键2 360 | VCR = 2674 # 录像机开启键 361 | VCR2 = 2675 # 录像机开启键2 362 | SAT = 2676 # SIM卡应用工具包(SAT)键 363 | CD = 2677 # CD键 364 | TAPE = 2678 # 磁带键 365 | TUNER = 2679 # 调谐器键 366 | PLAYER = 2680 # 播放器键 367 | DVD = 2681 # DVD键 368 | AUDIO = 2682 # 音频键 369 | VIDEO = 2683 # 视频键 370 | MEMO = 2684 # 备忘录键 371 | CALENDAR = 2685 # 日历键 372 | RED = 2686 # 红色指示器 373 | GREEN = 2687 # 绿色指示器 374 | YELLOW = 2688 # 黄色指示器 375 | BLUE = 2689 # 蓝色指示器 376 | CHANNELUP = 2690 # 频道向上键 377 | CHANNELDOWN = 2691 # 频道向下键 378 | LAST = 2692 # 末尾键 379 | RESTART = 2693 # 重启键 380 | SLOW = 2694 # 慢速键 381 | SHUFFLE = 2695 # 随机播放键 382 | VIDEOPHONE = 2696 # 可视电话键 383 | GAMES = 2697 # 游戏键 384 | ZOOMIN = 2698 # 放大键 385 | ZOOMOUT = 2699 # 缩小键 386 | ZOOMRESET = 2700 # 缩放重置键 387 | WORDPROCESSOR = 2701 # 文字处理键 388 | EDITOR = 2702 # 编辑器键 389 | SPREADSHEET = 2703 # 电子表格键 390 | GRAPHICSEDITOR = 2704 # 图形编辑器键 391 | PRESENTATION = 2705 # 演示文稿键 392 | DATABASE = 2706 # 数据库键标 393 | NEWS = 2707 # 新闻键 394 | VOICEMAIL = 2708 # 语音信箱 395 | ADDRESSBOOK = 2709 # 通讯簿 396 | MESSENGER = 2710 # 通信键 397 | BRIGHTNESS_TOGGLE = 2711 # 亮度切换键 398 | SPELLCHECK = 2712 # AL拼写检查 399 | COFFEE = 2713 # 终端锁/屏幕保护程序 400 | MEDIA_REPEAT = 2714 # 媒体循环键 401 | IMAGES = 2715 # 图像键 402 | BUTTONCONFIG = 2716 # 按键配置键 403 | TASKMANAGER = 2717 # 任务管理器 404 | JOURNAL = 2718 # 日志按键 405 | CONTROLPANEL = 2719 # 控制面板键 406 | APPSELECT = 2720 # 应用程序选择键 407 | SCREENSAVER = 2721 # 屏幕保护程序键 408 | ASSISTANT = 2722 # 辅助键 409 | KBD_LAYOUT_NEXT = 2723 # 下一个键盘布局键 410 | BRIGHTNESS_MIN = 2724 # 最小亮度键 411 | BRIGHTNESS_MAX = 2725 # 最大亮度键 412 | KBDINPUTASSIST_PREV = 2726 # 键盘输入Assist_Previous 413 | KBDINPUTASSIST_NEXT = 2727 # 键盘输入Assist_Next 414 | KBDINPUTASSIST_PREVGROUP = 2728 # 键盘输入Assist_Previous 415 | KBDINPUTASSIST_NEXTGROUP = 2729 # 键盘输入Assist_Next 416 | KBDINPUTASSIST_ACCEPT = 2730 # 键盘输入Assist_Accept 417 | KBDINPUTASSIST_CANCEL = 2731 # 键盘输入Assist_Cancel 418 | FRONT = 2800 # 挡风玻璃除雾器开关 419 | SETUP = 2801 # 设置键 420 | WAKE_UP = 2802 # 唤醒键 421 | SENDFILE = 2803 # 发送文件按键 422 | DELETEFILE = 2804 # 删除文件按键 423 | XFER = 2805 # 文件传输(XFER)按键 424 | PROG1 = 2806 # 程序键1 425 | PROG2 = 2807 # 程序键2 426 | MSDOS = 2808 # MS-DOS键(微软磁盘操作系统 427 | SCREENLOCK = 2809 # 屏幕锁定键 428 | DIRECTION_ROTATE_DISPLAY = 2810 # 方向旋转显示键 429 | CYCLEWINDOWS = 2811 # Windows循环键 430 | COMPUTER = 2812 # 按键 431 | EJECTCLOSECD = 2813 # 弹出CD键 432 | ISO = 2814 # ISO键 433 | MOVE = 2815 # 移动键 434 | F13 = 2816 # 按键’F13’ 435 | F14 = 2817 # 按键’F14’ 436 | F15 = 2818 # 按键’F15’ 437 | F16 = 2819 # 按键’F16’ 438 | F17 = 2820 # 按键’F17’ 439 | F18 = 2821 # 按键’F18’ 440 | F19 = 2822 # 按键’F19’ 441 | F20 = 2823 # 按键’F20’ 442 | F21 = 2824 # 按键’F21’ 443 | F22 = 2825 # 按键’F22’ 444 | F23 = 2826 # 按键’F23’ 445 | F24 = 2827 # 按键’F24’ 446 | PROG3 = 2828 # 程序键3 447 | PROG4 = 2829 # 程序键4 448 | DASHBOARD = 2830 # 仪表板 449 | SUSPEND = 2831 # 挂起键 450 | HP = 2832 # 高阶路径键 451 | SOUND = 2833 # 音量键 452 | QUESTION = 2834 # 疑问按键 453 | CONNECT = 2836 # 连接键 454 | SPORT = 2837 # 运动按键 455 | SHOP = 2838 # 商城键 456 | ALTERASE = 2839 # 交替键 457 | SWITCHVIDEOMODE = 2841 # 在可用视频之间循环输出(监视器/LCD/TV输出/等) 458 | BATTERY = 2842 # 电池按键 459 | BLUETOOTH = 2843 # 蓝牙按键 460 | WLAN = 2844 # 无线局域网 461 | UWB = 2845 # 超宽带(UWB) 462 | WWAN_WIMAX = 2846 # WWANWiMAX按键 463 | RFKILL = 2847 # 控制所有收音机的键 464 | CHANNEL = 3001 # 向上频道键 465 | BTN_0 = 3100 # 按键0 466 | BTN_1 = 3101 # 按键1 467 | BTN_2 = 3102 # 按键2 468 | BTN_3 = 3103 # 按键3 469 | BTN_4 = 3104 # 按键4 470 | BTN_5 = 3105 # 按键5 471 | BTN_6 = 3106 # 按键6 472 | BTN_7 = 3107 # 按键7 473 | BTN_8 = 3108 # 按键8 474 | BTN_9 = 3109 # 按键9 -------------------------------------------------------------------------------- /hmdriver2/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | import time 5 | import socket 6 | import re 7 | from functools import wraps 8 | from typing import Union 9 | 10 | from .proto import Bounds 11 | 12 | 13 | def delay(func): 14 | """ 15 | After each UI operation, it is necessary to wait for a while to ensure the stability of the UI, 16 | so as not to affect the next UI operation. 17 | """ 18 | DELAY_TIME = 0.6 19 | 20 | @wraps(func) 21 | def wrapper(*args, **kwargs): 22 | result = func(*args, **kwargs) 23 | time.sleep(DELAY_TIME) 24 | return result 25 | return wrapper 26 | 27 | 28 | class FreePort: 29 | def __init__(self): 30 | self._start = 10000 31 | self._end = 20000 32 | self._now = self._start - 1 33 | 34 | def get(self) -> int: 35 | while True: 36 | self._now += 1 37 | if self._now > self._end: 38 | self._now = self._start 39 | if not self.is_port_in_use(self._now): 40 | return self._now 41 | 42 | @staticmethod 43 | def is_port_in_use(port: int) -> bool: 44 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 45 | return s.connect_ex(('localhost', port)) == 0 46 | 47 | 48 | def parse_bounds(bounds: str) -> Union[Bounds, None]: 49 | """ 50 | Parse bounds string to Bounds. 51 | bounds is str, like: "[832,1282][1125,1412]" 52 | """ 53 | result = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds) 54 | if result: 55 | g = result.groups() 56 | return Bounds(int(g[0]), 57 | int(g[1]), 58 | int(g[2]), 59 | int(g[3])) 60 | return None -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "hmdriver2" 3 | version = "1.0.0" 4 | description = "UI Automation Framework for Harmony Next" 5 | authors = ["codematrixer "] 6 | license = "MIT" 7 | readme = "README.md" 8 | include = ["*/assets/*"] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | lxml = "^5.3.0" 13 | 14 | [tool.poetry.extras] 15 | opencv-python = ["opencv-python-headless"] 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | pytest = "^8.3.2" 19 | 20 | [build-system] 21 | requires = ["poetry-core>=1.0.0"] 22 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /runtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pytest tests --capture=no 4 | # pytest tests/test_driver.py::test_toast --capture=no 5 | # pytest tests/test_driver.py::test_toast tests/test_driver.py::test_get_app_main_ability --capture=no 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | import pytest 5 | 6 | 7 | # sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 8 | # pip install -e . 9 | 10 | from hmdriver2._client import HmClient 11 | from hmdriver2.proto import HypiumResponse 12 | 13 | 14 | @pytest.fixture 15 | def client(): 16 | client = HmClient("FMR0223C13000649") 17 | client.start() 18 | yield client 19 | client.release() 20 | 21 | 22 | def test_client_create(client): 23 | resp = client.invoke("Driver.create") # {"result":"Driver#0"} 24 | assert resp == HypiumResponse("Driver#0") 25 | 26 | 27 | def test_client_invoke(client): 28 | resp: HypiumResponse = client.invoke("Driver.getDisplaySize") 29 | assert resp == HypiumResponse({"x": 1260, "y": 2720}) 30 | 31 | # client.hdc.start_app("com.kuaishou.hmapp", "EntryAbility") 32 | client.hdc.start_app("com.samples.test.uitest", "EntryAbility") 33 | 34 | resp = client.invoke("On.text", this="On#seed", args=["showToast"]) # {"result":"On#0"} 35 | by = resp.result 36 | resp = client.invoke("Driver.findComponent", this="Driver#0", args=[by]) # {"result":"Component#0"} 37 | t1 = int(time.time()) 38 | resp = client.invoke("Driver.waitForComponent", this="Driver#0", args=[by, 2000]) # {"result":"Component#0"} 39 | 40 | t2 = int(time.time()) 41 | print(f"take: {t2 - t1}") 42 | component = resp.result 43 | 44 | resp = client.invoke("Component.getText", this=component, args=[]) # {"result":"Component#0"} 45 | assert resp.result == "showToast" -------------------------------------------------------------------------------- /tests/test_driver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import time 5 | import pytest 6 | 7 | from hmdriver2.driver import Driver 8 | from hmdriver2.proto import KeyCode, DisplayRotation 9 | 10 | 11 | @pytest.fixture 12 | def d(): 13 | d = Driver() 14 | yield d 15 | del d 16 | 17 | 18 | def test_driver(d): 19 | dd = Driver("FMR0223C13000649") 20 | assert id(dd) == id(d) 21 | 22 | 23 | def test_device_info(d): 24 | info = d.device_info 25 | assert info.sdkVersion == "12" 26 | assert info.cpuAbi == "arm64-v8a" 27 | assert info.displaySize == (1260, 2720) 28 | 29 | 30 | def test_force_start_app(d): 31 | d.unlock() 32 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 33 | 34 | 35 | def test_get_app_main_ability(d): 36 | d.unlock() 37 | ability = d.get_app_main_ability("com.samples.test.uitest") 38 | assert ability.get("name") == "EntryAbility" 39 | 40 | 41 | def test_clear_app(d): 42 | d.clear_app("com.samples.test.uitest") 43 | 44 | 45 | def test_install_app(d): 46 | pass 47 | 48 | 49 | def test_uninstall_app(d): 50 | pass 51 | 52 | 53 | def test_list_apps(d): 54 | assert "com.samples.test.uitest" in d.list_apps() 55 | 56 | 57 | def test_has_app(d): 58 | assert d.has_app("com.samples.test.uitest") 59 | 60 | 61 | def test_current_app(d): 62 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 63 | assert d.current_app() == ("com.samples.test.uitest", "EntryAbility") 64 | 65 | 66 | def test_get_app_info(d): 67 | assert d.get_app_info("com.samples.test.uitest") 68 | 69 | 70 | def test_go_back(d): 71 | d.go_back() 72 | 73 | 74 | def test_go_home(d): 75 | d.go_home() 76 | 77 | 78 | def test_press_key(d): 79 | d.press_key(KeyCode.POWER) 80 | 81 | 82 | def test_screen_on(d): 83 | d.screen_on() 84 | assert d.hdc.screen_state() == "AWAKE" 85 | 86 | 87 | def test_screen_off(d): 88 | d.screen_off() 89 | time.sleep(3) 90 | assert d.hdc.screen_state() != "AWAKE" 91 | 92 | 93 | def test_unlock(d): 94 | d.screen_off() 95 | d.unlock() 96 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 97 | assert d.hdc.screen_state() == "AWAKE" 98 | 99 | 100 | def test_display_size(d): 101 | w, h = d.display_size 102 | assert w, h == (1260, 2720) 103 | 104 | 105 | def test_display_rotation(d): 106 | assert d.display_rotation == DisplayRotation.ROTATION_0 107 | 108 | 109 | def test_open_url(d): 110 | d.open_url("http://www.baidu.com") 111 | 112 | 113 | def test_pull_file(d): 114 | rpath = "/data/local/tmp/agent.so" 115 | lpath = "./agent.so" 116 | d.pull_file(rpath, lpath) 117 | assert os.path.exists(lpath) 118 | 119 | 120 | def test_push_file(d): 121 | lpath = "~/arch.png" 122 | rpath = "/data/local/tmp/arch.png" 123 | d.push_file(lpath, rpath) 124 | 125 | 126 | def test_screenshot(d) -> str: 127 | lpath = "./test.png" 128 | d.screenshot(lpath) 129 | assert os.path.exists(lpath) 130 | 131 | 132 | def test_shell(d): 133 | d.shell("pwd") 134 | 135 | 136 | def test_click(d): 137 | d.click(500, 1000) 138 | d.click(0.5, 0.4) 139 | d.click(0.5, 400) 140 | 141 | 142 | def test_double_click(d): 143 | d.double_click(500, 1000) 144 | d.double_click(0.5, 0.4) 145 | d.double_click(0.5, 400) 146 | 147 | 148 | def test_long_click(d): 149 | d.long_click(500, 1000) 150 | d.long_click(0.5, 0.4) 151 | d.long_click(0.5, 400) 152 | 153 | 154 | def test_swipe(d): 155 | d.swipe(0.5, 0.8, 0.5, 0.4, speed=2000) 156 | 157 | 158 | def test_input_text(d): 159 | d.input_text("adbcdfg") 160 | 161 | 162 | def test_dump_hierarchy(d): 163 | data = d.dump_hierarchy() 164 | print(data) 165 | assert data 166 | 167 | 168 | def test_toast(d): 169 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 170 | d.toast_watcher.start() 171 | d(type="Button", text="showToast").click() 172 | toast = d.toast_watcher.get_toast() 173 | print(f"toast: {toast}") 174 | assert toast == "testMessage" 175 | 176 | 177 | def test_gesture(d): 178 | d(id="drag").click() 179 | d.gesture.start(630, 984, interval=1).move(0.2, 0.4, interval=.5).pause(interval=1).move(0.5, 0.6, interval=.5).pause(interval=1).action() 180 | d.go_back() 181 | 182 | 183 | def test_gesture_click(d): 184 | d.gesture.start(0.77, 0.49).action() 185 | 186 | 187 | def test_screenrecord(d): 188 | path = "test.mp4" 189 | d.screenrecord.start(path) 190 | 191 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 192 | time.sleep(5) 193 | for _ in range(3): 194 | d.swipe(0.5, 0.8, 0.5, 0.3) 195 | d.go_home() 196 | 197 | path = d.screenrecord.stop() 198 | assert os.path.isfile(path) 199 | 200 | 201 | def test_screenrecord2(d): 202 | path = "test2.mp4" 203 | with d.screenrecord.start(path): 204 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 205 | time.sleep(5) 206 | for _ in range(3): 207 | d.swipe(0.5, 0.8, 0.5, 0.3) 208 | d.go_home() 209 | 210 | assert os.path.isfile(path) 211 | 212 | 213 | def test_xpath(d): 214 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 215 | 216 | xpath1 = '//root[1]/Row[1]/Column[1]/Row[1]/Button[3]' # showToast 217 | xpath2 = '//*[@text="showDialog"]' 218 | 219 | d.toast_watcher.start() 220 | d.xpath(xpath1).click() 221 | toast = d.toast_watcher.get_toast() 222 | print(f"toast: {toast}") 223 | assert toast == "testMessage" 224 | 225 | d.xpath(xpath2).click() -------------------------------------------------------------------------------- /tests/test_element.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | 5 | from hmdriver2.driver import Driver 6 | from hmdriver2.proto import ElementInfo, ComponentData 7 | 8 | 9 | @pytest.fixture 10 | def d(): 11 | d = Driver("FMR0223C13000649") 12 | d.force_start_app("com.samples.test.uitest", "EntryAbility") 13 | yield d 14 | del d 15 | 16 | 17 | def test_by_type(d): 18 | d(type="Button") 19 | 20 | 21 | def test_by_combine(d): 22 | assert d(type="Button", text="showToast").exists() 23 | assert not d(type="Button", text="showToast1").exists() 24 | assert d(type="Button", index=3).exists() 25 | assert not d(type="Button", index=5).exists() 26 | 27 | 28 | def test_isBefore_isAfter(d): 29 | assert d(text="showToast", isAfter=True).text == "showDialog" 30 | # assert d(id="drag", isBefore=True).text == "showDialog" 31 | 32 | 33 | def test_count(d): 34 | assert d(type="Button").count == 5 35 | assert len(d(type="Button")) == 5 36 | 37 | 38 | def test_id(d): 39 | assert d(text="showToast").text == "showToast" 40 | 41 | 42 | def test_info(d): 43 | info: ElementInfo = d(text="showToast").info 44 | mock = { 45 | "id": "", 46 | "key": "", 47 | "type": "Button", 48 | "text": "showToast", 49 | "description": "", 50 | "isSelected": False, 51 | "isChecked": False, 52 | "isEnabled": True, 53 | "isFocused": False, 54 | "isCheckable": False, 55 | "isClickable": True, 56 | "isLongClickable": False, 57 | "isScrollable": False, 58 | "bounds": { 59 | "left": 539, 60 | "top": 1282, 61 | "right": 832, 62 | "bottom": 1412 63 | }, 64 | "boundsCenter": { 65 | "x": 685, 66 | "y": 1347 67 | } 68 | } 69 | assert info.to_dict() == mock 70 | 71 | 72 | def test_click(d): 73 | d(text="showToast1").click_if_exists() 74 | d(text="showToast").click() 75 | d(type="Button", index=3).click() 76 | d.click(0.5, 0.2) 77 | 78 | 79 | def test_double_click(d): 80 | d(text="unit_jsunit").double_click() 81 | assert d(id="swiper").exists() 82 | d.go_back() 83 | 84 | 85 | def test_long_click(d): 86 | d(text="showToast").long_click() 87 | 88 | 89 | def test_drag_to(d): 90 | d(id="drag").click() 91 | assert d(type="Text", index=6).text == "two" 92 | 93 | component: ComponentData = d(type="ListItem", index=1).find_component() 94 | d(type="ListItem").drag_to(component) 95 | assert d(type="Text", index=5).text == "two" 96 | 97 | 98 | def test_input(d): 99 | d(text="showToast").input_text("abc") 100 | d(text="showToast").clear_text() 101 | 102 | 103 | def test_pinch(d): 104 | d(text="showToast").pinch_in() 105 | d(text="showToast").pinch_out() 106 | --------------------------------------------------------------------------------