Skip to content

Commit

Permalink
Add support for android devices via ADB
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
ep1cman committed Jan 14, 2025
1 parent a8ca43b commit 892693a
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 2 deletions.
46 changes: 46 additions & 0 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions labgrid/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@
from .deditecrelaisdriver import DeditecRelaisDriver
from .dediprogflashdriver import DediprogFlashDriver
from .httpdigitaloutput import HttpDigitalOutputDriver
from .adb import ADBDriver
113 changes: 113 additions & 0 deletions labgrid/driver/adb.py
Original file line number Diff line number Diff line change
@@ -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)
92 changes: 90 additions & 2 deletions labgrid/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions labgrid/remote/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions labgrid/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@
from .httpdigitalout import HttpDigitalOutput
from .sigrok import SigrokDevice
from .fastboot import AndroidNetFastboot
from .adb import NetworkADBDevice, ADBDevice
Loading

0 comments on commit 892693a

Please sign in to comment.