Skip to content

Commit 79aca51

Browse files
ep1cmanDavid Brownfreedrikpluke-hackwell
committed
Add support for android devices via ADB
ADB is expected to be installed and working on the exporter and client machines. For screensharing "scrcpy" needs to be installed on the client. Signed-off-by: Sebastian Goscik <sebastian.goscik@arm.com> Co-authored-by: David Brown <david.brown@arm.com> Co-authored-by: freedrikp <freedrikp@users.noreply.github.com> Co-authored-by: Luke Hackwell <luke.hackwell@arm.com>
1 parent c61a683 commit 79aca51

File tree

8 files changed

+440
-2
lines changed

8 files changed

+440
-2
lines changed

doc/configuration.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,31 @@ Arguments:
14041404
Used by:
14051405
- none
14061406

1407+
ADB
1408+
~~~
1409+
1410+
USBADBDevice
1411+
++++++++++++
1412+
1413+
:any:`USBADBDevice` describes a local adb device connected via USB.
1414+
1415+
Arguments:
1416+
- serialno (str): The serial number of the device as shown by adb
1417+
1418+
RemoteUSBADBDevice
1419+
++++++++++++++++++
1420+
1421+
A :any:`RemoteUSBADBDevice` describes a `USBADBDevice`_ available on a remote computer.
1422+
1423+
NetworkADBDevice
1424+
++++++++++++++++
1425+
1426+
:any:`NetworkADBDevice` describes an ADB device available via TCP.
1427+
1428+
Arguments:
1429+
- host (str): The address of the TCP ADP device
1430+
- port (int): The TCP port ADB is exposed on the device
1431+
14071432
Providers
14081433
~~~~~~~~~
14091434
Providers describe directories that are accessible by the target over a
@@ -3862,6 +3887,24 @@ The ``stage()`` method returns the filename as stored on the LAA.
38623887
The ``list()`` method returns a list of filenames. The ``remove(name)``
38633888
method removes a file by name.
38643889

3890+
ADBDriver
3891+
~~~~~~~~~
3892+
The :any:`ADBDriver` allows interaction with ADB devices. It allows the
3893+
execution of commands, transfer of files, and rebooting of the device.
3894+
3895+
It can interact with both USB and TCP adb devices.
3896+
3897+
Binds to:
3898+
iface:
3899+
- `USBADBDevice`_
3900+
- `RemoteUSBADBDevice`_
3901+
- `NetworkADBDevice`_
3902+
3903+
Implements:
3904+
- :any:`CommandProtocol`
3905+
- :any:`FileTransferProtocol`
3906+
- :any:`ResetProtocol`
3907+
38653908
.. _conf-strategies:
38663909

38673910
Strategies

labgrid/driver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@
5454
LAAUSBGadgetMassStorageDriver, LAAUSBDriver, \
5555
LAAButtonDriver, LAALedDriver, LAATempDriver, LAAWattDriver, \
5656
LAAProviderDriver
57+
from .adb import ADBDriver

