From 892693a741d3b7145d24f03727e2ecd228c3b2c6 Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Wed, 8 Jan 2025 16:01:48 +0000 Subject: [PATCH] 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 --- doc/configuration.rst | 46 ++++++++++++++ labgrid/driver/__init__.py | 1 + labgrid/driver/adb.py | 113 +++++++++++++++++++++++++++++++++++ labgrid/remote/client.py | 92 +++++++++++++++++++++++++++- labgrid/remote/exporter.py | 79 ++++++++++++++++++++++++ labgrid/resource/__init__.py | 1 + labgrid/resource/adb.py | 23 +++++++ man/labgrid-client.1 | 4 ++ man/labgrid-client.rst | 4 ++ 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 labgrid/driver/adb.py create mode 100644 labgrid/resource/adb.py diff --git a/doc/configuration.rst b/doc/configuration.rst index 4a3ffea66..a57e07400 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -1261,6 +1261,31 @@ Arguments: Used by: - none +ADB +~~~ + +ADBDevice ++++++++++ + +:any:`ADBDevice` describes a local adb device connected via USB. + +Arguments: + - serial (str): The serial number of the device as shown by adb + +NetworkADBDevice +++++++++++++++++ + +A :any:`NetworkADBDevice` describes a `AdbDevice`_ available on a remote computer. + +RemoteADBDevice ++++++++++++++++ + +:any:`RemoteADBDevice` describes a adb device available via TCP. + +Arguments: + - host (str): The address of the TCP ADP device + - port (int): The TCP port ADB is exposed on the device + Providers ~~~~~~~~~ Providers describe directories that are accessible by the target over a @@ -3281,6 +3306,27 @@ Implements: Arguments: - None +ADBDriver +~~~~~~~~~ +The :any:`ADBDriver` allows interaction with ADB devices. It allows the +execution of commands, transfer of files, and rebooting of the device. + +It can interact with both USB and TCP adb devices. + +Binds to: + iface: + - `ADBDevice`_ + - `NetworkADBDevice`_ + - `RemoteADBDevice`_ + +Implements: + - :any:`CommandProtocol` + - :any:`FileTransferProtocol` + - :any:`ResetProtocol` + +Arguments: + - None + .. _conf-strategies: Strategies diff --git a/labgrid/driver/__init__.py b/labgrid/driver/__init__.py index 721256bbf..67c669cdb 100644 --- a/labgrid/driver/__init__.py +++ b/labgrid/driver/__init__.py @@ -48,3 +48,4 @@ from .deditecrelaisdriver import DeditecRelaisDriver from .dediprogflashdriver import DediprogFlashDriver from .httpdigitaloutput import HttpDigitalOutputDriver +from .adb import ADBDriver diff --git a/labgrid/driver/adb.py b/labgrid/driver/adb.py new file mode 100644 index 000000000..5da79a194 --- /dev/null +++ b/labgrid/driver/adb.py @@ -0,0 +1,113 @@ +import shlex +import subprocess + +import attr + +from ..factory import target_factory +from ..protocol import CommandProtocol, FileTransferProtocol, ResetProtocol +from ..resource.adb import ADBDevice, NetworkADBDevice, RemoteADBDevice +from ..step import step +from ..util.proxy import proxymanager +from .commandmixin import CommandMixin +from .common import Driver + +# Default timeout for adb commands, in seconds +ADB_TIMEOUT = 10 + + +@target_factory.reg_driver +@attr.s(eq=False) +class ADBDriver(CommandMixin, Driver, CommandProtocol, FileTransferProtocol, ResetProtocol): + """ADB driver to execute commands, transfer files and reset devices via ADB.""" + + bindings = {"device": {"ADBDevice", "NetworkADBDevice", "RemoteADBDevice"}} + + def __attrs_post_init__(self): + super().__attrs_post_init__() + if self.target.env: + self.tool = self.target.env.config.get_tool("adb") + else: + self.tool = "adb" + + if isinstance(self.device, ADBDevice): + self._base_command = [self.tool, "-s", self.device.serial] + + elif isinstance(self.device, NetworkADBDevice): + self._host, self._port = proxymanager.get_host_and_port(self.device) + self._base_command = [self.tool, "-H", self._host, "-P", str(self._port), "-s", self.device.serial] + + elif isinstance(self.device, RemoteADBDevice): + self._host, self._port = proxymanager.get_host_and_port(self.device) + # ADB does not automatically remove a network device from its + # devices list when the connection is broken by the remote, so the + # adb connection may have gone "stale", resulting in adb blocking + # indefinitely when making calls to the device. To avoid this, + # always disconnect first. + subprocess.run( + ["adb", "disconnect", f"{self._host}:{str(self._port)}"], + stderr=subprocess.DEVNULL, + timeout=ADB_TIMEOUT, + ) + subprocess.run( + ["adb", "connect", f"{self._host}:{str(self._port)}"], stdout=subprocess.DEVNULL, timeout=ADB_TIMEOUT + ) # Connect adb client to TCP adb device + self._base_command = [self.tool, "-s", f"{self._host}:{str(self._port)}"] + + def on_deactivate(self): + if isinstance(self.device, RemoteADBDevice): + # Clean up TCP adb device once the driver is deactivated + subprocess.Popen(["adb", "disconnect", f"{self._host}:{str(self._port)}"], stderr=subprocess.DEVNULL).wait( + timeout=ADB_TIMEOUT + ) + + # Command Protocol + + def _run(self, cmd, *, timeout=30.0, codec="utf-8", decodeerrors="strict"): + cmd = [*self._base_command, "shell", *shlex.split(cmd)] + result = subprocess.run( + cmd, + text=True, # Automatically decode using default UTF-8 + capture_output=True, + timeout=timeout, + ) + return ( + result.stdout.splitlines(), + result.stderr.splitlines(), + result.returncode, + ) + + @Driver.check_active + @step(args=["cmd"], result=True) + def run(self, cmd, timeout=30.0, codec="utf-8", decodeerrors="strict"): + return self._run(cmd, timeout=timeout, codec=codec, decodeerrors=decodeerrors) + + @step() + def get_status(self): + return 1 + + # File Transfer Protocol + + @Driver.check_active + @step(args=["filename", "remotepath", "timeout"]) + def put(self, filename: str, remotepath: str, timeout: float = ADB_TIMEOUT): + subprocess.run([*self._base_command, "push", filename, remotepath], timeout=timeout) + + @Driver.check_active + @step(args=["filename", "destination", "timeout"]) + def get(self, filename: str, destination: str, timeout: float = ADB_TIMEOUT): + subprocess.run([*self._base_command, "pull", filename, destination], timeout=timeout) + + # Reset Protocol + + @Driver.check_active + @step(args=["mode"]) + def reset(self, mode=None): + valid_modes = ["bootloader", "recovery", "sideload", "sideload-auto-reboot"] + cmd = [*self._base_command, "reboot"] + + if mode: + if mode not in valid_modes: + raise ValueError(f"{mode} must be one of: {', '.join(valid_modes)}") + cmd.append(mode) + + subprocess.run(cmd, timeout=ADB_TIMEOUT) diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 5ab4f0683..ffa5ad2f6 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -18,7 +18,7 @@ import json import itertools from textwrap import indent -from socket import gethostname +from socket import gethostname, gethostbyname from getpass import getuser from collections import defaultdict, OrderedDict from datetime import datetime @@ -44,6 +44,7 @@ from ..resource.remote import RemotePlaceManager, RemotePlace from ..util import diff_dict, flat_dict, dump, atomic_replace, labgrid_version, Timeout from ..util.proxy import proxymanager +from ..util.ssh import sshmanager from ..util.helper import processwrapper from ..driver import Mode, ExecutionError from ..logging import basicConfig, StepLogger @@ -1530,6 +1531,85 @@ async def export(self, place, target): def print_version(self): print(labgrid_version()) + def adb(self): + place = self.get_acquired_place() + target = self._get_target(place) + name = self.args.name + adb_cmd = ["adb"] + + from ..resource.adb import NetworkADBDevice, RemoteADBDevice + + for resource in target.resources: + if name and resource.name != name: + continue + if isinstance(resource, NetworkADBDevice): + host, port = proxymanager.get_host_and_port(resource) + adb_cmd = ["adb", "-H", host, "-P", str(port), "-s", resource.serial] + break + elif isinstance(resource, RemoteADBDevice): + host, port = proxymanager.get_host_and_port(resource) + # ADB does not automatically remove a network device from its + # devices list when the connection is broken by the remote, so the + # adb connection may have gone "stale", resulting in adb blocking + # indefinitely when making calls to the device. To avoid this, + # always disconnect first. + subprocess.run(["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10) + subprocess.run( + ["adb", "connect", f"{host}:{str(port)}"], stdout=subprocess.DEVNULL, timeout=10 + ) # Connect adb client to TCP adb device + adb_cmd = ["adb", "-s", f"{host}:{str(port)}"] + break + + adb_cmd += self.args.leftover + subprocess.run(adb_cmd) + + def scrcpy(self): + place = self.get_acquired_place() + target = self._get_target(place) + name = self.args.name + scrcpy_cmd = ["scrcpy"] + env_var = os.environ.copy() + + from ..resource.adb import NetworkADBDevice, RemoteADBDevice + + for resource in target.resources: + if name and resource.name != name: + continue + if isinstance(resource, NetworkADBDevice): + host, adb_port = proxymanager.get_host_and_port(resource) + ip_addr = gethostbyname(host) + env_var["ADB_SERVER_SOCKET"] = f"tcp:{ip_addr}:{adb_port}" + + scrcpy_cmd = [ + "scrcpy", + "--port", + "27183", + "-s", + resource.serial, + ] + + # If a proxy is required, we need to setup a ssh port forward for the port + # (27183) scrcpy will use to send data along side the adb port + if resource.extra.get("proxy_required") or self.args.proxy: + proxy = resource.extra.get("proxy") + scrcpy_cmd.append(f"--tunnel-host={ip_addr}") + scrcpy_cmd.append(f"--tunnel-port={sshmanager.request_forward(proxy, host, 27183)}") + break + + elif isinstance(resource, RemoteADBDevice): + host, port = proxymanager.get_host_and_port(resource) + # ADB does not automatically remove a network device from its + # devices list when the connection is broken by the remote, so the + # adb connection may have gone "stale", resulting in adb blocking + # indefinitely when making calls to the device. To avoid this, + # always disconnect first. + subprocess.run(["adb", "disconnect", f"{host}:{str(port)}"], stderr=subprocess.DEVNULL, timeout=10) + scrcpy_cmd = ["scrcpy", f"--tcpip={host}:{str(port)}"] + break + + scrcpy_cmd += self.args.leftover + subprocess.run(scrcpy_cmd, env=env_var) + _loop: ContextVar["asyncio.AbstractEventLoop | None"] = ContextVar("_loop", default=None) @@ -2031,9 +2111,17 @@ def main(): subparser = subparsers.add_parser("version", help="show version") subparser.set_defaults(func=ClientSession.print_version) + subparser = subparsers.add_parser("adb", help="Run Android Debug Bridge") + subparser.add_argument("--name", "-n", help="optional resource name") + subparser.set_defaults(func=ClientSession.adb) + + subparser = subparsers.add_parser("scrcpy", help="Run scrcpy to remote control an android device") + subparser.add_argument("--name", "-n", help="optional resource name") + subparser.set_defaults(func=ClientSession.scrcpy) + # make any leftover arguments available for some commands args, leftover = parser.parse_known_args() - if args.command not in ["ssh", "rsync", "forward"]: + if args.command not in ["ssh", "rsync", "forward", "adb", "scrcpy"]: args = parser.parse_args() else: args.leftover = leftover diff --git a/labgrid/remote/exporter.py b/labgrid/remote/exporter.py index 7831ef8a7..068a17895 100755 --- a/labgrid/remote/exporter.py +++ b/labgrid/remote/exporter.py @@ -773,6 +773,85 @@ def _get_params(self): exports["YKUSHPowerPort"] = YKUSHPowerPortExport +@attr.s(eq=False) +class ADBExport(ResourceExport): + """ResourceExport for Android Debug Bridge Devices.""" + + def __attrs_post_init__(self): + super().__attrs_post_init__() + local_cls_name = self.cls + self.data["cls"] = f"Network{local_cls_name}" + from ..resource import adb + + local_cls = getattr(adb, local_cls_name) + self.local = local_cls(target=None, name=None, **self.local_params) + self.child = None + self.port = None + + def __del__(self): + if self.child is not None: + self.stop() + + def _get_params(self): + """Helper function to return parameters""" + return { + "host": self.host, + "port": self.port, + "serial": self.local.serial, + } + + def _start(self, start_params): + """Start `adb server` subprocess""" + assert self.local.avail + self.port = get_free_port() + + # If the exporter is run on the same machine as clients, and the client uses ADB to connect to TCP + # clients it will latch onto USB devices. This prevents the exporter from ever starting adb servers + # for USB devices. + # This will kill the global server to work around this but won't affect the --one-device servers + # started by the exporter + subprocess.Popen(["adb", "kill-server"]).wait() + + cmd = [ + "adb", + "server", + "nodaemon", + "-a", + "-P", + str(self.port), + "--one-device", + self.local.serial, + ] + self.logger.info("Starting adb server with: %s", " ".join(cmd)) + self.child = subprocess.Popen(cmd) + try: + self.child.wait(timeout=0.5) + raise ExporterError(f"adb for {self.local.serial} exited immediately") + except subprocess.TimeoutExpired: + # good, adb didn't exit immediately + pass + self.logger.info("started adb for %s on port %s", self.local.serial, self.port) + + def _stop(self, start_params): + assert self.child + child = self.child + self.child = None + port = self.port + self.port = None + child.terminate() + try: + child.wait(2.0) # Give adb a chance to close + except subprocess.TimeoutExpired: + self.logger.warning("adb for %s still running after SIGTERM", self.local.serial) + log_subprocess_kernel_stack(self.logger, child) + child.kill() + child.wait(1.0) + self.logger.info("stopped adb for %s on port %d", self.local.serial, port) + + +exports["ADBDevice"] = ADBExport + + class Exporter: def __init__(self, config) -> None: """Set up internal datastructures on successful connection: diff --git a/labgrid/resource/__init__.py b/labgrid/resource/__init__.py index dd7554dff..b97b4b958 100644 --- a/labgrid/resource/__init__.py +++ b/labgrid/resource/__init__.py @@ -47,3 +47,4 @@ from .httpdigitalout import HttpDigitalOutput from .sigrok import SigrokDevice from .fastboot import AndroidNetFastboot +from .adb import NetworkADBDevice, ADBDevice diff --git a/labgrid/resource/adb.py b/labgrid/resource/adb.py new file mode 100644 index 000000000..407f228b5 --- /dev/null +++ b/labgrid/resource/adb.py @@ -0,0 +1,23 @@ +import attr + +from ..factory import target_factory +from .common import NetworkResource, Resource + + +@target_factory.reg_resource +@attr.s(eq=False) +class ADBDevice(Resource): + serial = attr.ib(validator=attr.validators.instance_of(str)) + + +@target_factory.reg_resource +@attr.s(eq=False) +class NetworkADBDevice(NetworkResource): + serial = attr.ib(validator=attr.validators.instance_of(str)) + port = attr.ib(validator=attr.validators.instance_of(int)) + + +@target_factory.reg_resource +@attr.s(eq=False) +class RemoteADBDevice(NetworkResource): + port = attr.ib(validator=attr.validators.instance_of(int)) diff --git a/man/labgrid-client.1 b/man/labgrid-client.1 index 150e7be3c..09677f0b6 100644 --- a/man/labgrid-client.1 +++ b/man/labgrid-client.1 @@ -230,6 +230,10 @@ not at all. \fBexport\fP filename Export driver information to file (needs environment with drivers) .sp \fBversion\fP Print the labgrid version +.sp +\fBadb\fP Run Android Debug Bridge +.sp +\fBscrcpy\fP Run scrcpy to remote control an android device .SH ADDING NAMED RESOURCES .sp If a target contains multiple Resources of the same type, named matches need to diff --git a/man/labgrid-client.rst b/man/labgrid-client.rst index 43b76f663..8b00ecd07 100644 --- a/man/labgrid-client.rst +++ b/man/labgrid-client.rst @@ -222,6 +222,10 @@ LABGRID-CLIENT COMMANDS ``version`` Print the labgrid version +``adb`` Run Android Debug Bridge + +``scrcpy`` Run scrcpy to remote control an android device + ADDING NAMED RESOURCES ---------------------- If a target contains multiple Resources of the same type, named matches need to