From 6f59ec42870da29b771e3a1165bbfa452bae7140 Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Wed, 8 Jan 2025 12:17:00 +0000 Subject: [PATCH 1/2] Extend Reset protocol to include "mode" Some forms of reset can reset the device into different modes. For example: - Reboot - Reboot into bootloader mode - Reboot into recovery mode Signed-off-by: Sebastian Goscik --- labgrid/protocol/resetprotocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 29fe2a5cfc27d8009e5674da27211792b2561533 Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Wed, 8 Jan 2025 16:01:48 +0000 Subject: [PATCH 2/2] 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 Co-authored-by: David Brown Co-authored-by: freedrikp Co-authored-by: Luke Hackwell --- doc/configuration.rst | 43 ++++++++++ labgrid/driver/__init__.py | 1 + labgrid/driver/adb.py | 149 +++++++++++++++++++++++++++++++++++ labgrid/remote/client.py | 111 +++++++++++++++++++++++++- labgrid/remote/exporter.py | 79 +++++++++++++++++++ labgrid/resource/__init__.py | 1 + labgrid/resource/adb.py | 23 ++++++ man/labgrid-client.1 | 37 +++++++++ 8 files changed, 442 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 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/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