labgrid/driver/adb.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import subprocess
2+
from enum import Enum
3+
4+
import attr
5+
6+
from ..factory import target_factory
7+
from ..protocol import CommandProtocol, FileTransferProtocol, ResetProtocol
8+
from ..resource.adb import NetworkADBDevice, RemoteUSBADBDevice, USBADBDevice
9+
from ..step import step
10+
from ..util.proxy import proxymanager
11+
from .commandmixin import CommandMixin
12+
from .common import Driver
13+
14+
# Default timeout for adb commands, in seconds
15+
ADB_TIMEOUT = 10
16+
17+
18+
class ADBRebootMode(Enum):
19+
REBOOT = None
20+
BOOTLOADER = "bootloader"
21+
RECOVERY = "recovery"
22+
SIDELOAD = "sideload"
23+
SIDELOAD_AUTO_REBOOT = "sideload-auto-reboot"
24+
25+
26+
@target_factory.reg_driver
27+
@attr.s(eq=False)
28+
class ADBDriver(CommandMixin, Driver, CommandProtocol, FileTransferProtocol, ResetProtocol):
29+
"""ADB driver to execute commands, transfer files and reset devices via ADB."""
30+
31+
bindings = {"device": {"USBADBDevice", "RemoteUSBADBDevice", "NetworkADBDevice"}}
32+
33+
def __attrs_post_init__(self):
34+
super().__attrs_post_init__()
35+
if self.target.env:
36+
self.tool = self.target.env.config.get_tool("adb")
37+
else:
38+
self.tool = "adb"
39+
40+
if isinstance(self.device, USBADBDevice):
41+
self._base_command = [self.tool, "-s", self.device.serialno]
42+
43+
elif isinstance(self.device, RemoteUSBADBDevice):
44+
self._host, self._port = proxymanager.get_host_and_port(self.device)
45+
self._base_command = [self.tool, "-H", self._host, "-P", str(self._port), "-s", self.device.serialno]
46+
47+
elif isinstance(self.device, NetworkADBDevice):
48+
self._host, self._port = proxymanager.get_host_and_port(self.device)
49+
# ADB does not automatically remove a network device from its
50+
# devices list when the connection is broken by the remote, so the
51+
# adb connection may have gone "stale", resulting in adb blocking
52+
# indefinitely when making calls to the device. To avoid this,
53+
# always disconnect first.
54+
subprocess.run(
55+
[self.tool, "disconnect", f"{self._host}:{str(self._port)}"],
56+
stdout=subprocess.DEVNULL,
57+
stderr=subprocess.DEVNULL,
58+
timeout=ADB_TIMEOUT,
59+
check=False,
60+
)
61+
subprocess.run(
62+
[self.tool, "connect", f"{self._host}:{str(self._port)}"],
63+
stdout=subprocess.DEVNULL,
64+
stderr=subprocess.DEVNULL,
65+
timeout=ADB_TIMEOUT,
66+
check=True,
67+
) # Connect adb client to TCP adb device
68+
self._base_command = [self.tool, "-s", f"{self._host}:{str(self._port)}"]
69+
70+
def on_deactivate(self):
71+
if isinstance(self.device, NetworkADBDevice):
72+
# Clean up TCP adb device once the driver is deactivated
73+
subprocess.run(
74+
[self.tool, "disconnect", f"{self._host}:{str(self._port)}"],
75+
stdout=subprocess.DEVNULL,
76+
stderr=subprocess.DEVNULL,
77+
timeout=ADB_TIMEOUT,
78+
check=True,
79+
)
80+
81+
# Command Protocol
82+
83+
def _run(self, cmd, *, timeout=30.0, codec="utf-8", decodeerrors="strict"):
84+
cmd = [*self._base_command, "shell", cmd]
85+
result = subprocess.run(
86+
cmd,
87+
text=True, # Automatically decode using default UTF-8
88+
capture_output=True,
89+
timeout=timeout,
90+
)
91+
return (
92+
result.stdout.splitlines(),
93+
result.stderr.splitlines(),
94+
result.returncode,
95+
)
96+
97+
@Driver.check_active
98+
@step(args=["cmd"], result=True)
99+
def run(self, cmd, timeout=30.0, codec="utf-8", decodeerrors="strict"):
100+
return self._run(cmd, timeout=timeout, codec=codec, decodeerrors=decodeerrors)
101+
102+
@step()
103+
def get_status(self):
104+
return 1
105+
106+
# File Transfer Protocol
107+
108+
@Driver.check_active
109+
@step(args=["filename", "remotepath", "timeout"])
110+
def put(self, filename: str, remotepath: str, timeout: float | None = None):
111+
subprocess.run(
112+
[*self._base_command, "push", filename, remotepath],
113+
stdout=subprocess.DEVNULL,
114+
stderr=subprocess.DEVNULL,
115+
timeout=timeout,
116+
check=True,
117+
)
118+
119+
@Driver.check_active
120+
@step(args=["filename", "destination", "timeout"])
121+
def get(self, filename: str, destination: str, timeout: float | None = None):
122+
subprocess.run(
123+
[*self._base_command, "pull", filename, destination],
124+
stdout=subprocess.DEVNULL,
125+
stderr=subprocess.DEVNULL,
126+
timeout=timeout,
127+
check=True,
128+
)
129+
130+
# Reset Protocol
131+
132+
@Driver.check_active
133+
@step(args=["mode"])
134+
def reset(self, mode: ADBRebootMode | str | None = None):
135+
cmd = [*self._base_command, "reboot"]
136+
137+
if mode is not None:
138+
try:
139+
mode = ADBRebootMode(mode)
140+
except ValueError as e:
141+
valid = ", ".join(m.value for m in ADBRebootMode if m.value)
142+
raise ValueError(f"Mode must be `None` or one of: {valid}") from e
143+
144+
if mode.value is not None:
145+
cmd.append(mode.value)
146+
147+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=ADB_TIMEOUT, check=True)

