Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions labgrid/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@
LAAUSBGadgetMassStorageDriver, LAAUSBDriver, \
LAAButtonDriver, LAALedDriver, LAATempDriver, LAAWattDriver, \
LAAProviderDriver
from .adb import ADBDriver
149 changes: 149 additions & 0 deletions labgrid/driver/adb.py
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
ep1cman marked this conversation as resolved.
Comment thread
ep1cman marked this conversation as resolved.
[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)}"]
Comment thread
ep1cman marked this conversation as resolved.

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)
2 changes: 1 addition & 1 deletion labgrid/protocol/resetprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

class ResetProtocol(abc.ABC):
@abc.abstractmethod
def reset(self):
def reset(self, mode=None):
raise NotImplementedError
111 changes: 109 additions & 2 deletions labgrid/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down
Loading
Loading