├── .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 | [](https://github.com/codematrixer/hmdriver2/actions)
3 | [](https://pypi.python.org/pypi/hmdriver2)
4 | 
5 | [](https://pepy.tech/project/hmdriver2)
6 |
7 |
8 | > 写这个项目前github上已有个叫`hmdriver`的项目,但它是侵入式(需要提前在手机端安装一个testRunner app);另外鸿蒙官方提供的hypium自动化框架,使用较为复杂,依赖繁杂。于是决定重写一套。
9 |
10 |
11 | **hmdriver2** 是一款支持`HarmonyOS NEXT`系统的UI自动化框架,**无侵入式**,提供应用管理,UI操作,元素定位等功能,轻量高效,上手简单,快速实现鸿蒙应用自动化测试需求。
12 |
13 | 
14 |
15 |
16 | *微信交流群(永久生效)*
17 |
18 |
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 | 
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 | 
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 |
--------------------------------------------------------------------------------