labgrid/remote/client.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import json
1919
import itertools
2020
from textwrap import indent
21-
from socket import gethostname
21+
from socket import gethostname, gethostbyname
2222
from getpass import getuser
2323
from collections import defaultdict, OrderedDict
2424
from datetime import datetime
@@ -47,6 +47,7 @@
4747
from ..resource.remote import RemotePlaceManager, RemotePlace
4848
from ..util import diff_dict, flat_dict, dump, atomic_replace, labgrid_version, Timeout
4949
from ..util.proxy import proxymanager
50+
from ..util.ssh import sshmanager
5051
from ..util.helper import processwrapper
5152
from ..driver import Mode, ExecutionError
5253
from ..logging import basicConfig, StepLogger
@@ -1627,6 +1628,100 @@ async def export(self, place, target):
16271628
def print_version(self):
16281629
print(labgrid_version())
16291630

1631+
def adb(self):
1632+
place = self.get_acquired_place()
1633+
target = self._get_target(place)
1634+
name = self.args.name
1635+
adb_cmd = ["adb"]
1636+
1637+
from ..resource.adb import RemoteUSBADBDevice, NetworkADBDevice
1638+
1639+
for resource in target.resources:
1640+
if name and resource.name != name:
1641+
continue
1642+
if isinstance(resource, RemoteUSBADBDevice):
1643+
host, port = proxymanager.get_host_and_port(resource)
1644+
adb_cmd = ["adb", "-H", host, "-P", str(port), "-s", resource.serialno]
1645+
break
1646+
elif isinstance(resource, NetworkADBDevice):
1647+
host, port = proxymanager.get_host_and_port(resource)
1648+
# ADB does not automatically remove a network device from its
1649+
# devices list when the connection is broken by the remote, so the
1650+
# adb connection may have gone "stale", resulting in adb blocking
1651+
# indefinitely when making calls to the device. To avoid this,
1652+
# always disconnect first.
1653+
subprocess.run(
1654+
["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10, check=True
1655+
)
1656+
subprocess.run(
1657+
["adb", "connect", f"{host}:{str(port)}"], stdout=subprocess.DEVNULL, timeout=10, check=True
1658+
) # Connect adb client to TCP adb device
1659+
adb_cmd = ["adb", "-s", f"{host}:{str(port)}"]
1660+
break
1661+
1662+
adb_cmd += self.args.leftover
1663+
subprocess.run(adb_cmd, check=True)
1664+
1665+
def scrcpy(self):
1666+
place = self.get_acquired_place()
1667+
target = self._get_target(place)
1668+
name = self.args.name
1669+
scrcpy_cmd = ["scrcpy"]
1670+
env_var = os.environ.copy()
1671+
1672+
from ..resource.adb import RemoteUSBADBDevice, NetworkADBDevice
1673+
1674+
for resource in target.resources:
1675+
if name and resource.name != name:
1676+
continue
1677+
if isinstance(resource, RemoteUSBADBDevice):
1678+
host, adb_port = proxymanager.get_host_and_port(resource)
1679+
ip_addr = gethostbyname(host)
1680+
env_var["ADB_SERVER_SOCKET"] = f"tcp:{ip_addr}:{adb_port}"
1681+
1682+
# Find a free port on the exporter machine
1683+
scrcpy_port = sshmanager.get(host).run_check(
1684+
'python -c "'
1685+
"import socket;"
1686+
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.bind(("
1687+
"'', 0));"
1688+
"addr = s.getsockname();"
1689+
"print(addr[1]);"
1690+
's.close()"'
1691+
)[0]
1692+
1693+
scrcpy_cmd = [
1694+
"scrcpy",
1695+
"--port",
1696+
scrcpy_port,
1697+
"-s",
1698+
resource.serialno,
1699+
]
1700+
1701+
# If a proxy is required, we need to setup a ssh port forward for the port
1702+
# (27183) scrcpy will use to send data along side the adb port
1703+
if resource.extra.get("proxy_required") or self.args.proxy:
1704+
proxy = resource.extra.get("proxy")
1705+
scrcpy_cmd.append(f"--tunnel-host={ip_addr}")
1706+
scrcpy_cmd.append(f"--tunnel-port={sshmanager.request_forward(proxy, host, int(scrcpy_port))}")
1707+
break
1708+
1709+
elif isinstance(resource, NetworkADBDevice):
1710+
host, port = proxymanager.get_host_and_port(resource)
1711+
# ADB does not automatically remove a network device from its
1712+
# devices list when the connection is broken by the remote, so the
1713+
# adb connection may have gone "stale", resulting in adb blocking
1714+
# indefinitely when making calls to the device. To avoid this,
1715+
# always disconnect first.
1716+
subprocess.run(
1717+
["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10, check=True
1718+
)
1719+
scrcpy_cmd = ["scrcpy", f"--tcpip={host}:{str(port)}"]
1720+
break
1721+
1722+
scrcpy_cmd += self.args.leftover
1723+
subprocess.run(scrcpy_cmd, env=env_var, check=True)
1724+
16301725

16311726
_loop: ContextVar["asyncio.AbstractEventLoop | None"] = ContextVar("_loop", default=None)
16321727

@@ -2180,6 +2275,18 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
21802275
subparser = subparsers.add_parser("version", help="show version")
21812276
subparser.set_defaults(func=ClientSession.print_version)
21822277

2278+
adb_subparser = subparsers.add_parser("adb", help="Run Android Debug Bridge")
2279+
adb_subparser.add_argument("--name", "-n", help="optional resource name")
2280+
adb_subparser.add_argument(
2281+
"adb_args", nargs=argparse.REMAINDER, help="adb command to execute (e.g. 'shell', 'devices', etc.)"
2282+
)
2283+
adb_subparser.set_defaults(func=ClientSession.adb)
2284+
2285+
adb_subparsers = adb_subparser.add_subparsers(dest="adb_command")
2286+
scrcpy_subparser = adb_subparsers.add_parser("scrcpy", help="Run scrcpy to remote control an android device")
2287+
scrcpy_subparser.add_argument("--name", "-n", help="optional resource name")
2288+
scrcpy_subparser.set_defaults(func=ClientSession.scrcpy)
2289+
21832290
return parser
21842291

21852292

@@ -2206,7 +2313,7 @@ def main():
22062313

22072314
# make any leftover arguments available for some commands
22082315
args, leftover = parser.parse_known_args()
2209-
if args.command not in ["ssh", "rsync", "forward"]:
2316+
if args.command not in ["ssh", "rsync", "forward", "adb", "scrcpy"]:
22102317
args = parser.parse_args()
22112318
else:
22122319
args.leftover = leftover

0 commit comments

Comments
 (0)