├── chrome_plugin ├── popup.js ├── options.js ├── client.js ├── options.html ├── image │ ├── get_started128.png │ ├── get_started16.png │ ├── get_started32.png │ └── get_started48.png ├── popup.css ├── popup.html ├── d.js ├── manifest.json └── background.js ├── poetry.toml ├── codecov.yml ├── uiautodev ├── binaries │ ├── scrcpy-server-v2.7.jar │ └── scrcpy-server-v3.3.3.jar ├── driver │ ├── udt │ │ ├── appium-uiautomator2-v5.12.4-light.apk │ │ └── udt.py │ ├── android │ │ ├── __init__.py │ │ ├── u2_driver.py │ │ ├── common.py │ │ └── adb_driver.py │ ├── mock.py │ ├── base_driver.py │ ├── ios.py │ ├── appium.py │ └── harmony.py ├── __init__.py ├── __main__.py ├── utils │ ├── envutils.py │ ├── exceptions.py │ ├── common.py │ └── usbmux.py ├── exceptions.py ├── router │ ├── xml.py │ ├── android.py │ ├── device.py │ └── proxy.py ├── model.py ├── static │ └── demo.html ├── common.py ├── appium_proxy.py ├── command_types.py ├── remote │ ├── android_input.py │ ├── scrcpy3.py │ ├── pipe.py │ ├── touch_controller.py │ ├── scrcpy.py │ ├── harmony_mjpeg.py │ └── keycode.py ├── provider.py ├── case.py ├── command_proxy.py ├── cli.py └── app.py ├── pytest.ini ├── runtest.sh ├── Makefile ├── tests ├── test_pydantic.py ├── test_api.py ├── test_android.py └── test_touch_controller.py ├── e2etests ├── test_scrcpy.py └── test_harmony_driver.py ├── .vscode └── launch.json ├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── LICENSE ├── pyproject.toml ├── DEVELOP.md ├── README.md ├── .gitignore └── examples └── harmony-video.html /chrome_plugin/popup.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /chrome_plugin/options.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: d4ab8f2c-0898-4b61-91c0-c0008863c201 -------------------------------------------------------------------------------- /chrome_plugin/client.js: -------------------------------------------------------------------------------- 1 | var Client = (function() { 2 | return {} 3 | })(); -------------------------------------------------------------------------------- /chrome_plugin/options.html: -------------------------------------------------------------------------------- 1 |
\d+)", output)
160 | version_code = m.group("code") if m else ""
161 | version_code = int(version_code) if version_code.isdigit() else None
162 |
163 | return {
164 | "versionName": version_name,
165 | "versionCode": version_code
166 | }
167 |
168 | def app_list(self) -> List[AppInfo]:
169 | results = []
170 | output = self.adb_device.shell(["pm", "list", "packages", '-3'])
171 | for m in re.finditer(r"^package:([^\s]+)\r?$", output, re.M):
172 | packageName = m.group(1)
173 | # get version
174 | version_info = self.get_app_version(packageName)
175 | app_info = AppInfo(
176 | packageName=packageName,
177 | versionName=version_info.get("versionName"),
178 | versionCode=version_info.get("versionCode")
179 | )
180 | results.append(app_info)
181 | return results
182 |
183 | def open_app_file(self, package: str) -> Iterator[bytes]:
184 | line = self.adb_device.shell(f"pm path {package}")
185 | assert isinstance(line, str)
186 | if not line.startswith("package:"):
187 | raise AndroidDriverException(f"Failed to get package path: {line}")
188 | remote_path = line.split(':', 1)[1]
189 | yield from self.adb_device.sync.iter_content(remote_path)
190 |
191 | def send_keys(self, text: str):
192 | self.adb_device.send_keys(text)
193 |
194 | def clear_text(self):
195 | for _ in range(3):
196 | self.adb_device.shell2("input keyevent DEL --longpress")
197 |
--------------------------------------------------------------------------------
/uiautodev/cli.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Tue Mar 19 2024 10:53:03 by codeskyblue
5 | """
6 |
7 | from __future__ import annotations
8 |
9 | import logging
10 | import os
11 | import platform
12 | import subprocess
13 | import sys
14 | import threading
15 | import time
16 | from pprint import pprint
17 |
18 | import click
19 | import httpx
20 | import pydantic
21 | import uvicorn
22 | from retry import retry
23 | from rich.logging import RichHandler
24 |
25 | from uiautodev import __version__, command_proxy
26 | from uiautodev.command_types import Command
27 | from uiautodev.common import get_webpage_url
28 | from uiautodev.provider import AndroidProvider, BaseProvider, IOSProvider
29 | from uiautodev.utils.common import convert_params_to_model, print_json
30 |
31 | logger = logging.getLogger(__name__)
32 |
33 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
34 | HARMONY_PACKAGES = [
35 | "setuptools",
36 | "https://public.uiauto.devsleep.com/harmony/xdevice-5.0.7.200.tar.gz",
37 | "https://public.uiauto.devsleep.com/harmony/xdevice-devicetest-5.0.7.200.tar.gz",
38 | "https://public.uiauto.devsleep.com/harmony/xdevice-ohos-5.0.7.200.tar.gz",
39 | "https://public.uiauto.devsleep.com/harmony/hypium-5.0.7.200.tar.gz",
40 | ]
41 |
42 |
43 | def enable_logger_to_console(level):
44 | _logger = logging.getLogger("uiautodev")
45 | _logger.setLevel(level)
46 | _logger.addHandler(RichHandler(enable_link_path=False))
47 |
48 |
49 | @click.group(context_settings=CONTEXT_SETTINGS)
50 | @click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode")
51 | def cli(verbose: bool):
52 | if verbose:
53 | enable_logger_to_console(level=logging.DEBUG)
54 | logger.debug("Verbose mode enabled")
55 | else:
56 | enable_logger_to_console(level=logging.INFO)
57 |
58 |
59 | def run_driver_command(provider: BaseProvider, command: Command, params: list[str] = None):
60 | if command == Command.LIST:
61 | devices = provider.list_devices()
62 | print("==> Devices <==")
63 | pprint(devices)
64 | return
65 | driver = provider.get_single_device_driver()
66 | params_obj = None
67 | model = command_proxy.get_command_params_type(command)
68 | if model:
69 | if not params:
70 | print(f"params is required for {command}")
71 | pprint(model.model_json_schema())
72 | return
73 | params_obj = convert_params_to_model(params, model)
74 |
75 | try:
76 | print("Command:", command.value)
77 | print("Params ↓")
78 | print_json(params_obj)
79 | result = command_proxy.send_command(driver, command, params_obj)
80 | print("Result ↓")
81 | print_json(result)
82 | except pydantic.ValidationError as e:
83 | print(f"params error: {e}")
84 | print(f"\n--- params should be match schema ---")
85 | pprint(model.model_json_schema()["properties"])
86 |
87 |
88 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command))
89 | @click.argument("command", type=Command, required=True)
90 | @click.argument("params", required=False, nargs=-1)
91 | def android(command: Command, params: list[str] = None):
92 | provider = AndroidProvider()
93 | run_driver_command(provider, command, params)
94 |
95 |
96 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command))
97 | @click.argument("command", type=Command, required=True)
98 | @click.argument("params", required=False, nargs=-1)
99 | def ios(command: Command, params: list[str] = None):
100 | provider = IOSProvider()
101 | run_driver_command(provider, command, params)
102 |
103 |
104 | @cli.command(help="run case (beta)")
105 | def case():
106 | from uiautodev.case import run
107 | run()
108 |
109 |
110 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command))
111 | @click.argument("command", type=Command, required=True)
112 | @click.argument("params", required=False, nargs=-1)
113 | def appium(command: Command, params: list[str] = None):
114 | from uiautodev.driver.appium import AppiumProvider
115 | from uiautodev.exceptions import AppiumDriverException
116 |
117 | provider = AppiumProvider()
118 | try:
119 | run_driver_command(provider, command, params)
120 | except AppiumDriverException as e:
121 | print(f"Error: {e}")
122 |
123 |
124 | @cli.command('version')
125 | def print_version():
126 | """ Print version """
127 | print(__version__)
128 |
129 |
130 | @cli.command('self-update')
131 | def self_update():
132 | """ Update uiautodev to latest version """
133 | subprocess.run([sys.executable, '-m', "pip", "install", "--upgrade", "uiautodev"])
134 |
135 |
136 | @cli.command('install-harmony')
137 | def install_harmony():
138 | pip_install("hypium")
139 |
140 | @retry(tries=2, delay=3, backoff=2)
141 | def pip_install(package: str):
142 | """Install a package using pip."""
143 | subprocess.run([sys.executable, '-m', "pip", "install", package], check=True)
144 | click.echo(f"Successfully installed {package}")
145 |
146 |
147 | @cli.command(help="start uiauto.dev local server [Default]")
148 | @click.option("--port", default=20242, help="port number", show_default=True)
149 | @click.option("--host", default="127.0.0.1", help="host", show_default=True)
150 | @click.option("--reload", is_flag=True, default=False, help="auto reload, dev only")
151 | @click.option("-f", "--force", is_flag=True, default=False, help="shutdown already running server")
152 | @click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser")
153 | @click.option("--offline", is_flag=True, default=False, help="offline mode, do not use internet")
154 | @click.option("--server-url", default="https://uiauto.dev", help="uiauto.dev server url", show_default=True)
155 | def server(port: int, host: str, reload: bool, force: bool, no_browser: bool, offline: bool, server_url: str):
156 | click.echo(f"uiautodev version: {__version__}")
157 | if force:
158 | try:
159 | httpx.get(f"http://{host}:{port}/shutdown", timeout=3)
160 | except httpx.HTTPError:
161 | pass
162 |
163 | use_color = True
164 | if platform.system() == 'Windows':
165 | use_color = False
166 |
167 | server_url = server_url.rstrip('/')
168 | from uiautodev.router import proxy
169 | proxy.base_url = server_url
170 |
171 | if offline:
172 | proxy.cache_dir.mkdir(parents=True, exist_ok=True)
173 | logger.info("offline mode enabled, cache dir: %s, server url: %s", proxy.cache_dir, proxy.base_url)
174 |
175 | if not no_browser:
176 | th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}", offline))
177 | th.daemon = True
178 | th.start()
179 | uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color)
180 |
181 | @cli.command(help="shutdown uiauto.dev local server")
182 | @click.option("--port", default=20242, help="port number", show_default=True)
183 | def shutdown(port: int):
184 | try:
185 | httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3)
186 | except httpx.HTTPError:
187 | pass
188 |
189 |
190 | def open_browser_when_server_start(local_server_url: str, offline: bool = False):
191 | deadline = time.time() + 10
192 | while time.time() < deadline:
193 | try:
194 | httpx.get(f"{local_server_url}/api/info", timeout=1)
195 | break
196 | except Exception as e:
197 | time.sleep(0.5)
198 | import webbrowser
199 | web_url = get_webpage_url(local_server_url if offline else None)
200 | logger.info("open browser: %s", web_url)
201 | webbrowser.open(web_url)
202 |
203 |
204 | def main():
205 | has_command = False
206 | for name in sys.argv[1:]:
207 | if not name.startswith("-"):
208 | has_command = True
209 |
210 | if not has_command:
211 | cli.main(args=sys.argv[1:] + ["server"], prog_name="uiauto.dev")
212 | else:
213 | cli()
214 |
215 |
216 | if __name__ == "__main__":
217 | main()
218 |
--------------------------------------------------------------------------------
/uiautodev/remote/scrcpy.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import logging
4 | import os
5 | import socket
6 | import struct
7 | from pathlib import Path
8 | from typing import Optional
9 |
10 | import retry
11 | from adbutils import AdbError, Network, adb
12 | from adbutils._adb import AdbConnection
13 | from adbutils._device import AdbDevice
14 | from starlette.websockets import WebSocket, WebSocketDisconnect
15 |
16 | from uiautodev.remote.touch_controller import ScrcpyTouchController
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class ScrcpyServer:
22 | """
23 | ScrcpyServer class is responsible for managing the scrcpy server on Android devices.
24 | It handles the initialization, communication, and control of the scrcpy server,
25 | including video streaming and touch control.
26 | """
27 |
28 | def __init__(self, device: AdbDevice, version: Optional[str] = "2.7"):
29 | """
30 | Initializes the ScrcpyServer instance.
31 |
32 | Args:
33 | device (AdbDevice): The ADB device instance to use.
34 | version (str, optional): Scrcpy server version to use. Defaults to "2.7".
35 | """
36 | self.scrcpy_jar_path = Path(__file__).parent.joinpath(f'../binaries/scrcpy-server-v{version}.jar')
37 | if self.scrcpy_jar_path.exists() is False:
38 | raise FileNotFoundError(f"Scrcpy server JAR not found: {self.scrcpy_jar_path}")
39 | self.device = device
40 | self.version = version
41 | self.resolution_width = 0 # scrcpy 投屏转换宽度
42 | self.resolution_height = 0 # scrcpy 投屏转换高度
43 |
44 | self._shell_conn: AdbConnection
45 | self._video_conn: socket.socket
46 | self._control_conn: socket.socket
47 |
48 | self._setup_connection()
49 |
50 | def _setup_connection(self):
51 | self._shell_conn = self._start_scrcpy_server(control=True)
52 | self._video_conn = self._connect_scrcpy(self.device)
53 | self._control_conn = self._connect_scrcpy(self.device)
54 | self._parse_scrcpy_info(self._video_conn)
55 |
56 | self.controller = ScrcpyTouchController(self._control_conn)
57 |
58 | @retry.retry(exceptions=AdbError, tries=20, delay=0.1)
59 | def _connect_scrcpy(self, device: AdbDevice) -> socket.socket:
60 | return device.create_connection(Network.LOCAL_ABSTRACT, 'scrcpy')
61 |
62 | def _parse_scrcpy_info(self, conn: socket.socket):
63 | dummy_byte = conn.recv(1)
64 | if not dummy_byte or dummy_byte != b"\x00":
65 | raise ConnectionError("Did not receive Dummy Byte!")
66 | logger.debug('Received Dummy Byte!')
67 | # print('Received Dummy Byte!')
68 | if self.version == '3.3.3': # 临时处理一下, 3.3.3使用WebCodec来接码,前端解析分辨率
69 | return
70 | device_name = conn.recv(64).decode('utf-8').rstrip('\x00')
71 | logger.debug(f'Device name: {device_name}')
72 | codec = conn.recv(4)
73 | logger.debug(f'resolution_data: {codec}')
74 | resolution_data = conn.recv(8)
75 | logger.debug(f'resolution_data: {resolution_data}')
76 | self.resolution_width, self.resolution_height = struct.unpack(">II", resolution_data)
77 | logger.debug(f'Resolution: {self.resolution_width}x{self.resolution_height}')
78 |
79 | def close(self):
80 | try:
81 | self._control_conn.close()
82 | self._video_conn.close()
83 | self._shell_conn.close()
84 | except:
85 | pass
86 |
87 | def __del__(self):
88 | self.close()
89 |
90 | def _start_scrcpy_server(self, control: bool = True) -> AdbConnection:
91 | """
92 | Pushes the scrcpy server JAR file to the Android device and starts the scrcpy server.
93 |
94 | Args:
95 | control (bool, optional): Whether to enable touch control. Defaults to True.
96 |
97 | Returns:
98 | AdbConnection
99 | """
100 | # 获取设备对象
101 | device = self.device
102 |
103 | # 推送 scrcpy 服务器到设备
104 | device.sync.push(self.scrcpy_jar_path, '/data/local/tmp/scrcpy_server.jar', check=True)
105 | logger.info('scrcpy server JAR pushed to device')
106 |
107 | # 构建启动 scrcpy 服务器的命令
108 | cmds = [
109 | 'CLASSPATH=/data/local/tmp/scrcpy_server.jar',
110 | 'app_process', '/',
111 | f'com.genymobile.scrcpy.Server', self.version,
112 | 'log_level=info', 'max_size=1024', 'max_fps=30',
113 | 'video_bit_rate=8000000', 'tunnel_forward=true',
114 | 'send_frame_meta='+('true' if self.version == '3.3.3' else 'false'),
115 | f'control={"true" if control else "false"}',
116 | 'audio=false', 'show_touches=false', 'stay_awake=false',
117 | 'power_off_on_close=false', 'clipboard_autosync=false'
118 | ]
119 | conn = device.shell(' '.join(cmds), stream=True)
120 | logger.debug("scrcpy output: %s", conn.conn.recv(100))
121 | return conn # type: ignore
122 |
123 | async def handle_unified_websocket(self, websocket: WebSocket, serial=''):
124 | logger.info(f"[Unified] WebSocket connection from {websocket} for serial: {serial}")
125 |
126 | video_task = asyncio.create_task(self._stream_video_to_websocket(self._video_conn, websocket))
127 | control_task = asyncio.create_task(self._handle_control_websocket(websocket))
128 |
129 | try:
130 | # 不使用 return_exceptions=True,让异常能够正确传播
131 | await asyncio.gather(video_task, control_task)
132 | finally:
133 | # 取消任务
134 | for task in (video_task, control_task):
135 | if not task.done():
136 | task.cancel()
137 | logger.info(f"[Unified] WebSocket closed for serial={serial}")
138 |
139 | async def _stream_video_to_websocket(self, conn: socket.socket, ws: WebSocket):
140 | # Set socket to non-blocking mode
141 | conn.setblocking(False)
142 |
143 | while True:
144 | # check if ws closed
145 | if ws.client_state.name != "CONNECTED":
146 | logger.info('WebSocket no longer connected. Exiting video stream.')
147 | break
148 | # Use asyncio to read data asynchronously
149 | data = await asyncio.get_event_loop().sock_recv(conn, 1024 * 1024)
150 | if not data:
151 | logger.warning('No data received, connection may be closed.')
152 | raise ConnectionError("Video stream ended unexpectedly")
153 | # send data to ws
154 | await ws.send_bytes(data)
155 |
156 | async def _handle_control_websocket(self, ws: WebSocket):
157 | while True:
158 | try:
159 | message = await ws.receive_text()
160 | logger.debug(f"[Unified] Received message: {message}")
161 | message = json.loads(message)
162 |
163 | width, height = self.resolution_width, self.resolution_height
164 | message_type = message.get('type')
165 | if message_type == 'touchMove':
166 | xP = message['xP']
167 | yP = message['yP']
168 | self.controller.move(int(xP * width), int(yP * height), width, height)
169 | elif message_type == 'touchDown':
170 | xP = message['xP']
171 | yP = message['yP']
172 | self.controller.down(int(xP * width), int(yP * height), width, height)
173 | elif message_type == 'touchUp':
174 | xP = message['xP']
175 | yP = message['yP']
176 | self.controller.up(int(xP * width), int(yP * height), width, height)
177 | elif message_type == 'keyEvent':
178 | event_number = message['data']['eventNumber']
179 | self.device.shell(f'input keyevent {event_number}')
180 | elif message_type == 'text':
181 | text = message['detail']
182 | self.device.shell(f'am broadcast -a SONIC_KEYBOARD --es msg \'{text}\'')
183 | elif message_type == 'ping':
184 | await ws.send_text(json.dumps({"type": "pong"}))
185 | except json.JSONDecodeError as e:
186 | logger.error(f"Invalid JSON message: {e}")
187 | continue
188 |
--------------------------------------------------------------------------------
/uiautodev/app.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Sun Feb 18 2024 13:48:55 by codeskyblue"""
5 |
6 | import logging
7 | import os
8 | import platform
9 | import signal
10 | from pathlib import Path
11 | from typing import Dict, List
12 |
13 | import adbutils
14 | import httpx
15 | import uvicorn
16 | from fastapi import FastAPI, File, Request, Response, UploadFile, WebSocket
17 | from fastapi.middleware.cors import CORSMiddleware
18 | from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
19 | from pydantic import BaseModel
20 | from starlette.websockets import WebSocketDisconnect
21 |
22 | from uiautodev import __version__
23 | from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
24 | from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
25 | from uiautodev.model import Node
26 | from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
27 | from uiautodev.remote.scrcpy import ScrcpyServer
28 | from uiautodev.router.android import router as android_device_router
29 | from uiautodev.router.device import make_router
30 | from uiautodev.router.proxy import make_reverse_proxy
31 | from uiautodev.router.proxy import router as proxy_router
32 | from uiautodev.router.xml import router as xml_router
33 | from uiautodev.utils.envutils import Environment
34 |
35 | logger = logging.getLogger(__name__)
36 |
37 | app = FastAPI()
38 |
39 | app.add_middleware(
40 | CORSMiddleware,
41 | allow_origins=["*"],
42 | allow_credentials=True,
43 | allow_methods=["GET", "POST"],
44 | allow_headers=["*"],
45 | )
46 |
47 | android_default_driver = U2AndroidDriver
48 | if os.getenv("UIAUTODEV_USE_ADB_DRIVER") in ("1", "true", "True"):
49 | android_default_driver = ADBAndroidDriver
50 |
51 | android_router = make_router(AndroidProvider(driver_class=android_default_driver))
52 | android_adb_router = make_router(AndroidProvider(driver_class=ADBAndroidDriver))
53 | ios_router = make_router(IOSProvider())
54 | harmony_router = make_router(HarmonyProvider())
55 | mock_router = make_router(MockProvider())
56 |
57 | app.include_router(mock_router, prefix="/api/mock", tags=["mock"])
58 |
59 | if Environment.UIAUTODEV_MOCK:
60 | app.include_router(mock_router, prefix="/api/android", tags=["mock"])
61 | app.include_router(mock_router, prefix="/api/ios", tags=["mock"])
62 | app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
63 | else:
64 | app.include_router(android_router, prefix="/api/android", tags=["android"])
65 | app.include_router(android_adb_router, prefix="/api/android_adb", tags=["android_adb"])
66 | app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
67 | app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
68 |
69 | app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
70 | app.include_router(android_device_router, prefix="/api/android", tags=["android"])
71 | app.include_router(proxy_router, tags=["proxy"])
72 |
73 |
74 | @app.get("/api/{platform}/features")
75 | def get_features(platform: str) -> Dict[str, bool]:
76 | """Get features supported by the specified platform"""
77 | features = {}
78 | # 获取所有带有指定平台tag的路由
79 | from starlette.routing import Route
80 |
81 | for route in app.routes:
82 | _route: Route = route # type: ignore
83 | if hasattr(_route, "tags") and platform in _route.tags:
84 | if _route.path.startswith(f"/api/{platform}/{{serial}}/"):
85 | # 提取特性名称
86 | parts = _route.path.split("/")
87 | feature_name = parts[-1]
88 | if not feature_name.startswith("{"):
89 | features[feature_name] = True
90 | return features
91 |
92 |
93 | class InfoResponse(BaseModel):
94 | version: str
95 | description: str
96 | platform: str
97 | code_language: str
98 | cwd: str
99 | drivers: List[str]
100 |
101 |
102 | @app.get("/api/info")
103 | def info() -> InfoResponse:
104 | """Information about the application"""
105 | return InfoResponse(
106 | version=__version__,
107 | description="client for https://uiauto.dev",
108 | platform=platform.system(), # Linux | Darwin | Windows
109 | code_language="Python",
110 | cwd=os.getcwd(),
111 | drivers=["android", "ios", "harmony"],
112 | )
113 |
114 |
115 | @app.post("/api/ocr_image")
116 | async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
117 | """OCR an image"""
118 | image_data = await file.read()
119 | image = convert_bytes_to_image(image_data)
120 | return ocr_image(image)
121 |
122 |
123 | @app.get("/shutdown")
124 | def shutdown() -> str:
125 | """Shutdown the server"""
126 | os.kill(os.getpid(), signal.SIGINT)
127 | return "Server shutting down..."
128 |
129 |
130 | @app.get("/demo")
131 | def demo():
132 | """Demo endpoint"""
133 | static_dir = Path(__file__).parent / "static"
134 | print(static_dir / "demo.html")
135 | return FileResponse(static_dir / "demo.html")
136 |
137 |
138 | @app.get("/redirect")
139 | def index_redirect():
140 | """redirect to official homepage"""
141 | url = get_webpage_url()
142 | logger.debug("redirect to %s", url)
143 | return RedirectResponse(url)
144 |
145 |
146 | @app.get("/api/auth/me")
147 | def mock_auth_me():
148 | # 401 {"detail":"Authentication required"}
149 | return JSONResponse(status_code=401, content={"detail": "Authentication required"})
150 |
151 | @app.websocket('/ws/android/scrcpy3/{serial}')
152 | async def handle_android_scrcpy3_ws(websocket: WebSocket, serial: str):
153 | await websocket.accept()
154 | try:
155 | logger.info(f"WebSocket serial: {serial}")
156 | device = adbutils.device(serial)
157 | from uiautodev.remote.scrcpy3 import ScrcpyServer3
158 | scrcpy = ScrcpyServer3(device)
159 | try:
160 | await scrcpy.stream_to_websocket(websocket)
161 | finally:
162 | scrcpy.close()
163 | except WebSocketDisconnect:
164 | logger.info(f"WebSocket disconnected by client.")
165 | except Exception as e:
166 | logger.exception(f"WebSocket error for serial={serial}: {e}")
167 | reason = str(e).replace("\n", " ")
168 | await websocket.close(code=1000, reason=reason)
169 | finally:
170 | logger.info(f"WebSocket closed for serial={serial}")
171 |
172 | @app.websocket("/ws/android/scrcpy/{serial}")
173 | async def handle_android_ws(websocket: WebSocket, serial: str):
174 | """
175 | Args:
176 | serial: device serial
177 | websocket: WebSocket
178 | """
179 | scrcpy_version = websocket.query_params.get("version", "2.7")
180 | await websocket.accept()
181 |
182 | try:
183 | logger.info(f"WebSocket serial: {serial}")
184 | device = adbutils.device(serial)
185 | server = ScrcpyServer(device, version=scrcpy_version)
186 | await server.handle_unified_websocket(websocket, serial)
187 | except WebSocketDisconnect:
188 | logger.info(f"WebSocket disconnected by client.")
189 | except Exception as e:
190 | logger.exception(f"WebSocket error for serial={serial}: {e}")
191 | await websocket.close(code=1000, reason=str(e))
192 | finally:
193 | logger.info(f"WebSocket closed for serial={serial}")
194 |
195 |
196 | def get_harmony_mjpeg_server(serial: str):
197 | from hypium import UiDriver
198 |
199 | from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
200 |
201 | driver = UiDriver.connect(device_sn=serial)
202 | logger.info("create harmony mjpeg server for %s", serial)
203 | logger.info(f"device wake_up_display: {driver.wake_up_display()}")
204 | return HarmonyMjpegServer(driver)
205 |
206 |
207 | @app.websocket("/ws/harmony/mjpeg/{serial}")
208 | async def unified_harmony_ws(websocket: WebSocket, serial: str):
209 | """
210 | Args:
211 | serial: device serial
212 | websocket: WebSocket
213 | """
214 | await websocket.accept()
215 |
216 | try:
217 | logger.info(f"WebSocket serial: {serial}")
218 |
219 | # 获取 HarmonyScrcpyServer 实例
220 | server = get_harmony_mjpeg_server(serial)
221 | server.start()
222 | await server.handle_ws(websocket)
223 | except ImportError as e:
224 | logger.error(f"missing library for harmony: {e}")
225 | await websocket.close(
226 | code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"'
227 | )
228 | except WebSocketDisconnect:
229 | logger.info(f"WebSocket disconnected by client.")
230 | except Exception as e:
231 | logger.exception(f"WebSocket error for serial={serial}: {e}")
232 | await websocket.close(code=1000, reason=str(e))
233 | finally:
234 | logger.info(f"WebSocket closed for serial={serial}")
235 |
236 |
237 | if __name__ == "__main__":
238 | uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
239 |
--------------------------------------------------------------------------------
/uiautodev/remote/harmony_mjpeg.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import logging
4 | import socket
5 | from datetime import datetime
6 | from threading import Thread
7 |
8 | from fastapi import WebSocket
9 | from hypium import KeyCode
10 |
11 | from uiautodev.exceptions import HarmonyDriverException
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class HarmonyMjpegServer:
17 | """
18 | HarmonyMjpegServer is responsible for handling screen streaming functionality
19 | for HarmonyOS devices that support ABC proxy (a communication interface).
20 |
21 | It manages WebSocket clients, communicates with the ABC server over gRPC, and streams
22 | the device's screen data in real-time to connected clients.
23 |
24 | This server is specifically designed for devices running in 'abc mode' and requires that
25 | the target device expose an `abc_proxy` attribute for communication.
26 |
27 | Attributes:
28 | device: The HarmonyOS device object.
29 | driver: The controlling driver which may wrap the device.
30 | abc_rpc_addr: Tuple containing the IP and port used to communicate with abc_proxy.
31 | channel: The gRPC communication channel (initialized later).
32 | clients: A set of connected WebSocket clients.
33 | loop: Asyncio event loop used to run asynchronous tasks.
34 | is_running: Boolean flag indicating if the streaming service is active.
35 |
36 | Raises:
37 | RuntimeError: If the connected device does not support abc_proxy.
38 |
39 | References:
40 | - Huawei HarmonyOS Python Guidelines:
41 | https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hypium-python-guidelines
42 | """
43 |
44 | def __init__(self, driver):
45 | if hasattr(driver, "_device"):
46 | device = driver._device
47 | else:
48 | device = driver
49 | logger.info(f'device: {device}')
50 | if not hasattr(device, "abc_proxy") or device.abc_proxy is None:
51 | raise HarmonyDriverException("Only abc mode can support screen recorder")
52 | self.device = device
53 | self.driver = driver
54 | self.abc_rpc_addr = ("127.0.0.1", device.abc_proxy.port)
55 | self.channel = None
56 | self.clients = set()
57 | self.loop = asyncio.get_event_loop()
58 | self.is_running = False
59 |
60 | def connect(self):
61 | self.channel = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
62 | self.channel.connect(self.abc_rpc_addr)
63 |
64 | def start(self, timeout=3600):
65 | if self.channel is None:
66 | self.connect()
67 | self.is_running = True
68 | self.timeout = timeout
69 | self.stop_capture_if_running()
70 | msg_json = {'api': "startCaptureScreen", 'args': []}
71 | full_msg = {
72 | "module": "com.ohos.devicetest.hypiumApiHelper",
73 | "method": "Captures",
74 | "params": msg_json,
75 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
76 | }
77 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
78 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
79 | reply = self.channel.recv(1024)
80 | logger.info(f'reply: {reply}')
81 | if b"true" in reply:
82 | thread_record = Thread(target=self._record_worker)
83 | thread_record.start()
84 | else:
85 | raise RuntimeError("Fail to start screen capture")
86 |
87 | def stop_capture_if_running(self):
88 | msg_json = {'api': "stopCaptureScreen", 'args': []}
89 | full_msg = {
90 | "module": "com.ohos.devicetest.hypiumApiHelper",
91 | "method": "Captures",
92 | "params": msg_json,
93 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
94 | }
95 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
96 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
97 | reply = self.channel.recv(1024)
98 | logger.info(f'stop reply: {reply}')
99 |
100 | async def handle_ws(self, websocket: WebSocket):
101 | self.clients.add(websocket)
102 | serial = getattr(self.device, "device_sn", "unknown")
103 | logger.info(f"[{serial}] WebSocket connected")
104 |
105 | try:
106 | while True:
107 | message = await websocket.receive_text()
108 | logger.info(f"Received message: {message}")
109 | try:
110 | data = json.loads(message)
111 | if data.get('type') == 'touch':
112 | action = data.get('action')
113 | x, y = data.get('x'), data.get('y')
114 | if action == 'normal':
115 | self.driver.touch((x, y))
116 | elif action == 'long':
117 | self.driver.touch(target=(x, y), mode='long')
118 | elif action == 'double':
119 | self.driver.touch(target=(x, y), mode='double')
120 | elif action == 'move':
121 | self.driver.slide(
122 | start=(data.get('x1'), data.get('y1')),
123 | end=(data.get('x2'), data.get('y2')),
124 | slide_time=0.1
125 | )
126 | elif data.get('type') == 'keyEvent':
127 | event_number = data['eventNumber']
128 | if event_number == 187:
129 | self.driver.swipe_to_recent_task()
130 | elif event_number == 3:
131 | self.driver.go_home()
132 | elif event_number == 4:
133 | self.driver.go_back()
134 | elif event_number == 224:
135 | self.driver.wake_up_display()
136 | elif data.get('type') == 'text':
137 | detail = data.get('detail')
138 | if detail == 'CODE_AC_BACK':
139 | self.driver.press_key(KeyCode.DEL)
140 | elif detail == 'CODE_AC_ENTER':
141 | self.driver.press_key(KeyCode.ENTER)
142 | else:
143 | self.driver.shell(
144 | f"uitest uiInput inputText {data.get('x')} {data.get('y')} {detail}")
145 | except Exception as e:
146 | logger.warning(f"Failed to handle message: {e}")
147 | except Exception as e:
148 | logger.info(f"WebSocket closed: {e}")
149 | finally:
150 | self.clients.discard(websocket)
151 |
152 | def _record_worker(self):
153 | tmp_data = b''
154 | start_flag = b'\xff\xd8'
155 | end_flag = b'\xff\xd9'
156 | while self.is_running:
157 | try:
158 | result = self.channel.recv(4096 * 1024)
159 | tmp_data += result
160 | while start_flag in tmp_data and end_flag in tmp_data:
161 | start_index = tmp_data.index(start_flag)
162 | end_index = tmp_data.index(end_flag) + 2
163 | frame = tmp_data[start_index:end_index]
164 | tmp_data = tmp_data[end_index:]
165 | asyncio.run_coroutine_threadsafe(self._broadcast(frame), self.loop)
166 | except Exception as e:
167 | logger.warning(f"Record worker error: {e}")
168 | self.is_running = False
169 | self.channel = None
170 | break
171 |
172 | async def _broadcast(self, data):
173 | for client in self.clients.copy():
174 | try:
175 | await client.send_bytes(data)
176 | except Exception as e:
177 | logger.info(f"Send error, removing client: {e}")
178 | self.clients.discard(client)
179 |
180 | def stop(self):
181 | self.is_running = False
182 | if self.channel is None:
183 | return
184 | msg_json = {'api': "stopCaptureScreen", 'args': []}
185 | full_msg = {
186 | "module": "com.ohos.devicetest.hypiumApiHelper",
187 | "method": "Captures",
188 | "params": msg_json,
189 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
190 | }
191 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
192 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
193 | reply = self.channel.recv(1024)
194 | if b"true" not in reply:
195 | logger.info("Fail to stop capture")
196 | self.channel.close()
197 | self.channel = None
198 | for client in self.clients:
199 | asyncio.run_coroutine_threadsafe(client.close(), self.loop)
200 |
--------------------------------------------------------------------------------
/uiautodev/driver/udt/udt.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 |
4 | """Created on Sun Apr 21 2024 21:15:15 by codeskyblue
5 | """
6 |
7 |
8 | import atexit
9 | import enum
10 | import io
11 | import json
12 | import logging
13 | import threading
14 | import time
15 | from base64 import b64decode
16 | from pathlib import Path
17 | from pprint import pprint
18 | from typing import Any, Optional
19 |
20 | import adbutils
21 | import requests
22 | from PIL import Image
23 | from pydantic import BaseModel
24 |
25 | """
26 | shell steps:
27 | adb push appium-uiautomator2-v5.12.4.apk /data/local/tmp/udt.jar
28 | adb shell CLASSPATH=/data/local/tmp/udt.jar app_process / "com.wetest.uia2.Main"
29 | adb forward tcp:6790 tcp:6790
30 | # 创建session
31 | echo '{"capabilities": {}}' | http POST :6790/session
32 | # 获取当前所有session
33 | http GET :6790/sessions
34 | # 获取pageSource
35 | http GET :6790/session/{session_id}/source
36 |
37 | # TODO
38 | # /appium/settins 中waitForIdleTimeout需要调整,其他的再看看
39 | """
40 |
41 | logger = logging.getLogger(__name__)
42 |
43 | class UDTError(Exception):
44 | pass
45 |
46 |
47 | class HTTPError(UDTError):
48 | pass
49 |
50 |
51 | class AppiumErrorEnum(str, enum.Enum):
52 | InvalidSessionID = 'invalid session id'
53 |
54 |
55 | class AppiumError(UDTError):
56 | def __init__(self, error: str, message):
57 | self.error = error
58 | self.message = message
59 |
60 |
61 | class AppiumResponseValue(BaseModel):
62 | error: Optional[str] = None
63 | message: Optional[str] = None
64 | stacktrace: Optional[str] = None
65 |
66 |
67 | class AppiumResponse(BaseModel):
68 | sessionId: Optional[str] = None
69 | value: Any = None
70 |
71 |
72 | class MockAdbProcess:
73 | def __init__(self, conn: adbutils.AdbConnection) -> None:
74 | self._conn = conn
75 | self._event = threading.Event()
76 |
77 | def wait_finished():
78 | try:
79 | self._conn.read_until_close()
80 | except:
81 | pass
82 | self._event.set()
83 |
84 | t = threading.Thread(target=wait_finished)
85 | t.daemon = True
86 | t.name = "wait_adb_conn"
87 | t.start()
88 |
89 | def wait(self) -> int:
90 | self._event.wait()
91 | return 0
92 |
93 | def pool(self) -> Optional[int]:
94 | if self._event.is_set():
95 | return 0
96 | return None
97 |
98 | def kill(self):
99 | self._conn.close()
100 |
101 |
102 | class UDT:
103 | def __init__(self, device: adbutils.AdbDevice):
104 | self._device = device
105 | self._lport = None
106 | self._process = None
107 | self._lock = threading.Lock()
108 | self._session_id = None
109 | atexit.register(self.release)
110 |
111 | def get_session_id(self) -> str:
112 | if self._session_id:
113 | return self._session_id
114 | self._session_id = self._new_session()
115 | logger.debug("update waitForIdleTimeout to 0ms")
116 | self._dev_request("POST", f"/session/{self._session_id}/appium/settings", payload={
117 | "settings": {
118 | "waitForIdleTimeout": 10,
119 | "waitForSelectorTimeout": 10,
120 | "actionAcknowledgmentTimeout": 10,
121 | "scrollAcknowledgmentTimeout": 10,
122 | "trackScrollEvents": False,
123 | }
124 | })
125 | result = self._dev_request("GET", f"/session/{self._session_id}/appium/settings")
126 | return self._session_id
127 |
128 | def dev_request(self, method: str, path: str, **kwargs) -> AppiumResponse:
129 | """send http request to device
130 | :param method: GET, POST, DELETE, PUT
131 | :param path: url path, path start with @ means with_session=True
132 |
133 | :return: response json
134 | """
135 | try:
136 | if path.startswith("@"):
137 | path = path[1:]
138 | kwargs['with_session'] = True
139 | return self._dev_request(method, path, **kwargs)
140 | except HTTPError:
141 | self.launch_server()
142 | return self._dev_request(method, path, **kwargs)
143 | except AppiumError as e:
144 | if e.error == AppiumErrorEnum.InvalidSessionID:
145 | self._session_id = self._new_session()
146 | return self._dev_request(method, path, **kwargs)
147 | raise
148 |
149 | def _dev_request(self, method: str, path: str, payload=None, timeout: float = 10.0, with_session: bool = False) -> AppiumResponse:
150 | try:
151 | if with_session:
152 | sid = self.get_session_id()
153 | path = f"/session/{sid}{path}"
154 | url = f"http://localhost:{self._lport}{path}"
155 | logger.debug("request %s %s", method, url)
156 | r = requests.request(method, url, json=payload, timeout=timeout)
157 | response_json = r.json()
158 | resp = AppiumResponse.model_validate(response_json)
159 | if isinstance(resp.value, dict):
160 | value = AppiumResponseValue.model_validate(resp.value)
161 | if value.error:
162 | raise AppiumError(value.error, value.message)
163 | return resp
164 | except requests.RequestException as e:
165 | raise HTTPError(f"{method} to {path!r} error", payload)
166 | except json.JSONDecodeError as e:
167 | raise HTTPError("JSON decode error", e.msg)
168 |
169 | def _new_session(self) -> str:
170 | resp = self._dev_request("POST", "/session", payload={"capabilities": {}})
171 | value = resp.value
172 | if not isinstance(value, dict) and 'sessionId' not in value:
173 | raise UDTError("session create failed", resp)
174 | sid = value['sessionId']
175 | if not sid:
176 | raise UDTError("session create failed", resp)
177 | return sid
178 |
179 | def post(self, path: str, payload=None) -> AppiumResponse:
180 | return self.dev_request("POST", path, payload=payload)
181 |
182 | def get(self, path: str, ) -> AppiumResponse:
183 | return self.dev_request("GET", path)
184 |
185 | def _update_process_status(self):
186 | if self._process:
187 | if self._process.pool() is not None:
188 | self._process = None
189 |
190 | def release(self):
191 | logger.debug("Releasing")
192 | with self._lock:
193 | if self._process is not None:
194 | logger.debug("Killing process")
195 | self._process.kill()
196 | self._process.wait()
197 | self._process = None
198 |
199 | def launch_server(self):
200 | try:
201 | self._launch_server()
202 | self._device.keyevent("WAKEUP")
203 | except adbutils.AdbError as e:
204 | raise UDTError("fail to start udt", str(e))
205 | self._wait_ready()
206 |
207 | def _launch_server(self):
208 | with self._lock:
209 | self._update_process_status()
210 | if self._process:
211 | logger.debug("Process already running")
212 | return
213 | logger.debug("Launching process")
214 | dex_local_path = Path(__file__).parent.joinpath("appium-uiautomator2-v5.12.4-light.apk")
215 | logger.debug("dex_local_path: %s", dex_local_path)
216 | dex_remote_path = "/data/local/tmp/udt/udt-5.12.4-light.dex"
217 | info = self._device.sync.stat(dex_remote_path)
218 | if info.size == dex_local_path.stat().st_size:
219 | logger.debug("%s already exists", dex_remote_path)
220 | else:
221 | logger.debug("push dex(%d) to %s", dex_local_path.stat().st_size, dex_remote_path)
222 | self._device.shell("mkdir -p /data/local/tmp/udt")
223 | self._device.sync.push(dex_local_path, dex_remote_path, 0o644)
224 | logger.debug("CLASSPATH=%s app_process / com.wetest.uia2.Main", dex_remote_path)
225 | conn = self._device.shell(f"CLASSPATH={dex_remote_path} app_process / com.wetest.uia2.Main", stream=True)
226 | self._process = MockAdbProcess(conn)
227 |
228 | self._lport = self._device.forward_port(6790)
229 | logger.debug("forward tcp:6790 -> tcp:%d", self._lport)
230 |
231 | def _wait_ready(self):
232 | deadline = time.time() + 10
233 | while time.time() < deadline:
234 | try:
235 | self._dev_request("GET", "/status", timeout=1)
236 | return
237 | except HTTPError:
238 | time.sleep(0.5)
239 | raise UDTError("Service not ready")
240 |
241 | def dump_hierarchy(self) -> str:
242 | resp = self.get(f"@/source")
243 | return resp.value
244 |
245 | def status(self):
246 | return self.get("/status")
247 |
248 | def screenshot(self) -> Image.Image:
249 | resp = self.get(f"@/screenshot")
250 | raw = b64decode(resp.value)
251 | return Image.open(io.BytesIO(raw))
252 |
253 |
254 |
255 | if __name__ == '__main__':
256 | logging.basicConfig(level=logging.DEBUG)
257 | r = UDT(adbutils.device())
258 | print(r.status())
259 | r.dump_hierarchy()
260 |
--------------------------------------------------------------------------------
/uiautodev/remote/keycode.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 |
3 |
4 | class KeyCode(IntEnum):
5 | """Android key codes ported from Android's KeyEvent class
6 |
7 | This enum contains all the key codes defined in Android's KeyEvent class,
8 | which are used for sending key events to Android devices through scrcpy.
9 |
10 | The comments for each key code are taken directly from the Android source code
11 | to maintain compatibility and provide accurate descriptions.
12 | """
13 | # Unknown key code
14 | UNKNOWN = 0
15 | # Soft Left key - Usually situated below the display on phones
16 | SOFT_LEFT = 1
17 | # Soft Right key - Usually situated below the display on phones
18 | SOFT_RIGHT = 2
19 | # Home key - This key is handled by the framework and is never delivered to applications
20 | HOME = 3
21 | # Back key
22 | BACK = 4
23 | # Call key
24 | CALL = 5
25 | # End Call key
26 | ENDCALL = 6
27 | # '0' key
28 | KEY_0 = 7
29 | # '1' key
30 | KEY_1 = 8
31 | # '2' key
32 | KEY_2 = 9
33 | # '3' key
34 | KEY_3 = 10
35 | # '4' key
36 | KEY_4 = 11
37 | # '5' key
38 | KEY_5 = 12
39 | # '6' key
40 | KEY_6 = 13
41 | # '7' key
42 | KEY_7 = 14
43 | # '8' key
44 | KEY_8 = 15
45 | # '9' key
46 | KEY_9 = 16
47 | # '*' key
48 | STAR = 17
49 | # '#' key
50 | POUND = 18
51 | # Directional Pad Up key - May also be synthesized from trackball motions
52 | DPAD_UP = 19
53 | # Directional Pad Down key - May also be synthesized from trackball motions
54 | DPAD_DOWN = 20
55 | # Directional Pad Left key - May also be synthesized from trackball motions
56 | DPAD_LEFT = 21
57 | # Directional Pad Right key - May also be synthesized from trackball motions
58 | DPAD_RIGHT = 22
59 | # Directional Pad Center key - May also be synthesized from trackball motions
60 | DPAD_CENTER = 23
61 | # Volume Up key - Adjusts the speaker volume up
62 | VOLUME_UP = 24
63 | # Volume Down key - Adjusts the speaker volume down
64 | VOLUME_DOWN = 25
65 | # Power key
66 | POWER = 26
67 | # Camera key - Used to launch a camera application or take pictures
68 | CAMERA = 27
69 | # Clear key
70 | CLEAR = 28
71 | A = 29
72 | B = 30
73 | C = 31
74 | D = 32
75 | E = 33
76 | F = 34
77 | G = 35
78 | H = 36
79 | I = 37
80 | J = 38
81 | K = 39
82 | L = 40
83 | M = 41
84 | N = 42
85 | O = 43
86 | P = 44
87 | Q = 45
88 | R = 46
89 | S = 47
90 | T = 48
91 | U = 49
92 | V = 50
93 | W = 51
94 | X = 52
95 | Y = 53
96 | Z = 54
97 | COMMA = 55
98 | PERIOD = 56
99 | ALT_LEFT = 57
100 | ALT_RIGHT = 58
101 | SHIFT_LEFT = 59
102 | SHIFT_RIGHT = 60
103 | TAB = 61
104 | SPACE = 62
105 | SYM = 63
106 | EXPLORER = 64
107 | ENVELOPE = 65
108 | # Enter key
109 | ENTER = 66
110 | # Backspace key - Deletes characters before the insertion point
111 | DEL = 67
112 | GRAVE = 68
113 | MINUS = 69
114 | EQUALS = 70
115 | LEFT_BRACKET = 71
116 | RIGHT_BRACKET = 72
117 | BACKSLASH = 73
118 | SEMICOLON = 74
119 | APOSTROPHE = 75
120 | SLASH = 76
121 | AT = 77
122 | NUM = 78
123 | HEADSETHOOK = 79
124 | FOCUS = 80
125 | PLUS = 81
126 | # Menu key
127 | MENU = 82
128 | NOTIFICATION = 83
129 | SEARCH = 84
130 | MEDIA_PLAY_PAUSE = 85
131 | MEDIA_STOP = 86
132 | MEDIA_NEXT = 87
133 | MEDIA_PREVIOUS = 88
134 | MEDIA_REWIND = 89
135 | MEDIA_FAST_FORWARD = 90
136 | MUTE = 91
137 | PAGE_UP = 92
138 | PAGE_DOWN = 93
139 | PICTSYMBOLS = 94
140 | SWITCH_CHARSET = 95
141 | BUTTON_A = 96
142 | BUTTON_B = 97
143 | BUTTON_C = 98
144 | BUTTON_X = 99
145 | BUTTON_Y = 100
146 | BUTTON_Z = 101
147 | BUTTON_L1 = 102
148 | BUTTON_R1 = 103
149 | BUTTON_L2 = 104
150 | BUTTON_R2 = 105
151 | BUTTON_THUMBL = 106
152 | BUTTON_THUMBR = 107
153 | BUTTON_START = 108
154 | BUTTON_SELECT = 109
155 | BUTTON_MODE = 110
156 | ESCAPE = 111
157 | FORWARD_DEL = 112
158 | CTRL_LEFT = 113
159 | CTRL_RIGHT = 114
160 | CAPS_LOCK = 115
161 | SCROLL_LOCK = 116
162 | META_LEFT = 117
163 | META_RIGHT = 118
164 | FUNCTION = 119
165 | SYSRQ = 120
166 | BREAK = 121
167 | MOVE_HOME = 122
168 | MOVE_END = 123
169 | INSERT = 124
170 | FORWARD = 125
171 | MEDIA_PLAY = 126
172 | MEDIA_PAUSE = 127
173 | MEDIA_CLOSE = 128
174 | MEDIA_EJECT = 129
175 | MEDIA_RECORD = 130
176 | F1 = 131
177 | F2 = 132
178 | F3 = 133
179 | F4 = 134
180 | F5 = 135
181 | F6 = 136
182 | F7 = 137
183 | F8 = 138
184 | F9 = 139
185 | F10 = 140
186 | F11 = 141
187 | F12 = 142
188 | NUM_LOCK = 143
189 | NUMPAD_0 = 144
190 | NUMPAD_1 = 145
191 | NUMPAD_2 = 146
192 | NUMPAD_3 = 147
193 | NUMPAD_4 = 148
194 | NUMPAD_5 = 149
195 | NUMPAD_6 = 150
196 | NUMPAD_7 = 151
197 | NUMPAD_8 = 152
198 | NUMPAD_9 = 153
199 | NUMPAD_DIVIDE = 154
200 | NUMPAD_MULTIPLY = 155
201 | NUMPAD_SUBTRACT = 156
202 | NUMPAD_ADD = 157
203 | NUMPAD_DOT = 158
204 | NUMPAD_COMMA = 159
205 | NUMPAD_ENTER = 160
206 | NUMPAD_EQUALS = 161
207 | NUMPAD_LEFT_PAREN = 162
208 | NUMPAD_RIGHT_PAREN = 163
209 | VOLUME_MUTE = 164
210 | INFO = 165
211 | CHANNEL_UP = 166
212 | CHANNEL_DOWN = 167
213 | ZOOM_IN = 168
214 | ZOOM_OUT = 169
215 | TV = 170
216 | WINDOW = 171
217 | GUIDE = 172
218 | DVR = 173
219 | BOOKMARK = 174
220 | CAPTIONS = 175
221 | SETTINGS = 176
222 | TV_POWER = 177
223 | TV_INPUT = 178
224 | STB_POWER = 179
225 | STB_INPUT = 180
226 | AVR_POWER = 181
227 | AVR_INPUT = 182
228 | PROG_RED = 183
229 | PROG_GREEN = 184
230 | PROG_YELLOW = 185
231 | PROG_BLUE = 186
232 | APP_SWITCH = 187
233 | BUTTON_1 = 188
234 | BUTTON_2 = 189
235 | BUTTON_3 = 190
236 | BUTTON_4 = 191
237 | BUTTON_5 = 192
238 | BUTTON_6 = 193
239 | BUTTON_7 = 194
240 | BUTTON_8 = 195
241 | BUTTON_9 = 196
242 | BUTTON_10 = 197
243 | BUTTON_11 = 198
244 | BUTTON_12 = 199
245 | BUTTON_13 = 200
246 | BUTTON_14 = 201
247 | BUTTON_15 = 202
248 | BUTTON_16 = 203
249 | LANGUAGE_SWITCH = 204
250 | MANNER_MODE = 205
251 | MODE_3D = 206
252 | CONTACTS = 207
253 | CALENDAR = 208
254 | MUSIC = 209
255 | CALCULATOR = 210
256 | ZENKAKU_HANKAKU = 211
257 | EISU = 212
258 | MUHENKAN = 213
259 | HENKAN = 214
260 | KATAKANA_HIRAGANA = 215
261 | YEN = 216
262 | RO = 217
263 | KANA = 218
264 | ASSIST = 219
265 | BRIGHTNESS_DOWN = 220
266 | BRIGHTNESS_UP = 221
267 | MEDIA_AUDIO_TRACK = 222
268 | SLEEP = 223
269 | WAKEUP = 224
270 | PAIRING = 225
271 | MEDIA_TOP_MENU = 226
272 | KEY_11 = 227
273 | KEY_12 = 228
274 | LAST_CHANNEL = 229
275 | TV_DATA_SERVICE = 230
276 | VOICE_ASSIST = 231
277 | TV_RADIO_SERVICE = 232
278 | TV_TELETEXT = 233
279 | TV_NUMBER_ENTRY = 234
280 | TV_TERRESTRIAL_ANALOG = 235
281 | TV_TERRESTRIAL_DIGITAL = 236
282 | TV_SATELLITE = 237
283 | TV_SATELLITE_BS = 238
284 | TV_SATELLITE_CS = 239
285 | TV_SATELLITE_SERVICE = 240
286 | TV_NETWORK = 241
287 | TV_ANTENNA_CABLE = 242
288 | TV_INPUT_HDMI_1 = 243
289 | TV_INPUT_HDMI_2 = 244
290 | TV_INPUT_HDMI_3 = 245
291 | TV_INPUT_HDMI_4 = 246
292 | TV_INPUT_COMPOSITE_1 = 247
293 | TV_INPUT_COMPOSITE_2 = 248
294 | TV_INPUT_COMPONENT_1 = 249
295 | TV_INPUT_COMPONENT_2 = 250
296 | TV_INPUT_VGA_1 = 251
297 | TV_AUDIO_DESCRIPTION = 252
298 | TV_AUDIO_DESCRIPTION_MIX_UP = 253
299 | TV_AUDIO_DESCRIPTION_MIX_DOWN = 254
300 | TV_ZOOM_MODE = 255
301 | TV_CONTENTS_MENU = 256
302 | TV_MEDIA_CONTEXT_MENU = 257
303 | TV_TIMER_PROGRAMMING = 258
304 | HELP = 259
305 | NAVIGATE_PREVIOUS = 260
306 | NAVIGATE_NEXT = 261
307 | NAVIGATE_IN = 262
308 | NAVIGATE_OUT = 263
309 | STEM_PRIMARY = 264
310 | STEM_1 = 265
311 | STEM_2 = 266
312 | STEM_3 = 267
313 | DPAD_UP_LEFT = 268
314 | DPAD_DOWN_LEFT = 269
315 | DPAD_UP_RIGHT = 270
316 | DPAD_DOWN_RIGHT = 271
317 | MEDIA_SKIP_FORWARD = 272
318 | MEDIA_SKIP_BACKWARD = 273
319 | MEDIA_STEP_FORWARD = 274
320 | MEDIA_STEP_BACKWARD = 275
321 | SOFT_SLEEP = 276
322 | CUT = 277
323 | COPY = 278
324 | PASTE = 279
325 | SYSTEM_NAVIGATION_UP = 280
326 | SYSTEM_NAVIGATION_DOWN = 281
327 | SYSTEM_NAVIGATION_LEFT = 282
328 | SYSTEM_NAVIGATION_RIGHT = 283
329 | ALL_APPS = 284
330 |
331 | # =========================================================================
332 | # Aliases for original Android KeyEvent names
333 | # =========================================================================
334 | # These aliases are provided to maintain compatibility with the original
335 | # Android KeyEvent naming convention (AKEYCODE_*). This makes it easier
336 | # to reference keys using the same names as in Android documentation.
337 |
338 | # Numeric key aliases
339 | KEYCODE_0 = KEY_0 # '0' key
340 | KEYCODE_1 = KEY_1 # '1' key
341 | KEYCODE_2 = KEY_2 # '2' key
342 | KEYCODE_3 = KEY_3 # '3' key
343 | KEYCODE_4 = KEY_4 # '4' key
344 | KEYCODE_5 = KEY_5 # '5' key
345 | KEYCODE_6 = KEY_6 # '6' key
346 | KEYCODE_7 = KEY_7 # '7' key
347 | KEYCODE_8 = KEY_8 # '8' key
348 | KEYCODE_9 = KEY_9 # '9' key
349 | KEYCODE_11 = KEY_11 # '11' key
350 | KEYCODE_12 = KEY_12 # '12' key
--------------------------------------------------------------------------------
/uiautodev/driver/harmony.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | import json
4 | import logging
5 | import os
6 | import re
7 | import shutil
8 | import subprocess
9 | import tempfile
10 | import time
11 | import uuid
12 | from pathlib import Path
13 | from typing import Dict, List, Optional, Tuple, Union, final
14 |
15 | from PIL import Image
16 |
17 | from uiautodev.command_types import CurrentAppResponse
18 | from uiautodev.driver.base_driver import BaseDriver
19 | from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
20 |
21 | logger = logging.getLogger(__name__)
22 |
23 | StrOrPath = Union[str, Path]
24 |
25 |
26 | def run_command(command: str, timeout: int = 60) -> str:
27 | try:
28 | result = subprocess.run(
29 | command,
30 | shell=True,
31 | capture_output=True,
32 | timeout=timeout,
33 | text=True,
34 | errors='ignore',
35 | input='' # this avoid stdout: "FreeChannelContinue handle->data is nullptr"
36 | )
37 | # the hdc shell stderr is (不仅没啥用,还没办法去掉)
38 | # Remote PTY will not be allocated because stdin is not a terminal.
39 | # Use multiple -t options to force remote PTY allocation.
40 | output = result.stdout.strip()
41 | return output
42 | except subprocess.TimeoutExpired as e:
43 | raise TimeoutError(f"{command:r} timeout {e}")
44 |
45 |
46 | class HDCError(Exception):
47 | pass
48 |
49 |
50 | class HDC:
51 | def __init__(self):
52 | self.hdc = 'hdc'
53 | self.tmpdir = tempfile.TemporaryDirectory()
54 |
55 | def __del__(self):
56 | self.tmpdir.cleanup()
57 |
58 | def list_device(self) -> List[str]:
59 | command = f"{self.hdc} list targets"
60 | result = run_command(command)
61 | if result and not "Empty" in result:
62 | devices = []
63 | for line in result.strip().split("\n"):
64 | serial = line.strip().split('\t', 1)[0]
65 | devices.append(serial)
66 | return devices
67 | else:
68 | return []
69 |
70 | def shell(self, serial: str, command: str) -> str:
71 | command = f"{self.hdc} -t {serial} shell \"{command}\""
72 | result = run_command(command)
73 | return result.strip()
74 |
75 | def __split_text(self, text: str) -> str:
76 | return text.split("\n")[0].strip() if text else ""
77 |
78 | def get_model(self, serial: str) -> str:
79 | return self.shell(serial, "param get const.product.model")
80 |
81 | def get_name(self, serial: str) -> str:
82 | data = self.shell(serial, "param get const.product.name")
83 | return self.__split_text(data)
84 |
85 | def wlan_ip(self, serial: str) -> str:
86 | data = self.shell(serial, "ifconfig")
87 | if not data or "not found" in data.lower() or "error" in data.lower():
88 | logger.warning(f"ifconfig command failed or returned error for serial {serial}: {data!r}")
89 | return ""
90 | # Try multiple patterns for IP address
91 | matches = re.findall(r'inet addr:(?!127)(\d+\.\d+\.\d+\.\d+)', data)
92 | if not matches:
93 | matches = re.findall(r'inet (?!127)(\d+\.\d+\.\d+\.\d+)', data)
94 | if matches:
95 | return matches[0]
96 | logger.warning(f"No valid IP address found in ifconfig output for serial {serial}: {data!r}")
97 | return ""
98 |
99 | def sdk_version(self, serial: str) -> str:
100 | data = self.shell(serial, "param get const.ohos.apiversion")
101 | return self.__split_text(data)
102 |
103 | def sys_version(self, serial: str) -> str:
104 | data = self.shell(serial, "param get const.product.software.version")
105 | return self.__split_text(data)
106 |
107 | def brand(self, serial: str) -> str:
108 | data = self.shell(serial, "param get const.product.brand")
109 | return self.__split_text(data)
110 |
111 | def pull(self, serial: str, remote: StrOrPath, local: StrOrPath):
112 | if isinstance(remote, Path):
113 | remote = remote.as_posix()
114 | command = f"{self.hdc} -t {serial} file recv {remote} {local}"
115 | output = run_command(command)
116 | if not os.path.exists(local):
117 | raise HDCError(f"device file: {remote} not found", output)
118 |
119 | def push(self, serial: str, local: StrOrPath, remote: StrOrPath) -> str:
120 | if isinstance(remote, Path):
121 | remote = remote.as_posix()
122 | command = f"{self.hdc} -t {serial} file send {local} {remote}"
123 | return run_command(command)
124 |
125 | def screenshot(self, serial: str) -> Image.Image:
126 | device_path = f'/data/local/tmp/screenshot-{int(time.time()*1000)}.png'
127 | self.shell(serial, f"uitest screenCap -p {device_path}")
128 | try:
129 | local_path = os.path.join(self.tmpdir.name, f"{uuid.uuid4()}.png")
130 | self.pull(serial, device_path, local_path)
131 | with Image.open(local_path) as image:
132 | image.load()
133 | return image
134 | finally:
135 | self.shell(serial, f"rm {device_path}")
136 |
137 | def dump_layout(self, serial: str) -> dict:
138 | name = "{}.json".format(int(time.time() * 1000))
139 | remote_path = f"/data/local/tmp/layout-{name}.json"
140 | temp_path = os.path.join(self.tmpdir.name, f"layout-{name}.json")
141 | output = self.shell(serial, f"uitest dumpLayout -p {remote_path}")
142 | self.pull(serial, remote_path, temp_path)
143 | # mock
144 | # temp_path = Path(__file__).parent / 'testdata/layout.json'
145 | try:
146 | with open(temp_path, "rb") as f:
147 | json_content = json.load(f)
148 | return json_content
149 | except json.JSONDecodeError:
150 | raise HDCError(f"failed to dump layout: {output}")
151 | finally:
152 | self.shell(serial, f"rm {remote_path}")
153 |
154 |
155 | class HarmonyDriver(BaseDriver):
156 | def __init__(self, hdc: HDC, serial: str):
157 | super().__init__(serial)
158 | self.hdc = hdc
159 |
160 | def screenshot(self, id: int = 0) -> Image.Image:
161 | return self.hdc.screenshot(self.serial)
162 |
163 | def window_size(self) -> WindowSize:
164 | result = self.hdc.shell(self.serial, "hidumper -s 10 -a screen")
165 | pattern = r"activeMode:\s*(\d+x\d+)"
166 | match = re.search(pattern, result)
167 | if match:
168 | resolution = match.group(1).split("x")
169 | return WindowSize(width=int(resolution[0]), height=int(resolution[1]))
170 | else:
171 | image = self.screenshot()
172 | return WindowSize(width=image.width, height=image.height)
173 |
174 | def dump_hierarchy(self) -> Tuple[str, Node]:
175 | """returns xml string and hierarchy object"""
176 | layout = self.hdc.dump_layout(self.serial)
177 | return json.dumps(layout), parse_json_element(layout)
178 |
179 | def tap(self, x: int, y: int):
180 | self.hdc.shell(self.serial, f"uinput -T -c {x} {y}")
181 |
182 | def app_current(self) -> Optional[CurrentAppResponse]:
183 | echo = self.hdc.shell(self.serial, "hidumper -s WindowManagerService -a '-a'")
184 | focus_window = re.search(r"Focus window: (\d+)", echo)
185 | if focus_window:
186 | focus_window = focus_window.group(1)
187 | mission_echo = self.hdc.shell(self.serial, "aa dump -a")
188 | pkg_names = re.findall(r"Mission ID #(\d+)\s+mission name #\[(.*?)\]", mission_echo)
189 | if focus_window and pkg_names:
190 | for mission in pkg_names:
191 | mission_id = mission[0]
192 | if focus_window == mission_id:
193 | mission_name = mission[1]
194 | pkg_name = mission_name.split(":")[0].replace("#", "")
195 | ability_name = mission_name.split(":")[-1]
196 | pid = self.hdc.shell(self.serial, f"pidof {pkg_name}").strip()
197 | return CurrentAppResponse(package=pkg_name, activity=ability_name, pid=int(pid))
198 | else:
199 | return None
200 |
201 | def get_app_info(self, package_name: str) -> Dict:
202 | """
203 | Get detailed information about a specific application.
204 |
205 | Args:
206 | package_name (str): The package name of the application to retrieve information for.
207 |
208 | Returns:
209 | Dict: A dictionary containing the application information. If an error occurs during parsing,
210 | an empty dictionary is returned.
211 | """
212 | app_info = {}
213 | data = self.hdc.shell(self.serial, f"bm dump -n {package_name}")
214 | output = data
215 | try:
216 | json_start = output.find("{")
217 | json_end = output.rfind("}") + 1
218 | json_output = output[json_start:json_end]
219 |
220 | app_info = json.loads(json_output)
221 | except Exception as e:
222 | logger.error(f"An error occurred: {e}")
223 | return app_info
224 |
225 | def get_app_abilities(self, package_name: str) -> List[Dict]:
226 | """
227 | Get the abilities of an application.
228 |
229 | Args:
230 | package_name (str): The package name of the application.
231 |
232 | Returns:
233 | List[Dict]: A list of dictionaries containing the abilities of the application.
234 | """
235 | result = []
236 | app_info = self.get_app_info(package_name)
237 | hap_module_infos = app_info.get("hapModuleInfos")
238 | main_entry = app_info.get("mainEntry")
239 | for hap_module_info in hap_module_infos:
240 | # 尝试读取moduleInfo
241 | try:
242 | ability_infos = hap_module_info.get("abilityInfos")
243 | module_main = hap_module_info["mainAbility"]
244 | except Exception as e:
245 | logger.warning(f"Fail to parse moduleInfo item, {repr(e)}")
246 | continue
247 | # 尝试读取abilityInfo
248 | for ability_info in ability_infos:
249 | try:
250 | is_launcher_ability = False
251 | skills = ability_info['skills']
252 | if len(skills) > 0 and "action.system.home" in skills[0]["actions"]:
253 | is_launcher_ability = True
254 | icon_ability_info = {
255 | "name": ability_info["name"],
256 | "moduleName": ability_info["moduleName"],
257 | "moduleMainAbility": module_main,
258 | "mainModule": main_entry,
259 | "isLauncherAbility": is_launcher_ability
260 | }
261 | result.append(icon_ability_info)
262 | except Exception as e:
263 | logger.warning(f"Fail to parse ability_info item, {repr(e)}")
264 | continue
265 | logger.debug(f"all abilities: {result}")
266 | return result
267 |
268 | def get_app_main_ability(self, package_name: str) -> Dict:
269 | """
270 | Get the main ability of an application.
271 |
272 | Args:
273 | package_name (str): The package name of the application to retrieve information for.
274 |
275 | Returns:
276 | Dict: A dictionary containing the main ability of the application.
277 |
278 | """
279 | if not (abilities := self.get_app_abilities(package_name)):
280 | return {}
281 | for item in abilities:
282 | score = 0
283 | if (name := item["name"]) and name == item["moduleMainAbility"]:
284 | score += 1
285 | if (module_name := item["moduleName"]) and module_name == item["mainModule"]:
286 | score += 1
287 | item["score"] = score
288 | abilities.sort(key=lambda x: (not x["isLauncherAbility"], -x["score"]))
289 | logger.debug(f"main ability: {abilities[0]}")
290 | return abilities[0]
291 |
292 | def app_launch(self, package: str, page_name: Optional[str] = None):
293 | """
294 | Start an application on the device.
295 | If the `page_name` is empty, it will retrieve the main ability using `get_app_main_ability`.
296 | Args:
297 | package (str): The package name of the application.
298 | page_name (Optional[str]): Ability Name within the application to start. If not provided, the main ability will be used.
299 | """
300 | if not page_name:
301 | page_name = self.get_app_main_ability(package).get('name', 'MainAbility')
302 | self.shell(f"aa start -a {page_name} -b {package}")
303 |
304 | def app_terminate(self, package: str):
305 | self.shell(f"aa force-stop {package}")
306 |
307 | def shell(self, command: str) -> ShellResponse:
308 | result = self.hdc.shell(self.serial, command)
309 | return ShellResponse(output=result)
310 |
311 | def home(self):
312 | self.hdc.shell(self.serial, "uinput -K -d 1 -u 1")
313 |
314 | def back(self):
315 | self.hdc.shell(self.serial, "uinput -K -d 2 -u 2")
316 |
317 | def volume_up(self):
318 | self.hdc.shell(self.serial, "uinput -K -d 16 -u 16")
319 |
320 | def volume_down(self):
321 | self.hdc.shell(self.serial, "uinput -K -d 17 -u 17")
322 |
323 | def volume_mute(self):
324 | self.hdc.shell(self.serial, "uinput -K -d 22 -u 22")
325 |
326 | def app_switch(self):
327 | self.hdc.shell(self.serial, "uinput -K -d 2076 -d 2049 -u 2076 -u 2049")
328 |
329 | def app_list(self) -> List[AppInfo]:
330 | results = []
331 | output = self.hdc.shell(self.serial, "bm dump -a")
332 | for i in output.split("\n"):
333 | if "ID" in i:
334 | continue
335 | else:
336 | results.append(AppInfo(packageName=i.strip()))
337 | return results
338 |
339 |
340 | def parse_json_element(element, indexes: List[int] = [0]) -> Node:
341 | """
342 | Recursively parse an json element into a dictionary format.
343 | """
344 | attributes = element.get("attributes", {})
345 | name = attributes.get("type", "")
346 | bounds = attributes.get("bounds", "")
347 | bounds = list(map(int, re.findall(r"\d+", bounds)))
348 | assert len(bounds) == 4
349 | rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
350 | elem = Node(
351 | key="-".join(map(str, indexes)),
352 | name=name,
353 | bounds=None,
354 | rect=rect,
355 | properties={key: attributes[key] for key in attributes},
356 | children=[],
357 | )
358 | # Construct xpath for children
359 | for index, child in enumerate(element.get("children", [])):
360 | child_node = parse_json_element(child, indexes + [index])
361 | if child_node:
362 | elem.children.append(child_node)
363 |
364 | return elem
365 |
--------------------------------------------------------------------------------
/uiautodev/utils/usbmux.py:
--------------------------------------------------------------------------------
1 | """
2 | Copy from https://github.com/doronz88/pymobiledevice3
3 |
4 | Add http.client.HTTPConnection
5 | """
6 | import abc
7 | import plistlib
8 | import socket
9 | import sys
10 | import time
11 | from dataclasses import dataclass
12 | from http.client import HTTPConnection
13 | from typing import List, Mapping, Optional
14 |
15 | from construct import Const, CString, Enum, FixedSized, GreedyBytes, Int16ul, Int32ul, Padding, Prefixed, StreamError, \
16 | Struct, Switch, this
17 |
18 | from uiautodev.utils.exceptions import BadCommandError, BadDevError, ConnectionFailedError, \
19 | ConnectionFailedToUsbmuxdError, MuxException, MuxVersionError, NotPairedError
20 |
21 | usbmuxd_version = Enum(Int32ul,
22 | BINARY=0,
23 | PLIST=1,
24 | )
25 |
26 | usbmuxd_result = Enum(Int32ul,
27 | OK=0,
28 | BADCOMMAND=1,
29 | BADDEV=2,
30 | CONNREFUSED=3,
31 | BADVERSION=6,
32 | )
33 |
34 | usbmuxd_msgtype = Enum(Int32ul,
35 | RESULT=1,
36 | CONNECT=2,
37 | LISTEN=3,
38 | ADD=4,
39 | REMOVE=5,
40 | PAIRED=6,
41 | PLIST=8,
42 | )
43 |
44 | usbmuxd_header = Struct(
45 | 'version' / usbmuxd_version, # protocol version
46 | 'message' / usbmuxd_msgtype, # message type
47 | 'tag' / Int32ul, # responses to this query will echo back this tag
48 | )
49 |
50 | usbmuxd_request = Prefixed(Int32ul, Struct(
51 | 'header' / usbmuxd_header,
52 | 'data' / Switch(this.header.message, {
53 | usbmuxd_msgtype.CONNECT: Struct(
54 | 'device_id' / Int32ul,
55 | 'port' / Int16ul, # TCP port number
56 | 'reserved' / Const(0, Int16ul),
57 | ),
58 | usbmuxd_msgtype.PLIST: GreedyBytes,
59 | }),
60 | ), includelength=True)
61 |
62 | usbmuxd_device_record = Struct(
63 | 'device_id' / Int32ul,
64 | 'product_id' / Int16ul,
65 | 'serial_number' / FixedSized(256, CString('ascii')),
66 | Padding(2),
67 | 'location' / Int32ul
68 | )
69 |
70 | usbmuxd_response = Prefixed(Int32ul, Struct(
71 | 'header' / usbmuxd_header,
72 | 'data' / Switch(this.header.message, {
73 | usbmuxd_msgtype.RESULT: Struct(
74 | 'result' / usbmuxd_result,
75 | ),
76 | usbmuxd_msgtype.ADD: usbmuxd_device_record,
77 | usbmuxd_msgtype.REMOVE: Struct(
78 | 'device_id' / Int32ul,
79 | ),
80 | usbmuxd_msgtype.PLIST: GreedyBytes,
81 | }),
82 | ), includelength=True)
83 |
84 |
85 |
86 |
87 | @dataclass
88 | class MuxDevice:
89 | devid: int
90 | serial: str
91 | connection_type: str
92 |
93 | def connect(self, port: int, usbmux_address: Optional[str] = None) -> socket.socket:
94 | mux = create_mux(usbmux_address=usbmux_address)
95 | try:
96 | return mux.connect(self, port)
97 | except: # noqa: E722
98 | mux.close()
99 | raise
100 |
101 | @property
102 | def is_usb(self) -> bool:
103 | return self.connection_type == 'USB'
104 |
105 | @property
106 | def is_network(self) -> bool:
107 | return self.connection_type == 'Network'
108 |
109 | def matches_udid(self, udid: str) -> bool:
110 | return self.serial.replace('-', '') == udid.replace('-', '')
111 |
112 | def make_http_connection(self, port: int) -> HTTPConnection:
113 | return USBMuxHTTPConnection(self, port)
114 |
115 |
116 | class SafeStreamSocket:
117 | """ wrapper to native python socket object to be used with construct as a stream """
118 |
119 | def __init__(self, address, family):
120 | self._offset = 0
121 | self.sock = socket.socket(family, socket.SOCK_STREAM)
122 | self.sock.connect(address)
123 |
124 | def send(self, msg: bytes) -> int:
125 | self._offset += len(msg)
126 | self.sock.sendall(msg)
127 | return len(msg)
128 |
129 | def recv(self, size: int) -> bytes:
130 | msg = b''
131 | while len(msg) < size:
132 | chunk = self.sock.recv(size - len(msg))
133 | self._offset += len(chunk)
134 | if not chunk:
135 | raise MuxException('socket connection broken')
136 | msg += chunk
137 | return msg
138 |
139 | def close(self) -> None:
140 | self.sock.close()
141 |
142 | def settimeout(self, interval: float) -> None:
143 | self.sock.settimeout(interval)
144 |
145 | def setblocking(self, blocking: bool) -> None:
146 | self.sock.setblocking(blocking)
147 |
148 | def tell(self) -> int:
149 | return self._offset
150 |
151 | read = recv
152 | write = send
153 |
154 |
155 | class MuxConnection:
156 | # used on Windows
157 | ITUNES_HOST = ('127.0.0.1', 27015)
158 |
159 | # used for macOS and Linux
160 | USBMUXD_PIPE = '/var/run/usbmuxd'
161 |
162 | @staticmethod
163 | def create_usbmux_socket(usbmux_address: Optional[str] = None) -> SafeStreamSocket:
164 | try:
165 | if usbmux_address is not None:
166 | if ':' in usbmux_address:
167 | # assume tcp address
168 | hostname, port = usbmux_address.split(':')
169 | port = int(port)
170 | address = (hostname, port)
171 | family = socket.AF_INET
172 | else:
173 | # assume unix domain address
174 | address = usbmux_address
175 | family = socket.AF_UNIX
176 | else:
177 | if sys.platform in ['win32', 'cygwin']:
178 | address = MuxConnection.ITUNES_HOST
179 | family = socket.AF_INET
180 | else:
181 | address = MuxConnection.USBMUXD_PIPE
182 | family = socket.AF_UNIX
183 | return SafeStreamSocket(address, family)
184 | except ConnectionRefusedError:
185 | raise ConnectionFailedToUsbmuxdError()
186 |
187 | @staticmethod
188 | def create(usbmux_address: Optional[str] = None):
189 | # first attempt to connect with possibly the wrong version header (plist protocol)
190 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address)
191 |
192 | message = usbmuxd_request.build({
193 | 'header': {'version': usbmuxd_version.PLIST, 'message': usbmuxd_msgtype.PLIST, 'tag': 1},
194 | 'data': plistlib.dumps({'MessageType': 'ReadBUID'})
195 | })
196 | sock.send(message)
197 | response = usbmuxd_response.parse_stream(sock)
198 |
199 | # if we sent a bad request, we should re-create the socket in the correct version this time
200 | sock.close()
201 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address)
202 |
203 | if response.header.version == usbmuxd_version.BINARY:
204 | return BinaryMuxConnection(sock)
205 | elif response.header.version == usbmuxd_version.PLIST:
206 | return PlistMuxConnection(sock)
207 |
208 | raise MuxVersionError(f'usbmuxd returned unsupported version: {response.version}')
209 |
210 | def __init__(self, sock: SafeStreamSocket):
211 | self._sock = sock
212 |
213 | # after initiating the "Connect" packet, this same socket will be used to transfer data into the service
214 | # residing inside the target device. when this happens, we can no longer send/receive control commands to
215 | # usbmux on same socket
216 | self._connected = False
217 |
218 | # message sequence number. used when verifying the response matched the request
219 | self._tag = 1
220 |
221 | self.devices = []
222 |
223 | @abc.abstractmethod
224 | def _connect(self, device_id: int, port: int):
225 | """ initiate a "Connect" request to target port """
226 | pass
227 |
228 | @abc.abstractmethod
229 | def get_device_list(self, timeout: float = None):
230 | """
231 | request an update to current device list
232 | """
233 | pass
234 |
235 | def connect(self, device: MuxDevice, port: int) -> socket.socket:
236 | """ connect to a relay port on target machine and get a raw python socket object for the connection """
237 | self._connect(device.devid, socket.htons(port))
238 | self._connected = True
239 | return self._sock.sock
240 |
241 | def close(self):
242 | """ close current socket """
243 | self._sock.close()
244 |
245 | def _assert_not_connected(self):
246 | """ verify active state is in state for control messages """
247 | if self._connected:
248 | raise MuxException('Mux is connected, cannot issue control packets')
249 |
250 | def _raise_mux_exception(self, result: int, message: str = None):
251 | exceptions = {
252 | int(usbmuxd_result.BADCOMMAND): BadCommandError,
253 | int(usbmuxd_result.BADDEV): BadDevError,
254 | int(usbmuxd_result.CONNREFUSED): ConnectionFailedError,
255 | int(usbmuxd_result.BADVERSION): MuxVersionError,
256 | }
257 | exception = exceptions.get(result, MuxException)
258 | raise exception(message)
259 |
260 | def __enter__(self):
261 | return self
262 |
263 | def __exit__(self, exc_type, exc_val, exc_tb):
264 | self.close()
265 |
266 |
267 | class BinaryMuxConnection(MuxConnection):
268 | """ old binary protocol """
269 |
270 | def __init__(self, sock: SafeStreamSocket):
271 | super().__init__(sock)
272 | self._version = usbmuxd_version.BINARY
273 |
274 | def get_device_list(self, timeout: float = None):
275 | """ use timeout to wait for the device list to be fully populated """
276 | self._assert_not_connected()
277 | end = time.time() + timeout
278 | self.listen()
279 | while time.time() < end:
280 | self._sock.settimeout(end - time.time())
281 | try:
282 | self._receive_device_state_update()
283 | except (BlockingIOError, StreamError):
284 | continue
285 | except IOError:
286 | try:
287 | self._sock.setblocking(True)
288 | self.close()
289 | except OSError:
290 | pass
291 | raise MuxException('Exception in listener socket')
292 |
293 | def listen(self):
294 | """ start listening for events of attached and detached devices """
295 | self._send_receive(usbmuxd_msgtype.LISTEN)
296 |
297 | def _connect(self, device_id: int, port: int):
298 | self._send({'header': {'version': self._version,
299 | 'message': usbmuxd_msgtype.CONNECT,
300 | 'tag': self._tag},
301 | 'data': {'device_id': device_id, 'port': port},
302 | })
303 | response = self._receive()
304 | if response.header.message != usbmuxd_msgtype.RESULT:
305 | raise MuxException(f'unexpected message type received: {response}')
306 |
307 | if response.data.result != usbmuxd_result.OK:
308 | raise self._raise_mux_exception(int(response.data.result),
309 | f'failed to connect to device: {device_id} at port: {port}. reason: '
310 | f'{response.data.result}')
311 |
312 | def _send(self, data: Mapping):
313 | self._assert_not_connected()
314 | self._sock.send(usbmuxd_request.build(data))
315 | self._tag += 1
316 |
317 | def _receive(self, expected_tag: int = None):
318 | self._assert_not_connected()
319 | response = usbmuxd_response.parse_stream(self._sock)
320 | if expected_tag and response.header.tag != expected_tag:
321 | raise MuxException(f'Reply tag mismatch: expected {expected_tag}, got {response.header.tag}')
322 | return response
323 |
324 | def _send_receive(self, message_type: int):
325 | self._send({'header': {'version': self._version, 'message': message_type, 'tag': self._tag},
326 | 'data': b''})
327 | response = self._receive(self._tag - 1)
328 | if response.header.message != usbmuxd_msgtype.RESULT:
329 | raise MuxException(f'unexpected message type received: {response}')
330 |
331 | result = response.data.result
332 | if result != usbmuxd_result.OK:
333 | raise self._raise_mux_exception(int(result), f'{message_type} failed: error {result}')
334 |
335 | def _add_device(self, device: MuxDevice):
336 | self.devices.append(device)
337 |
338 | def _remove_device(self, device_id: int):
339 | self.devices = [device for device in self.devices if device.devid != device_id]
340 |
341 | def _receive_device_state_update(self):
342 | response = self._receive()
343 | if response.header.message == usbmuxd_msgtype.ADD:
344 | # old protocol only supported USB devices
345 | self._add_device(MuxDevice(response.data.device_id, response.data.serial_number, 'USB'))
346 | elif response.header.message == usbmuxd_msgtype.REMOVE:
347 | self._remove_device(response.data.device_id)
348 | else:
349 | raise MuxException(f'Invalid packet type received: {response}')
350 |
351 |
352 | class PlistMuxConnection(BinaryMuxConnection):
353 | def __init__(self, sock: SafeStreamSocket):
354 | super().__init__(sock)
355 | self._version = usbmuxd_version.PLIST
356 |
357 | def listen(self) -> None:
358 | self._send_receive({'MessageType': 'Listen'})
359 |
360 | def get_pair_record(self, serial: str) -> Mapping:
361 | # serials are saved inside usbmuxd without '-'
362 | self._send({'MessageType': 'ReadPairRecord', 'PairRecordID': serial})
363 | response = self._receive(self._tag - 1)
364 | pair_record = response.get('PairRecordData')
365 | if pair_record is None:
366 | raise NotPairedError('device should be paired first')
367 | return plistlib.loads(pair_record)
368 |
369 | def get_device_list(self, timeout: float = None) -> None:
370 | """ get device list synchronously without waiting the timeout """
371 | self.devices = []
372 | self._send({'MessageType': 'ListDevices'})
373 | for response in self._receive(self._tag - 1)['DeviceList']:
374 | if response['MessageType'] == 'Attached':
375 | super()._add_device(MuxDevice(response['DeviceID'], response['Properties']['SerialNumber'],
376 | response['Properties']['ConnectionType']))
377 | elif response['MessageType'] == 'Detached':
378 | super()._remove_device(response['DeviceID'])
379 | else:
380 | raise MuxException(f'Invalid packet type received: {response}')
381 |
382 | def get_buid(self) -> str:
383 | """ get SystemBUID """
384 | self._send({'MessageType': 'ReadBUID'})
385 | return self._receive(self._tag - 1)['BUID']
386 |
387 | def save_pair_record(self, serial: str, device_id: int, record_data: bytes):
388 | # serials are saved inside usbmuxd without '-'
389 | self._send_receive({'MessageType': 'SavePairRecord',
390 | 'PairRecordID': serial,
391 | 'PairRecordData': record_data,
392 | 'DeviceID': device_id})
393 |
394 | def _connect(self, device_id: int, port: int):
395 | self._send_receive({'MessageType': 'Connect', 'DeviceID': device_id, 'PortNumber': port})
396 |
397 | def _send(self, data: Mapping):
398 | request = {'ClientVersionString': 'qt4i-usbmuxd', 'ProgName': 'pymobiledevice3', 'kLibUSBMuxVersion': 3}
399 | request.update(data)
400 | super()._send({'header': {'version': self._version,
401 | 'message': usbmuxd_msgtype.PLIST,
402 | 'tag': self._tag},
403 | 'data': plistlib.dumps(request),
404 | })
405 |
406 | def _receive(self, expected_tag: int = None) -> Mapping:
407 | response = super()._receive(expected_tag=expected_tag)
408 | if response.header.message != usbmuxd_msgtype.PLIST:
409 | raise MuxException(f'Received non-plist type {response}')
410 | return plistlib.loads(response.data)
411 |
412 | def _send_receive(self, data: Mapping):
413 | self._send(data)
414 | response = self._receive(self._tag - 1)
415 | if response['MessageType'] != 'Result':
416 | raise MuxException(f'got an invalid message: {response}')
417 | if response['Number'] != 0:
418 | raise self._raise_mux_exception(response['Number'], f'got an error message: {response}')
419 |
420 |
421 | def create_mux(usbmux_address: Optional[str] = None) -> MuxConnection:
422 | return MuxConnection.create(usbmux_address=usbmux_address)
423 |
424 |
425 | def list_devices(usbmux_address: Optional[str] = None) -> List[MuxDevice]:
426 | mux = create_mux(usbmux_address=usbmux_address)
427 | mux.get_device_list(0.1)
428 | devices = mux.devices
429 | mux.close()
430 | return devices
431 |
432 |
433 | def select_device(udid: str = None, connection_type: str = None, usbmux_address: Optional[str] = None) \
434 | -> Optional[MuxDevice]:
435 | """
436 | select a UsbMux device according to given arguments.
437 | if more than one device could be selected, always prefer the usb one.
438 | """
439 | tmp = None
440 | for device in list_devices(usbmux_address=usbmux_address):
441 | if connection_type is not None and device.connection_type != connection_type:
442 | # if a specific connection_type was desired and not of this one then skip
443 | continue
444 |
445 | if udid is not None and not device.matches_udid(udid):
446 | # if a specific udid was desired and not of this one then skip
447 | continue
448 |
449 | # save best result as a temporary
450 | tmp = device
451 |
452 | if device.is_usb:
453 | # always prefer usb connection
454 | return device
455 |
456 | return tmp
457 |
458 |
459 | def select_devices_by_connection_type(connection_type: str, usbmux_address: Optional[str] = None) -> List[MuxDevice]:
460 | """
461 | select all UsbMux devices by connection type
462 | """
463 | tmp = []
464 | for device in list_devices(usbmux_address=usbmux_address):
465 | if device.connection_type == connection_type:
466 | tmp.append(device)
467 |
468 | return tmp
469 |
470 |
471 |
472 | class USBMuxHTTPConnection(HTTPConnection):
473 | def __init__(self, device: MuxDevice, port=8100):
474 | super().__init__("localhost", port)
475 | self.__device = device
476 | self.__port = port
477 |
478 | def connect(self):
479 | self.sock = self.__device.connect(self.__port)
480 |
481 | def __enter__(self) -> HTTPConnection:
482 | return self
483 |
484 | def __exit__(self, exc_type, exc_value, traceback):
485 | self.close()
--------------------------------------------------------------------------------