Skip to content

Commit e346334

Browse files
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 <[email protected]>
1 parent a8ca43b commit e346334

File tree

9 files changed

+361
-2
lines changed

9 files changed

+361
-2
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+
- serial (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
@@ -3281,6 +3306,27 @@ Implements:
32813306
Arguments:
32823307
- None
32833308

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

32863332
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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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.serial]
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.serial]
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+
["adb", "disconnect", f"{self._host}:{str(self._port)}"],
48+
stderr=subprocess.DEVNULL,
49+
timeout=ADB_TIMEOUT,
50+
)
51+
subprocess.run(
52+
["adb", "connect", f"{self._host}:{str(self._port)}"], stdout=subprocess.DEVNULL, timeout=ADB_TIMEOUT
53+
) # Connect adb client to TCP adb device
54+
self._base_command = [self.tool, "-s", f"{self._host}:{str(self._port)}"]
55+
56+
def on_deactivate(self):
57+
if isinstance(self.device, RemoteADBDevice):
58+
# Clean up TCP adb device once the driver is deactivated
59+
subprocess.Popen(["adb", "disconnect", f"{self._host}:{str(self._port)}"], stderr=subprocess.DEVNULL).wait(
60+
timeout=ADB_TIMEOUT
61+
)
62+
63+
# Command Protocol
64+
65+
def _run(self, cmd, *, timeout=30.0, codec="utf-8", decodeerrors="strict"):
66+
cmd = [*self._base_command, "shell", *shlex.split(cmd)]
67+
result = subprocess.run(
68+
cmd,
69+
text=True, # Automatically decode using default UTF-8
70+
capture_output=True,
71+
timeout=timeout,
72+
)
73+
return (
74+
result.stdout.splitlines(),
75+
result.stderr.splitlines(),
76+
result.returncode,
77+
)
78+
79+
@Driver.check_active
80+
@step(args=["cmd"], result=True)
81+
def run(self, cmd, timeout=30.0, codec="utf-8", decodeerrors="strict"):
82+
return self._run(cmd, timeout=timeout, codec=codec, decodeerrors=decodeerrors)
83+
84+
@step()
85+
def get_status(self):
86+
return 1
87+
88+
# File Transfer Protocol
89+
90+
@Driver.check_active
91+
@step(args=["filename", "remotepath", "timeout"])
92+
def put(self, filename: str, remotepath: str, timeout: float = ADB_TIMEOUT):
93+
subprocess.run([*self._base_command, "push", filename, remotepath], timeout=timeout)
94+
95+
@Driver.check_active
96+
@step(args=["filename", "destination", "timeout"])
97+
def get(self, filename: str, destination: str, timeout: float = ADB_TIMEOUT):
98+
subprocess.run([*self._base_command, "pull", filename, destination], timeout=timeout)
99+
100+
# Reset Protocol
101+
102+
@Driver.check_active
103+
@step(args=["mode"])
104+
def reset(self, mode=None):
105+
valid_modes = ["bootloader", "recovery", "sideload", "sideload-auto-reboot"]
106+
cmd = [*self._base_command, "reboot"]
107+
108+
if mode:
109+
if mode not in valid_modes:
110+
raise ValueError(f"{mode} must be one of: {', '.join(valid_modes)}")
111+
cmd.append(mode)
112+
113+
subprocess.run(cmd, timeout=ADB_TIMEOUT)

labgrid/remote/client.py

Lines changed: 90 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
@@ -1530,6 +1531,85 @@ async def export(self, place, target):
15301531
def print_version(self):
15311532
print(labgrid_version())
15321533

