Skip to content

Commit 8786e31

Browse files
ep1cmanluke-hackwell
authored andcommitted
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 <[email protected]>
1 parent 5ea9f9b commit 8786e31

File tree

10 files changed

+386
-5
lines changed

10 files changed

+386
-5
lines changed

doc/configuration.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,31 @@ Arguments:
12611261
Used by:
12621262
- none
12631263

1264+
ADB
1265+
~~~
1266+
1267+
ADBDevice
1268+
+++++++++
1269+
1270+
:any:`ADBDevice` describes a local adb device connected via USB.
1271+
1272+
Arguments:
1273+
- serialno (str): The serial number of the device as shown by adb
1274+
1275+
NetworkADBDevice
1276+
++++++++++++++++
1277+
1278+
A :any:`NetworkADBDevice` describes a `AdbDevice`_ available on a remote computer.
1279+
1280+
RemoteADBDevice
1281+
+++++++++++++++
1282+
1283+
:any:`RemoteADBDevice` describes a adb device available via TCP.
1284+
1285+
Arguments:
1286+
- host (str): The address of the TCP ADP device
1287+
- port (int): The TCP port ADB is exposed on the device
1288+
12641289
Providers
12651290
~~~~~~~~~
12661291
Providers describe directories that are accessible by the target over a
@@ -3284,6 +3309,27 @@ Implements:
32843309
Arguments:
32853310
- None
32863311

3312+
ADBDriver
3313+
~~~~~~~~~
3314+
The :any:`ADBDriver` allows interaction with ADB devices. It allows the
3315+
execution of commands, transfer of files, and rebooting of the device.
3316+
3317+
It can interact with both USB and TCP adb devices.
3318+
3319+
Binds to:
3320+
iface:
3321+
- `ADBDevice`_
3322+
- `NetworkADBDevice`_
3323+
- `RemoteADBDevice`_
3324+
3325+
Implements:
3326+
- :any:`CommandProtocol`
3327+
- :any:`FileTransferProtocol`
3328+
- :any:`ResetProtocol`
3329+
3330+
Arguments:
3331+
- None
3332+
32873333
.. _conf-strategies:
32883334

32893335
Strategies

labgrid/driver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@
4848
from .deditecrelaisdriver import DeditecRelaisDriver
4949
from .dediprogflashdriver import DediprogFlashDriver
5050
from .httpdigitaloutput import HttpDigitalOutputDriver
51+
from .adb import ADBDriver

