Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ADB (Android Debug Bridge) Support #1564

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
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:
- 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
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
120 changes: 120 additions & 0 deletions labgrid/driver/adb.py
Original file line number Diff line number Diff line change
@@ -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(
ep1cman marked this conversation as resolved.
Show resolved Hide resolved
["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)
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
107 changes: 105 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,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)

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