diff --git a/doc/configuration.rst b/doc/configuration.rst index cb7e4c520..d6a8c3e3c 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -1404,6 +1404,31 @@ Arguments: Used by: - none +ADB +~~~ + +USBADBDevice +++++++++++++ + +:any:`USBADBDevice` describes a local adb device connected via USB. + +Arguments: + - serialno (str): The serial number of the device as shown by adb + +RemoteUSBADBDevice +++++++++++++++++++ + +A :any:`RemoteUSBADBDevice` describes a `USBADBDevice`_ available on a remote computer. + +NetworkADBDevice +++++++++++++++++ + +:any:`NetworkADBDevice` describes an 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 @@ -3862,6 +3887,24 @@ The ``stage()`` method returns the filename as stored on the LAA. The ``list()`` method returns a list of filenames. The ``remove(name)`` method removes a file by name. +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: + - `USBADBDevice`_ + - `RemoteUSBADBDevice`_ + - `NetworkADBDevice`_ + +Implements: + - :any:`CommandProtocol` + - :any:`FileTransferProtocol` + - :any:`ResetProtocol` + .. _conf-strategies: Strategies diff --git a/labgrid/driver/__init__.py b/labgrid/driver/__init__.py index d3cd6f55e..7f0e925cf 100644 --- a/labgrid/driver/__init__.py +++ b/labgrid/driver/__init__.py @@ -54,3 +54,4 @@ LAAUSBGadgetMassStorageDriver, LAAUSBDriver, \ LAAButtonDriver, LAALedDriver, LAATempDriver, LAAWattDriver, \ LAAProviderDriver +from .adb import ADBDriver diff --git a/labgrid/driver/adb.py b/labgrid/driver/adb.py new file mode 100644 index 000000000..8db3ce2b5 --- /dev/null +++ b/labgrid/driver/adb.py @@ -0,0 +1,149 @@ +import subprocess +from enum import Enum + +import attr + +from ..factory import target_factory +from ..protocol import CommandProtocol, FileTransferProtocol, ResetProtocol +from ..resource.adb import NetworkADBDevice, RemoteUSBADBDevice, USBADBDevice +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 + + +class ADBRebootMode(Enum): + REBOOT = None + BOOTLOADER = "bootloader" + RECOVERY = "recovery" + SIDELOAD = "sideload" + SIDELOAD_AUTO_REBOOT = "sideload-auto-reboot" + + +@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": {"USBADBDevice", "RemoteUSBADBDevice", "NetworkADBDevice"}} + + 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" + + def on_activate(self): + if isinstance(self.device, USBADBDevice): + self._base_command = [self.tool, "-s", self.device.serialno] + + elif isinstance(self.device, RemoteUSBADBDevice): + 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.serialno] + + elif isinstance(self.device, NetworkADBDevice): + 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. + # TODO: Replace subprocess.run() with process wrapper once it supports timeouts. + subprocess.run( + [self.tool, "disconnect", f"{self._host}:{str(self._port)}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=ADB_TIMEOUT, + check=False, + ) + subprocess.run( + [self.tool, "connect", f"{self._host}:{str(self._port)}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=ADB_TIMEOUT, + check=True, + ) # 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, NetworkADBDevice): + # Clean up TCP adb device once the driver is deactivated + subprocess.run( + [self.tool, "disconnect", f"{self._host}:{str(self._port)}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=ADB_TIMEOUT, + check=True, + ) + + # Command Protocol + + def _run(self, cmd, *, timeout=30.0, codec="utf-8", decodeerrors="strict"): + cmd = [*self._base_command, "shell", 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 | None = None): + subprocess.run( + [*self._base_command, "push", filename, remotepath], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=timeout, + check=True, + ) + + @Driver.check_active + @step(args=["filename", "destination", "timeout"]) + def get(self, filename: str, destination: str, timeout: float | None = None): + subprocess.run( + [*self._base_command, "pull", filename, destination], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=timeout, + check=True, + ) + + # Reset Protocol + + @Driver.check_active + @step(args=["mode"]) + def reset(self, mode: ADBRebootMode | str | None = None): + cmd = [*self._base_command, "reboot"] + + if mode is not None: + try: + mode = ADBRebootMode(mode) + except ValueError as e: + valid = ", ".join(m.value for m in ADBRebootMode if m.value) + raise ValueError(f"Mode must be `None` or one of: {valid}") from e + + if mode.value is not None: + cmd.append(mode.value) + + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=ADB_TIMEOUT, check=True) diff --git a/labgrid/protocol/resetprotocol.py b/labgrid/protocol/resetprotocol.py index 6dc838706..76db32e75 100644 --- a/labgrid/protocol/resetprotocol.py +++ b/labgrid/protocol/resetprotocol.py @@ -3,5 +3,5 @@ class ResetProtocol(abc.ABC): @abc.abstractmethod - def reset(self): + def reset(self, mode=None): raise NotImplementedError diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py index 4d2eb0bfa..881b45abe 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -19,7 +19,7 @@ import itertools import ipaddress 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 @@ -48,6 +48,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 @@ -1660,6 +1661,100 @@ 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 RemoteUSBADBDevice, NetworkADBDevice + + for resource in target.resources: + if name and resource.name != name: + continue + if isinstance(resource, RemoteUSBADBDevice): + host, port = proxymanager.get_host_and_port(resource) + adb_cmd = ["adb", "-H", host, "-P", str(port), "-s", resource.serialno] + break + elif isinstance(resource, NetworkADBDevice): + 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, check=True + ) + subprocess.run( + ["adb", "connect", f"{host}:{str(port)}"], stdout=subprocess.DEVNULL, timeout=10, check=True + ) # 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, check=True) + + 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 RemoteUSBADBDevice, NetworkADBDevice + + for resource in target.resources: + if name and resource.name != name: + continue + if isinstance(resource, RemoteUSBADBDevice): + host, adb_port = proxymanager.get_host_and_port(resource) + ip_addr = gethostbyname(host) + env_var["ADB_SERVER_SOCKET"] = f"tcp:{ip_addr}:{adb_port}" + + # Find a free port on the exporter machine + scrcpy_port = sshmanager.get(host).run_check( + 'python -c "' + "import socket;" + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.bind((" + "'', 0));" + "addr = s.getsockname();" + "print(addr[1]);" + 's.close()"' + )[0] + + scrcpy_cmd = [ + "scrcpy", + "--port", + scrcpy_port, + "-s", + resource.serialno, + ] + + # 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, int(scrcpy_port))}") + break + + elif isinstance(resource, NetworkADBDevice): + 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, check=True + ) + scrcpy_cmd = ["scrcpy", f"--tcpip={host}:{str(port)}"] + break + + scrcpy_cmd += self.args.leftover + subprocess.run(scrcpy_cmd, env=env_var, check=True) + _loop: ContextVar["asyncio.AbstractEventLoop | None"] = ContextVar("_loop", default=None) @@ -2237,6 +2332,18 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg subparser = subparsers.add_parser("version", help="show version") subparser.set_defaults(func=ClientSession.print_version) + adb_subparser = subparsers.add_parser("adb", help="Run Android Debug Bridge") + adb_subparser.add_argument("--name", "-n", help="optional resource name") + adb_subparser.add_argument( + "adb_args", nargs=argparse.REMAINDER, help="adb command to execute (e.g. 'shell', 'devices', etc.)" + ) + adb_subparser.set_defaults(func=ClientSession.adb) + + adb_subparsers = adb_subparser.add_subparsers(dest="adb_command") + scrcpy_subparser = adb_subparsers.add_parser("scrcpy", help="Run scrcpy to remote control an android device") + scrcpy_subparser.add_argument("--name", "-n", help="optional resource name") + scrcpy_subparser.set_defaults(func=ClientSession.scrcpy) + return parser @@ -2263,7 +2370,7 @@ def main(): # 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 0e48f1942..66d7d80d0 100755 --- a/labgrid/remote/exporter.py +++ b/labgrid/remote/exporter.py @@ -803,6 +803,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, + "serialno": self.local.serialno, + } + + 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.run(["adb", "kill-server"], timeout=10, check=True) + + cmd = [ + "adb", + "server", + "nodaemon", + "-a", + "-P", + str(self.port), + "--one-device", + self.local.serialno, + ] + 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.serialno} exited immediately") + except subprocess.TimeoutExpired: + # good, adb didn't exit immediately + pass + self.logger.info("started adb for %s on port %s", self.local.serialno, 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.serialno) + 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.serialno, port) + + +exports["USBADBDevice"] = 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 53d88f458..c0dd97796 100644 --- a/labgrid/resource/__init__.py +++ b/labgrid/resource/__init__.py @@ -52,3 +52,4 @@ from .laa import LAASerialPort, LAAPowerPort, LAAUSBGadgetMassStorage, \ LAAUSBPort, LAAButtonPort, \ LAALed, LAATempSensor, LAAWattMeter, LAAProvider +from .adb import NetworkADBDevice, RemoteUSBADBDevice, USBADBDevice diff --git a/labgrid/resource/adb.py b/labgrid/resource/adb.py new file mode 100644 index 000000000..c936335d9 --- /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 USBADBDevice(Resource): + serialno = attr.ib(validator=attr.validators.instance_of(str)) + + +@target_factory.reg_resource +@attr.s(eq=False) +class RemoteUSBADBDevice(NetworkResource): + serialno = attr.ib(validator=attr.validators.instance_of(str)) + port = attr.ib(converter=int, validator=attr.validators.instance_of(int)) + + +@target_factory.reg_resource +@attr.s(eq=False) +class NetworkADBDevice(NetworkResource): + port = attr.ib(converter=int, validator=attr.validators.instance_of(int)) diff --git a/man/labgrid-client.1 b/man/labgrid-client.1 index dfc95b2e8..b2972fce5 100644 --- a/man/labgrid-client.1 +++ b/man/labgrid-client.1 @@ -100,6 +100,43 @@ usage: labgrid\-client acquire|lock [\-\-allow\-unmatched] .B \-\-allow\-unmatched allow missing resources for matches when locking the place .UNINDENT +.SS labgrid\-client adb +.sp +Run Android Debug Bridge +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +usage: labgrid\-client adb [\-\-name NAME] ... {scrcpy} ... +.EE +.UNINDENT +.UNINDENT +.INDENT 0.0 +.TP +.B adb_args +adb command to execute (e.g. \(aqshell\(aq, \(aqdevices\(aq, etc.) +.UNINDENT +.INDENT 0.0 +.TP +.B \-\-name , \-n +optional resource name +.UNINDENT +.SS labgrid\-client adb ... scrcpy +.sp +Run scrcpy to remote control an android device +.INDENT 0.0 +.INDENT 3.5 +.sp +.EX +usage: labgrid\-client adb ... scrcpy [\-\-name NAME] +.EE +.UNINDENT +.UNINDENT +.INDENT 0.0 +.TP +.B \-\-name , \-n +optional resource name +.UNINDENT .SS labgrid\-client add\-alias .sp add an alias to a place