labgrid/driver/adb.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import shlex
2+
import subprocess
3+
4+
import attr
5+
6+
from ..factory import target_factory
7+
from ..protocol import CommandProtocol, FileTransferProtocol, ResetProtocol
8+
from ..resource.adb import ADBDevice, NetworkADBDevice, RemoteADBDevice
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+
@target_factory.reg_driver
19+
@attr.s(eq=False)
20+
class ADBDriver(CommandMixin, Driver, CommandProtocol, FileTransferProtocol, ResetProtocol):
21+
"""ADB driver to execute commands, transfer files and reset devices via ADB."""
22+
23+
bindings = {"device": {"ADBDevice", "NetworkADBDevice", "RemoteADBDevice"}}
24+
25+
def __attrs_post_init__(self):
26+
super().__attrs_post_init__()
27+
if self.target.env:
28+
self.tool = self.target.env.config.get_tool("adb")
29+
else:
30+
self.tool = "adb"
31+
32+
if isinstance(self.device, ADBDevice):
33+
self._base_command = [self.tool, "-s", self.device.serialno]
34+
35+
elif isinstance(self.device, NetworkADBDevice):
36+
self._host, self._port = proxymanager.get_host_and_port(self.device)
37+
self._base_command = [self.tool, "-H", self._host, "-P", str(self._port), "-s", self.device.serialno]
38+
39+
elif isinstance(self.device, RemoteADBDevice):
40+
self._host, self._port = proxymanager.get_host_and_port(self.device)
41+
# ADB does not automatically remove a network device from its
42+
# devices list when the connection is broken by the remote, so the
43+
# adb connection may have gone "stale", resulting in adb blocking
44+
# indefinitely when making calls to the device. To avoid this,
45+
# always disconnect first.
46+
subprocess.run(
47+
[self.tool, "disconnect", f"{self._host}:{str(self._port)}"],
48+
stderr=subprocess.DEVNULL,
49+
timeout=ADB_TIMEOUT,
50+
check=False,
51+
)
52+
subprocess.run(
53+
[self.tool, "connect", f"{self._host}:{str(self._port)}"],
54+
stdout=subprocess.DEVNULL,
55+
timeout=ADB_TIMEOUT,
56+
check=True,
57+
) # Connect adb client to TCP adb device
58+
self._base_command = [self.tool, "-s", f"{self._host}:{str(self._port)}"]
59+
60+
def on_deactivate(self):
61+
if isinstance(self.device, RemoteADBDevice):
62+
# Clean up TCP adb device once the driver is deactivated
63+
subprocess.run(
64+
[self.tool, "disconnect", f"{self._host}:{str(self._port)}"],
65+
stderr=subprocess.DEVNULL,
66+
timeout=ADB_TIMEOUT,
67+
check=True,
68+
)
69+
70+
# Command Protocol
71+
72+
def _run(self, cmd, *, timeout=30.0, codec="utf-8", decodeerrors="strict"):
73+
cmd = [*self._base_command, "shell", *shlex.split(cmd)]
74+
result = subprocess.run(
75+
cmd,
76+
text=True, # Automatically decode using default UTF-8
77+
capture_output=True,
78+
timeout=timeout,
79+
)
80+
return (
81+
result.stdout.splitlines(),
82+
result.stderr.splitlines(),
83+
result.returncode,
84+
)
85+
86+
@Driver.check_active
87+
@step(args=["cmd"], result=True)
88+
def run(self, cmd, timeout=30.0, codec="utf-8", decodeerrors="strict"):
89+
return self._run(cmd, timeout=timeout, codec=codec, decodeerrors=decodeerrors)
90+
91+
@step()
92+
def get_status(self):
93+
return 1
94+
95+
# File Transfer Protocol
96+
97+
@Driver.check_active
98+
@step(args=["filename", "remotepath", "timeout"])
99+
def put(self, filename: str, remotepath: str, timeout: float = ADB_TIMEOUT):
100+
subprocess.run([*self._base_command, "push", filename, remotepath], timeout=timeout, check=True)
101+
102+
@Driver.check_active
103+
@step(args=["filename", "destination", "timeout"])
104+
def get(self, filename: str, destination: str, timeout: float = ADB_TIMEOUT):
105+
subprocess.run([*self._base_command, "pull", filename, destination], timeout=timeout, check=True)
106+
107+
# Reset Protocol
108+
109+
@Driver.check_active
110+
@step(args=["mode"])
111+
def reset(self, mode=None):
112+
valid_modes = ["bootloader", "recovery", "sideload", "sideload-auto-reboot"]
113+
cmd = [*self._base_command, "reboot"]
114+
115+
if mode:
116+
if mode not in valid_modes:
117+
raise ValueError(f"{mode} must be one of: {', '.join(valid_modes)}")
118+
cmd.append(mode)
119+
120+
subprocess.run(cmd, timeout=ADB_TIMEOUT, check=True)

labgrid/remote/client.py

Lines changed: 105 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
@@ -44,6 +44,7 @@
4444
from ..resource.remote import RemotePlaceManager, RemotePlace
4545
from ..util import diff_dict, flat_dict, dump, atomic_replace, labgrid_version, Timeout
4646
from ..util.proxy import proxymanager
47+
from ..util.ssh import sshmanager
4748
from ..util.helper import processwrapper
4849
from ..driver import Mode, ExecutionError
4950
from ..logging import basicConfig, StepLogger
@@ -1551,6 +1552,100 @@ async def export(self, place, target):
15511552
def print_version(self):
15521553
print(labgrid_version())
15531554

