uiautomator2-mcp-server 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- u2mcp/.gitignore +1 -1
- u2mcp/__init__.py +1 -2
- u2mcp/__main__.py +193 -82
- u2mcp/background.py +19 -0
- u2mcp/health.py +67 -0
- u2mcp/helpers.py +222 -0
- u2mcp/mcp.py +172 -61
- u2mcp/middlewares.py +40 -0
- u2mcp/tools/__init__.py +8 -3
- u2mcp/tools/action.py +143 -169
- u2mcp/tools/app.py +232 -231
- u2mcp/tools/clipboard.py +35 -0
- u2mcp/tools/device.py +307 -293
- u2mcp/tools/element.py +267 -0
- u2mcp/tools/input.py +47 -0
- u2mcp/tools/misc.py +17 -0
- u2mcp/tools/scrcpy.py +142 -0
- u2mcp/{_version.py → version.py} +34 -34
- uiautomator2_mcp_server-0.2.0.dist-info/METADATA +738 -0
- uiautomator2_mcp_server-0.2.0.dist-info/RECORD +25 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/WHEEL +1 -1
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/entry_points.txt +1 -0
- uiautomator2_mcp_server-0.2.0.dist-info/licenses/LICENSE +190 -0
- uiautomator2_mcp_server-0.1.2.dist-info/METADATA +0 -113
- uiautomator2_mcp_server-0.1.2.dist-info/RECORD +0 -16
- uiautomator2_mcp_server-0.1.2.dist-info/licenses/LICENSE +0 -620
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/top_level.txt +0 -0
u2mcp/tools/device.py
CHANGED
|
@@ -1,293 +1,307 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
import uiautomator2 as u2
|
|
12
|
-
from adbutils import adb
|
|
13
|
-
from
|
|
14
|
-
from fastmcp.utilities.logging import get_logger
|
|
15
|
-
from PIL.Image import Image
|
|
16
|
-
|
|
17
|
-
from ..mcp import mcp
|
|
18
|
-
|
|
19
|
-
__all__ = (
|
|
20
|
-
"device_list",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
_devices: dict[str, tuple[
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@asynccontextmanager
|
|
39
|
-
async def get_device(serial: str) -> AsyncGenerator[u2.Device]:
|
|
40
|
-
async with
|
|
41
|
-
try:
|
|
42
|
-
|
|
43
|
-
except KeyError:
|
|
44
|
-
|
|
45
|
-
def _connect():
|
|
46
|
-
_d = u2.connect(serial)
|
|
47
|
-
_d.info
|
|
48
|
-
return _d
|
|
49
|
-
|
|
50
|
-
device = await
|
|
51
|
-
|
|
52
|
-
_devices[serial] =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
async
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
async
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
Args:
|
|
213
|
-
serial (str): Android device serialno
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from contextlib import asynccontextmanager, closing
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import uiautomator2 as u2
|
|
12
|
+
from adbutils import adb
|
|
13
|
+
from anyio import Lock, to_thread
|
|
14
|
+
from fastmcp.utilities.logging import get_logger
|
|
15
|
+
from PIL.Image import Image
|
|
16
|
+
|
|
17
|
+
from ..mcp import mcp
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
"device_list",
|
|
21
|
+
"shell_command",
|
|
22
|
+
"init",
|
|
23
|
+
"connect",
|
|
24
|
+
"disconnect",
|
|
25
|
+
"disconnect_all",
|
|
26
|
+
"window_size",
|
|
27
|
+
"screenshot",
|
|
28
|
+
"save_screenshot",
|
|
29
|
+
"dump_hierarchy",
|
|
30
|
+
"info",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_devices: dict[str, tuple[Lock, u2.Device]] = {}
|
|
35
|
+
_global_device_connection_lock = Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def get_device(serial: str) -> AsyncGenerator[u2.Device]:
|
|
40
|
+
async with _global_device_connection_lock:
|
|
41
|
+
try:
|
|
42
|
+
lock, device = _devices[serial]
|
|
43
|
+
except KeyError:
|
|
44
|
+
|
|
45
|
+
def _connect():
|
|
46
|
+
_d = u2.connect(serial)
|
|
47
|
+
_d.info
|
|
48
|
+
return _d
|
|
49
|
+
|
|
50
|
+
device = await to_thread.run_sync(_connect)
|
|
51
|
+
lock = Lock()
|
|
52
|
+
_devices[serial] = lock, device
|
|
53
|
+
|
|
54
|
+
async with lock:
|
|
55
|
+
yield device
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool("init", tags={"device:manage"})
|
|
59
|
+
async def init(serial: str = ""):
|
|
60
|
+
"""Install essential resources (minicap, minitouch, uiautomator ...) to device.
|
|
61
|
+
|
|
62
|
+
Important:
|
|
63
|
+
This tool must be run on the Android device before running operation actions.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
serial (str): Android device serialno to initialize. If empty string, all devices will be initialized.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
None upon successful completion
|
|
70
|
+
Raises an exception if the subprocess returns a non-zero exit code.
|
|
71
|
+
"""
|
|
72
|
+
from uiautomator2.__main__ import cmd_init
|
|
73
|
+
|
|
74
|
+
args = argparse.Namespace(serial=serial, serial_optional=None)
|
|
75
|
+
return await to_thread.run_sync(cmd_init, args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool("purge", tags={"device:manage"})
|
|
79
|
+
async def purge(serial: str = ""):
|
|
80
|
+
"""Purge all resources (minicap, minitouch, uiautomator ...) from device.
|
|
81
|
+
|
|
82
|
+
Important:
|
|
83
|
+
This tool must be run on the Android device before running operation actions.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
serial (str): Android device serialno to purge. If empty string, all devices will be purged.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
None upon successful completion
|
|
90
|
+
Raises an exception if the subprocess returns a non-zero exit code.
|
|
91
|
+
"""
|
|
92
|
+
from uiautomator2.__main__ import cmd_purge
|
|
93
|
+
|
|
94
|
+
args = argparse.Namespace(serial=serial)
|
|
95
|
+
return await to_thread.run_sync(cmd_purge, args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool("shell_command", tags={"device:shell"})
|
|
99
|
+
async def shell_command(serial: str, command: str, timeout: float = 60) -> tuple[int, str]:
|
|
100
|
+
"""Run a shell command on an Android device
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
serial (str): Android device serialno
|
|
104
|
+
command (str): Shell command to run
|
|
105
|
+
timeout (float): Seconds to wait for command to complete.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
tuple[int,str]: Return code and output of the command
|
|
109
|
+
"""
|
|
110
|
+
async with get_device(serial) as device:
|
|
111
|
+
return_value = await to_thread.run_sync(device.adb_device.shell2, command, timeout)
|
|
112
|
+
return return_value.returncode, return_value.output
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool("device_list", tags={"device:info"})
|
|
116
|
+
async def device_list() -> list[dict[str, Any]]:
|
|
117
|
+
"""List of Adb Device with state:device
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
list[dict[str,Any]]: List Adb Device information
|
|
121
|
+
"""
|
|
122
|
+
device_list = await to_thread.run_sync(adb.device_list)
|
|
123
|
+
return [d.info for d in device_list]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool("connect", tags={"device:manage"})
|
|
127
|
+
async def connect(serial: str = "") -> dict[str, Any]:
|
|
128
|
+
"""Connect to an Android device
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
serial (str): Android device serial number. If empty string, connects to the unique device if only one device is connected.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
dict[str,Any]: Device information
|
|
135
|
+
"""
|
|
136
|
+
global _devices
|
|
137
|
+
device: u2.Device | None = None
|
|
138
|
+
|
|
139
|
+
logger = get_logger(f"{__name__}.connect")
|
|
140
|
+
|
|
141
|
+
if serial := serial.strip():
|
|
142
|
+
try:
|
|
143
|
+
async with get_device(serial) as device_1:
|
|
144
|
+
# Found, then check if it's still connected
|
|
145
|
+
try:
|
|
146
|
+
return await to_thread.run_sync(lambda: device_1.device_info | device_1.info)
|
|
147
|
+
except u2.ConnectError as e:
|
|
148
|
+
# Found, but not connected, delete it
|
|
149
|
+
logger.warning("Device %s is no longer connected, delete it!", serial)
|
|
150
|
+
del _devices[serial]
|
|
151
|
+
raise e from None
|
|
152
|
+
except KeyError:
|
|
153
|
+
# Not found, need a new connection!
|
|
154
|
+
logger.info("Cannot find device with serial %s, connecting...")
|
|
155
|
+
|
|
156
|
+
# make new connection here!
|
|
157
|
+
async with _global_device_connection_lock:
|
|
158
|
+
device = await to_thread.run_sync(u2.connect, serial)
|
|
159
|
+
if device is None:
|
|
160
|
+
raise RuntimeError("Cannot connect to device")
|
|
161
|
+
logger.info("Connected to device %s", device.serial)
|
|
162
|
+
result = await to_thread.run_sync(lambda: device.device_info | device.info)
|
|
163
|
+
_devices[device.serial] = Lock(), device
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool("disconnect", tags={"device:manage"})
|
|
168
|
+
async def disconnect(serial: str):
|
|
169
|
+
"""Disconnect from an Android device
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
serial (str): Android device serialno
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
None
|
|
176
|
+
"""
|
|
177
|
+
if not (serial := serial.strip()):
|
|
178
|
+
raise ValueError("serial cannot be empty")
|
|
179
|
+
async with _global_device_connection_lock:
|
|
180
|
+
_devices.pop(serial, None)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@mcp.tool("disconnect_all", tags={"device:manage"})
|
|
184
|
+
async def disconnect_all():
|
|
185
|
+
"""Disconnect from all Android devices"""
|
|
186
|
+
async with _global_device_connection_lock:
|
|
187
|
+
_devices.clear()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool("window_size", tags={"device:info"})
|
|
191
|
+
async def window_size(serial: str) -> dict[str, int]:
|
|
192
|
+
"""Get window size of an Android device
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
serial (str): Android device serialno
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
dict[str,int]: Window size object:
|
|
199
|
+
- "width" (int): Window width
|
|
200
|
+
- "height" (int): Window height
|
|
201
|
+
"""
|
|
202
|
+
async with get_device(serial) as device:
|
|
203
|
+
width, height = await to_thread.run_sync(device.window_size)
|
|
204
|
+
return {"width": width, "height": height}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@mcp.tool("screenshot", tags={"device:capture", "screen:capture"})
|
|
208
|
+
async def screenshot(serial: str, format: str = "jpeg", display_id: int = -1) -> dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Take screenshot of device
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
serial (str): Android device serialno.
|
|
214
|
+
format (str): Image format. Defaults to "jpeg".
|
|
215
|
+
display_id (int): use specific display if device has multiple screen. Defaults to -1.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
219
|
+
- image (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
220
|
+
- height (int): Image height
|
|
221
|
+
- width (int): Image width
|
|
222
|
+
"""
|
|
223
|
+
display_id = int(display_id)
|
|
224
|
+
async with get_device(serial) as device:
|
|
225
|
+
im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
|
|
226
|
+
|
|
227
|
+
if not isinstance(im, Image):
|
|
228
|
+
raise RuntimeError("Invalid image")
|
|
229
|
+
|
|
230
|
+
with closing(im):
|
|
231
|
+
with BytesIO() as fp:
|
|
232
|
+
im.save(fp, format)
|
|
233
|
+
im_data = fp.getvalue()
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
237
|
+
"height": im.height,
|
|
238
|
+
"width": im.width,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool("save_screenshot", tags={"device:capture", "screen:capture"})
|
|
243
|
+
async def save_screenshot(serial: str, file: str, display_id: int = -1) -> str:
|
|
244
|
+
"""
|
|
245
|
+
Save screenshot of device to file
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
serial(str): Android device serial number.
|
|
249
|
+
file(str): File path to save the screenshot. Supports both absolute and relative paths.
|
|
250
|
+
display_id(int): Use specific display if device has multiple screens. Defaults to -1 (default display).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
str: Screenshot save file path
|
|
254
|
+
"""
|
|
255
|
+
display_id = int(display_id)
|
|
256
|
+
|
|
257
|
+
async with get_device(serial) as device:
|
|
258
|
+
im = await to_thread.run_sync(lambda: device.screenshot(display_id=display_id if display_id >= 0 else None))
|
|
259
|
+
|
|
260
|
+
if not isinstance(im, Image):
|
|
261
|
+
raise RuntimeError("Invalid image")
|
|
262
|
+
|
|
263
|
+
with closing(im):
|
|
264
|
+
# Convert path to Path object and resolve
|
|
265
|
+
file_path = Path(file)
|
|
266
|
+
# Create parent directory if it doesn't exist
|
|
267
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
# Save the image
|
|
269
|
+
im.save(file_path)
|
|
270
|
+
|
|
271
|
+
return file_path.resolve().as_posix()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool("dump_hierarchy", tags={"device:capture"})
|
|
275
|
+
async def dump_hierarchy(serial: str, compressed: bool = False, pretty: bool = False, max_depth: int = -1) -> str:
|
|
276
|
+
"""
|
|
277
|
+
Dump window hierarchy
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
serial (str): Android device serialno
|
|
281
|
+
compressed (bool): return compressed xml
|
|
282
|
+
pretty (bool): pretty print xml
|
|
283
|
+
max_depth (int): max depth of hierarchy
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
str: xml string of the hierarchy tree
|
|
287
|
+
"""
|
|
288
|
+
async with get_device(serial) as device:
|
|
289
|
+
return await to_thread.run_sync(
|
|
290
|
+
lambda: device.dump_hierarchy(compressed=compressed, pretty=pretty, max_depth=max_depth if max_depth > 0 else None)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@mcp.tool("info", tags={"device:info"})
|
|
295
|
+
async def info(serial: str) -> dict[str, Any]:
|
|
296
|
+
"""
|
|
297
|
+
Get device info
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
serial (str): Android device serialno
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
dict[str,Any]: Device info
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
async with get_device(serial) as device:
|
|
307
|
+
return await to_thread.run_sync(lambda: device.info)
|