1534+
def adb(self):
1535+
place = self.get_acquired_place()
1536+
target = self._get_target(place)
1537+
name = self.args.name
1538+
adb_cmd = ["adb"]
1539+
1540+
from ..resource.adb import NetworkADBDevice, RemoteADBDevice
1541+
1542+
for resource in target.resources:
1543+
if name and resource.name != name:
1544+
continue
1545+
if isinstance(resource, NetworkADBDevice):
1546+
host, port = proxymanager.get_host_and_port(resource)
1547+
adb_cmd = ["adb", "-H", host, "-P", str(port), "-s", resource.serial]
1548+
break
1549+
elif isinstance(resource, RemoteADBDevice):
1550+
host, port = proxymanager.get_host_and_port(resource)
1551+
# ADB does not automatically remove a network device from its
1552+
# devices list when the connection is broken by the remote, so the
1553+
# adb connection may have gone "stale", resulting in adb blocking
1554+
# indefinitely when making calls to the device. To avoid this,
1555+
# always disconnect first.
1556+
subprocess.run(["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10)
1557+
subprocess.run(
1558+
["adb", "connect", f"{host}:{str(port)}"], stdout=subprocess.DEVNULL, timeout=10
1559+
) # Connect adb client to TCP adb device
1560+
adb_cmd = ["adb", "-s", f"{host}:{str(port)}"]]
1561+
break
1562+
1563+
adb_cmd += self.args.leftover
1564+
subprocess.run(adb_cmd)
1565+
1566+
def scrcpy(self):
1567+
place = self.get_acquired_place()
1568+
target = self._get_target(place)
1569+
name = self.args.name
1570+
scrcpy_cmd = ["scrcpy"]
1571+
env_var = os.environ.copy()
1572+
1573+
from ..resource.adb import NetworkADBDevice, RemoteADBDevice
1574+
1575+
for resource in target.resources:
1576+
if name and resource.name != name:
1577+
continue
1578+
if isinstance(resource, NetworkADBDevice):
1579+
host, adb_port = proxymanager.get_host_and_port(resource)
1580+
ip_addr = gethostbyname(host)
1581+
env_var["ADB_SERVER_SOCKET"] = f"tcp:{ip_addr}:{adb_port}"
1582+
1583+
scrcpy_cmd = [
1584+
"scrcpy",
1585+
"--port",
1586+
"27183",
1587+
"-s",
1588+
resource.serial,
1589+
]
1590+
1591+
# If a proxy is required, we need to setup a ssh port forward for the port
1592+
# (27183) scrcpy will use to send data along side the adb port
1593+
if resource.extra.get("proxy_required") or self.args.proxy:
1594+
proxy = resource.extra.get("proxy")
1595+
scrcpy_cmd.append(f"--tunnel-host={ip_addr}")
1596+
scrcpy_cmd.append(f"--tunnel-port={sshmanager.request_forward(proxy, host, 27183)}")
1597+
break
1598+
1599+
elif isinstance(resource, RemoteADBDevice):
1600+
host, port = proxymanager.get_host_and_port(resource)
1601+
# ADB does not automatically remove a network device from its
1602+
# devices list when the connection is broken by the remote, so the
1603+
# adb connection may have gone "stale", resulting in adb blocking
1604+
# indefinitely when making calls to the device. To avoid this,
1605+
# always disconnect first.
1606+
subprocess.run(["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10)
1607+
scrcpy_cmd = ["scrcpy", f"--tcpip={host}:{str(port)}"]
1608+
break
1609+
1610+
scrcpy_cmd += self.args.leftover
1611+
subprocess.run(scrcpy_cmd, env=env_var)
1612+
15331613

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

@@ -2031,9 +2111,17 @@ def main():
20312111
subparser = subparsers.add_parser("version", help="show version")
20322112
subparser.set_defaults(func=ClientSession.print_version)
20332113

2114+
subparser = subparsers.add_parser("adb", help="Run Android Debug Bridge")
2115+
subparser.add_argument("--name", "-n", help="optional resource name")
2116+
subparser.set_defaults(func=ClientSession.adb)
2117+
2118+
subparser = subparsers.add_parser("scrcpy", help="Run scrcpy to remote control an android device")
2119+
subparser.add_argument("--name", "-n", help="optional resource name")
2120+
subparser.set_defaults(func=ClientSession.scrcpy)
2121+
20342122
# make any leftover arguments available for some commands
20352123
args, leftover = parser.parse_known_args()
2036-
if args.command not in ["ssh", "rsync", "forward"]:
2124+
if args.command not in ["ssh", "rsync", "forward", "adb", "scrcpy"]:
20372125
args = parser.parse_args()
20382126
else:
20392127
args.leftover = leftover

labgrid/remote/exporter.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,85 @@ def _get_params(self):
773773
exports["YKUSHPowerPort"] = YKUSHPowerPortExport
774774

775775

776+
@attr.s(eq=False)
777+
class ADBExport(ResourceExport):
778+
"""ResourceExport for Android Debug Bridge Devices."""
779+
780+
def __attrs_post_init__(self):
781+
super().__attrs_post_init__()
782+
local_cls_name = self.cls
783+
self.data["cls"] = f"Network{local_cls_name}"
784+
from ..resource import adb
785+
786+
local_cls = getattr(adb, local_cls_name)
787+
self.local = local_cls(target=None, name=None, **self.local_params)
788+
self.child = None
789+
self.port = None
790+
791+
def __del__(self):
792+
if self.child is not None:
793+
self.stop()
794+
795+
def _get_params(self):
796+
"""Helper function to return parameters"""
797+
return {
798+
"host": self.host,
799+
"port": self.port,
800+
"serial": self.local.serial,
801+
}
802+
803+
def _start(self, start_params):
804+
"""Start `adb server` subprocess"""
805+
assert self.local.avail
806+
self.port = get_free_port()
807+
808+
# If the exporter is run on the same machine as clients, and the client uses ADB to connect to TCP
809+
# clients it will latch onto USB devices. This prevents the exporter from ever starting adb servers
810+
# for USB devices.
811+
# This will kill the global server to work around this but won't affect the --one-device servers
812+
# started by the exporter
813+
subprocess.Popen(["adb", "kill-server"]).wait()
814+
815+
cmd = [
816+
"adb",
817+
"server",
818+
"nodaemon",
819+
"-a",
820+
"-P",
821+
str(self.port),
822+
"--one-device",
823+
self.local.serial,
824+
]
825+
self.logger.info("Starting adb server with: %s", " ".join(cmd))
826+
self.child = subprocess.Popen(cmd)
827+
try:
828+
self.child.wait(timeout=0.5)
829+
raise ExporterError(f"adb for {self.local.serial} exited immediately")
830+
except subprocess.TimeoutExpired:
831+
# good, adb didn't exit immediately
832+
pass
833+
self.logger.info("started adb for %s on port %s", self.local.serial, self.port)
834+
835+
def _stop(self, start_params):
836+
assert self.child
837+
child = self.child
838+
self.child = None
839+
port = self.port
840+
self.port = None
841+
child.terminate()
842+
try:
843+
child.wait(2.0) # Give adb a chance to close
844+
except subprocess.TimeoutExpired:
845+
self.logger.warning("adb for %s still running after SIGTERM", self.local.serial)
846+
log_subprocess_kernel_stack(self.logger, child)
847+
child.kill()
848+
child.wait(1.0)
849+
self.logger.info("stopped adb for %s on port %d", self.local.serial, port)
850+
851+
852+
exports["ADBDevice"] = ADBExport
853+
854+
776855
class Exporter:
777856
def __init__(self, config) -> None:
778857
"""Set up internal datastructures on successful connection:

labgrid/resource/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@
4747
from .httpdigitalout import HttpDigitalOutput
4848
from .sigrok import SigrokDevice
4949
from .fastboot import AndroidNetFastboot
50+
from .adb import NetworkADBDevice, ADBDevice

0 commit comments

Comments
 (0)