1555+
def adb(self):
1556+
place = self.get_acquired_place()
1557+
target = self._get_target(place)
1558+
name = self.args.name
1559+
adb_cmd = ["adb"]
1560+
1561+
from ..resource.adb import NetworkADBDevice, RemoteADBDevice
1562+
1563+
for resource in target.resources:
1564+
if name and resource.name != name:
1565+
continue
1566+
if isinstance(resource, NetworkADBDevice):
1567+
host, port = proxymanager.get_host_and_port(resource)
1568+
adb_cmd = ["adb", "-H", host, "-P", str(port), "-s", resource.serialno]
1569+
break
1570+
elif isinstance(resource, RemoteADBDevice):
1571+
host, port = proxymanager.get_host_and_port(resource)
1572+
# ADB does not automatically remove a network device from its
1573+
# devices list when the connection is broken by the remote, so the
1574+
# adb connection may have gone "stale", resulting in adb blocking
1575+
# indefinitely when making calls to the device. To avoid this,
1576+
# always disconnect first.
1577+
subprocess.run(
1578+
["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10, check=True
1579+
)
1580+
subprocess.run(
1581+
["adb", "connect", f"{host}:{str(port)}"], stdout=subprocess.DEVNULL, timeout=10, check=True
1582+
) # Connect adb client to TCP adb device
1583+
adb_cmd = ["adb", "-s", f"{host}:{str(port)}"]
1584+
break
1585+
1586+
adb_cmd += self.args.leftover
1587+
subprocess.run(adb_cmd, check=True)
1588+
1589+
def scrcpy(self):
1590+
place = self.get_acquired_place()
1591+
target = self._get_target(place)
1592+
name = self.args.name
1593+
scrcpy_cmd = ["scrcpy"]
1594+
env_var = os.environ.copy()
1595+
1596+
from ..resource.adb import NetworkADBDevice, RemoteADBDevice
1597+
1598+
for resource in target.resources:
1599+
if name and resource.name != name:
1600+
continue
1601+
if isinstance(resource, NetworkADBDevice):
1602+
host, adb_port = proxymanager.get_host_and_port(resource)
1603+
ip_addr = gethostbyname(host)
1604+
env_var["ADB_SERVER_SOCKET"] = f"tcp:{ip_addr}:{adb_port}"
1605+
1606+
# Find a free port on the exporter machine
1607+
scrcpy_port = sshmanager.get(host).run_check(
1608+
'python -c "'
1609+
"import socket;"
1610+
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.bind(("
1611+
"'', 0));"
1612+
"addr = s.getsockname();"
1613+
"print(addr[1]);"
1614+
's.close()"'
1615+
)[0]
1616+
1617+
scrcpy_cmd = [
1618+
"scrcpy",
1619+
"--port",
1620+
scrcpy_port,
1621+
"-s",
1622+
resource.serialno,
1623+
]
1624+
1625+
# If a proxy is required, we need to setup a ssh port forward for the port
1626+
# (27183) scrcpy will use to send data along side the adb port
1627+
if resource.extra.get("proxy_required") or self.args.proxy:
1628+
proxy = resource.extra.get("proxy")
1629+
scrcpy_cmd.append(f"--tunnel-host={ip_addr}")
1630+
scrcpy_cmd.append(f"--tunnel-port={sshmanager.request_forward(proxy, host, int(scrcpy_port))}")
1631+
break
1632+
1633+
elif isinstance(resource, RemoteADBDevice):
1634+
host, port = proxymanager.get_host_and_port(resource)
1635+
# ADB does not automatically remove a network device from its
1636+
# devices list when the connection is broken by the remote, so the
1637+
# adb connection may have gone "stale", resulting in adb blocking
1638+
# indefinitely when making calls to the device. To avoid this,
1639+
# always disconnect first.
1640+
subprocess.run(
1641+
["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10, check=True
1642+
)
1643+
scrcpy_cmd = ["scrcpy", f"--tcpip={host}:{str(port)}"]
1644+
break
1645+
1646+
scrcpy_cmd += self.args.leftover
1647+
subprocess.run(scrcpy_cmd, env=env_var, check=True)
1648+
15541649

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

@@ -2058,9 +2153,17 @@ def main():
20582153
subparser = subparsers.add_parser("version", help="show version")
20592154
subparser.set_defaults(func=ClientSession.print_version)
20602155

2156+
subparser = subparsers.add_parser("adb", help="Run Android Debug Bridge")
2157+
subparser.add_argument("--name", "-n", help="optional resource name")
2158+
subparser.set_defaults(func=ClientSession.adb)
2159+
2160+
subparser = subparsers.add_parser("scrcpy", help="Run scrcpy to remote control an android device")
2161+
subparser.add_argument("--name", "-n", help="optional resource name")
2162+
subparser.set_defaults(func=ClientSession.scrcpy)
2163+
20612164
# make any leftover arguments available for some commands
20622165
args, leftover = parser.parse_known_args()
2063-
if args.command not in ["ssh", "rsync", "forward"]:
2166+
if args.command not in ["ssh", "rsync", "forward", "adb", "scrcpy"]:
20642167
args = parser.parse_args()
20652168
else:
20662169
args.leftover = leftover

labgrid/remote/coordinator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,9 @@ def remove_place(self, place):
248248
def remove_reservation(self, reservation):
249249
self._assert_session(reservation.session)
250250
reservations = self._sessions[reservation.session]["reservations"]
251-
assert (
252-
reservation in reservations
253-
), f"Session {reservation.session} does not contain reservation {reservation.token}"
251+
assert reservation in reservations, (
252+
f"Session {reservation.session} does not contain reservation {reservation.token}"
253+
)
254254
reservations.remove(reservation)
255255

256256
def _assert_session(self, session):

0 commit comments

Comments
 (0)