diff --git a/doc/configuration.rst b/doc/configuration.rst index 4a3ffea66..650d39d06 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: + - serialno (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..eb2975a13 --- /dev/null +++ b/labgrid/driver/adb.py @@ -0,0 +1,120 @@ +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.serialno] + + 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.serialno] + + 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, + check=True, + ) + subprocess.run( + ["adb", "connect", f"{self._host}:{str(self._port)}"], + stdout=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, RemoteADBDevice): + # Clean up TCP adb device once the driver is deactivated + subprocess.run( + ["adb", "disconnect", f"{self._host}:{str(self._port)}"], + 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", *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, check=True) + + @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, check=True) + + # 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, 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 5ab4f0683..4f3162357 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,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 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.serialno] + 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, 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 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}" + + # 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, 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, 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) @@ -2031,9 +2126,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..3b465b332 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, + "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["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..0f748a436 --- /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): + serialno = attr.ib(validator=attr.validators.instance_of(str)) + + +@target_factory.reg_resource +@attr.s(eq=False) +class NetworkADBDevice(NetworkResource): + serialno = 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