droidock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- droidock/__init__.py +50 -0
- droidock/__main__.py +3 -0
- droidock/adb.py +261 -0
- droidock/cli.py +306 -0
- droidock/discovery.py +51 -0
- droidock/display.py +347 -0
- droidock/errors.py +11 -0
- droidock/interactive.py +447 -0
- droidock/interfaces.py +22 -0
- droidock/manager.py +391 -0
- droidock/models.py +226 -0
- droidock/portscan.py +231 -0
- droidock/py.typed +0 -0
- droidock/store.py +97 -0
- droidock/tailscale.py +144 -0
- droidock-0.1.0.dist-info/METADATA +308 -0
- droidock-0.1.0.dist-info/RECORD +19 -0
- droidock-0.1.0.dist-info/WHEEL +4 -0
- droidock-0.1.0.dist-info/entry_points.txt +3 -0
droidock/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Importable Android connection management. Importing this package performs no device I/O."""
|
|
2
|
+
|
|
3
|
+
from .errors import DroidockError, IdentityError
|
|
4
|
+
from .interfaces import Backend, Discovery
|
|
5
|
+
from .manager import ConnectionManager
|
|
6
|
+
from .models import (
|
|
7
|
+
AutoConnectReport,
|
|
8
|
+
ConnectionEvent,
|
|
9
|
+
DeviceRecord,
|
|
10
|
+
Identity,
|
|
11
|
+
PairResult,
|
|
12
|
+
Service,
|
|
13
|
+
ServiceGroup,
|
|
14
|
+
ServiceKind,
|
|
15
|
+
Settings,
|
|
16
|
+
Snapshot,
|
|
17
|
+
Transport,
|
|
18
|
+
group_services,
|
|
19
|
+
)
|
|
20
|
+
from .portscan import AdbPortScanner, PortScanProgress, PortScanStatus, preferred_adb_ports
|
|
21
|
+
from .store import DeviceStore
|
|
22
|
+
from .tailscale import TailscaleClient, TailscalePeer
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AdbPortScanner",
|
|
27
|
+
"DroidockError",
|
|
28
|
+
"AutoConnectReport",
|
|
29
|
+
"Backend",
|
|
30
|
+
"ConnectionEvent",
|
|
31
|
+
"ConnectionManager",
|
|
32
|
+
"DeviceRecord",
|
|
33
|
+
"DeviceStore",
|
|
34
|
+
"Discovery",
|
|
35
|
+
"Identity",
|
|
36
|
+
"IdentityError",
|
|
37
|
+
"PairResult",
|
|
38
|
+
"PortScanProgress",
|
|
39
|
+
"PortScanStatus",
|
|
40
|
+
"Service",
|
|
41
|
+
"ServiceGroup",
|
|
42
|
+
"ServiceKind",
|
|
43
|
+
"Settings",
|
|
44
|
+
"Snapshot",
|
|
45
|
+
"Transport",
|
|
46
|
+
"TailscaleClient",
|
|
47
|
+
"TailscalePeer",
|
|
48
|
+
"group_services",
|
|
49
|
+
"preferred_adb_ports",
|
|
50
|
+
]
|
droidock/__main__.py
ADDED
droidock/adb.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import DroidockError
|
|
13
|
+
from .models import (
|
|
14
|
+
ADB_SERVICE_KINDS,
|
|
15
|
+
Identity,
|
|
16
|
+
PairResult,
|
|
17
|
+
Service,
|
|
18
|
+
Settings,
|
|
19
|
+
Transport,
|
|
20
|
+
normalize_endpoint,
|
|
21
|
+
valid_serial,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_services(text: str) -> list[Service]:
|
|
26
|
+
result: list[Service] = []
|
|
27
|
+
for line in text.splitlines():
|
|
28
|
+
parts = line.split()
|
|
29
|
+
if len(parts) < 3:
|
|
30
|
+
continue
|
|
31
|
+
instance, service_type, address = parts[0], parts[-2].rstrip("."), parts[-1]
|
|
32
|
+
kind = ADB_SERVICE_KINDS.get(service_type)
|
|
33
|
+
if kind is None:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
endpoint = normalize_endpoint(address)
|
|
37
|
+
except DroidockError:
|
|
38
|
+
continue
|
|
39
|
+
result.append(Service(instance, kind, endpoint, "adb"))
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AdbBackend:
|
|
44
|
+
"""ADB adapter with bundled executable discovery and time-limited subprocesses.
|
|
45
|
+
|
|
46
|
+
Existing compatible servers are reused. No operation kills the shared ADB server.
|
|
47
|
+
Pairing secrets go through stdin and are removed from exception messages.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
51
|
+
self.settings = settings or Settings()
|
|
52
|
+
self.settings.validate()
|
|
53
|
+
self._executable: Path | None = None
|
|
54
|
+
self._version = ""
|
|
55
|
+
self._protocol: int | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def executable(self) -> Path:
|
|
59
|
+
if self._executable:
|
|
60
|
+
return self._executable
|
|
61
|
+
override = self.settings.adb_path or os.environ.get("DROIDOCK_ADB_PATH", "")
|
|
62
|
+
if override:
|
|
63
|
+
candidate = Path(override).expanduser()
|
|
64
|
+
if not candidate.is_file():
|
|
65
|
+
raise DroidockError(
|
|
66
|
+
f"The configured ADB executable does not exist: {candidate}", code="adb_missing"
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
bundled = importlib.resources.files("adbutils.binaries").joinpath(
|
|
70
|
+
"adb.exe" if os.name == "nt" else "adb"
|
|
71
|
+
)
|
|
72
|
+
candidate = Path(str(bundled))
|
|
73
|
+
if not candidate.is_file():
|
|
74
|
+
found = shutil.which("adb")
|
|
75
|
+
if not found:
|
|
76
|
+
raise DroidockError(
|
|
77
|
+
"No bundled ADB is available for this OS. Set an ADB executable path in settings.",
|
|
78
|
+
code="adb_missing",
|
|
79
|
+
)
|
|
80
|
+
candidate = Path(found)
|
|
81
|
+
try:
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
[str(candidate), "version"],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
errors="replace",
|
|
88
|
+
timeout=self.settings.command_timeout,
|
|
89
|
+
check=True,
|
|
90
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
91
|
+
)
|
|
92
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
93
|
+
raise DroidockError(f"Cannot run the ADB executable: {candidate}", code="adb_missing") from exc
|
|
94
|
+
match = re.search(r"Android Debug Bridge version 1\.0\.(\d+)", result.stdout)
|
|
95
|
+
if not match:
|
|
96
|
+
raise DroidockError("Cannot read the ADB version response.", code="adb_missing")
|
|
97
|
+
self._protocol = int(match.group(1))
|
|
98
|
+
self._version = result.stdout.strip()
|
|
99
|
+
self._executable = candidate.resolve()
|
|
100
|
+
return self._executable
|
|
101
|
+
|
|
102
|
+
def _check_server(self) -> None:
|
|
103
|
+
try:
|
|
104
|
+
# Windows can take about a second to reject a closed localhost port.
|
|
105
|
+
# A shorter timeout would mistake an absent server for an unresponsive one.
|
|
106
|
+
connection = socket.create_connection(("127.0.0.1", self.settings.server_port), timeout=3)
|
|
107
|
+
except ConnectionRefusedError:
|
|
108
|
+
return
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise DroidockError(f"Cannot reach the ADB server: {exc}", code="server_unavailable") from exc
|
|
111
|
+
try:
|
|
112
|
+
with connection:
|
|
113
|
+
|
|
114
|
+
def receive(count: int) -> bytes:
|
|
115
|
+
data = b""
|
|
116
|
+
while len(data) < count:
|
|
117
|
+
chunk = connection.recv(count - len(data))
|
|
118
|
+
if not chunk:
|
|
119
|
+
raise ValueError("Incomplete server response")
|
|
120
|
+
data += chunk
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
request = b"host:version"
|
|
124
|
+
connection.sendall(f"{len(request):04x}".encode() + request)
|
|
125
|
+
if receive(4) != b"OKAY":
|
|
126
|
+
raise ValueError("The response is not from an ADB server")
|
|
127
|
+
length = int(receive(4), 16)
|
|
128
|
+
if length > 32:
|
|
129
|
+
raise ValueError("Invalid server version response")
|
|
130
|
+
protocol = int(receive(length), 16)
|
|
131
|
+
if protocol != self._protocol:
|
|
132
|
+
raise DroidockError(
|
|
133
|
+
"The running ADB server uses a different protocol version. Select a compatible ADB executable "
|
|
134
|
+
"or a separate server port. The shared server will not be stopped automatically.",
|
|
135
|
+
code="server_conflict",
|
|
136
|
+
)
|
|
137
|
+
except (OSError, ValueError) as exc:
|
|
138
|
+
raise DroidockError(
|
|
139
|
+
f"The ADB server on local port {self.settings.server_port} is not responding correctly.",
|
|
140
|
+
code="server_unavailable",
|
|
141
|
+
) from exc
|
|
142
|
+
|
|
143
|
+
def _run(self, *arguments: str, input_text: str | None = None, timeout: float | None = None) -> str:
|
|
144
|
+
executable = self.executable
|
|
145
|
+
self._check_server()
|
|
146
|
+
environment = os.environ.copy()
|
|
147
|
+
# Explicitly select the checked local server, including when a parent app uses a remote server.
|
|
148
|
+
environment.pop("ADB_SERVER_SOCKET", None)
|
|
149
|
+
environment.pop("ANDROID_ADB_SERVER_ADDRESS", None)
|
|
150
|
+
environment.pop("ANDROID_ADB_SERVER_PORT", None)
|
|
151
|
+
# Even '-H 127.0.0.1' makes ADB refuse to start a missing server as a "remote host".
|
|
152
|
+
# Removing remote-server overrides and using -P selects localhost and permits startup.
|
|
153
|
+
command = [str(executable), "-P", str(self.settings.server_port), *arguments]
|
|
154
|
+
try:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
command,
|
|
157
|
+
input=input_text,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
errors="replace",
|
|
162
|
+
timeout=timeout or self.settings.command_timeout,
|
|
163
|
+
env=environment,
|
|
164
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
165
|
+
check=False,
|
|
166
|
+
)
|
|
167
|
+
except subprocess.TimeoutExpired as exc:
|
|
168
|
+
raise DroidockError("The device did not respond within the timeout.", code="timeout") from exc
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise DroidockError(f"Failed to run ADB: {exc}", code="adb_failed") from exc
|
|
171
|
+
output = "\n".join(x.strip() for x in (result.stdout, result.stderr) if x.strip())
|
|
172
|
+
if input_text:
|
|
173
|
+
output = output.replace(input_text.strip(), "[REDACTED]")
|
|
174
|
+
if result.returncode:
|
|
175
|
+
code = "unauthorized" if "unauthorized" in output.lower() else "adb_failed"
|
|
176
|
+
raise DroidockError(output[-2500:] or "The ADB command failed.", code=code)
|
|
177
|
+
return output
|
|
178
|
+
|
|
179
|
+
def inspect(self, address: str) -> Transport:
|
|
180
|
+
output = self._run("-s", address, "shell", "getprop")
|
|
181
|
+
properties = dict(re.findall(r"^\[([^\]]+)\]: \[(.*)\]$", output, flags=re.MULTILINE))
|
|
182
|
+
serial = properties.get("ro.serialno", "")
|
|
183
|
+
if not valid_serial(serial):
|
|
184
|
+
serial = properties.get("ro.boot.serialno", "")
|
|
185
|
+
if not valid_serial(serial):
|
|
186
|
+
return Transport(address, "device", detail="Cannot determine the device serial number.")
|
|
187
|
+
identity = Identity(
|
|
188
|
+
serial,
|
|
189
|
+
properties.get("ro.product.manufacturer", ""),
|
|
190
|
+
properties.get("ro.product.model", ""),
|
|
191
|
+
properties.get("persist.adb.wifi.guid", ""),
|
|
192
|
+
)
|
|
193
|
+
return Transport(address, "device", identity)
|
|
194
|
+
|
|
195
|
+
def transports(self) -> list[Transport]:
|
|
196
|
+
output = self._run("devices", "-l")
|
|
197
|
+
entries: list[tuple[str, str]] = []
|
|
198
|
+
for line in output.splitlines():
|
|
199
|
+
fields = line.split()
|
|
200
|
+
if len(fields) >= 2 and fields[1] in {
|
|
201
|
+
"device",
|
|
202
|
+
"offline",
|
|
203
|
+
"unauthorized",
|
|
204
|
+
"recovery",
|
|
205
|
+
"sideload",
|
|
206
|
+
}:
|
|
207
|
+
entries.append((fields[0], fields[1]))
|
|
208
|
+
elif len(fields) >= 3 and fields[1:3] == ["no", "permissions"]:
|
|
209
|
+
entries.append((fields[0], "no permissions"))
|
|
210
|
+
|
|
211
|
+
def inspect_entry(entry: tuple[str, str]) -> Transport:
|
|
212
|
+
address, state = entry
|
|
213
|
+
if state != "device":
|
|
214
|
+
return Transport(address, state)
|
|
215
|
+
try:
|
|
216
|
+
return self.inspect(address)
|
|
217
|
+
except DroidockError as exc:
|
|
218
|
+
return Transport(address, "unresponsive", detail=str(exc))
|
|
219
|
+
|
|
220
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
221
|
+
return list(executor.map(inspect_entry, entries))
|
|
222
|
+
|
|
223
|
+
def services(self) -> list[Service]:
|
|
224
|
+
return parse_services(self._run("mdns", "services"))
|
|
225
|
+
|
|
226
|
+
def connect(self, endpoint: str) -> None:
|
|
227
|
+
endpoint = normalize_endpoint(endpoint)
|
|
228
|
+
output = self._run("connect", endpoint)
|
|
229
|
+
if not re.search(r"(?:already )?connected to ", output, re.IGNORECASE):
|
|
230
|
+
raise DroidockError(output or "The connection could not be established.", code="connect_failed")
|
|
231
|
+
|
|
232
|
+
def pair(self, endpoint: str, code: str) -> PairResult:
|
|
233
|
+
endpoint = normalize_endpoint(endpoint)
|
|
234
|
+
if not re.fullmatch(r"\d{6}", code):
|
|
235
|
+
raise DroidockError("Enter the six-digit pairing code shown on the device.", code="invalid_code")
|
|
236
|
+
output = self._run("pair", endpoint, input_text=code + "\n", timeout=30)
|
|
237
|
+
if "successfully paired" not in output.lower():
|
|
238
|
+
raise DroidockError(output or "Pairing failed.", code="pair_failed")
|
|
239
|
+
match = re.search(r"\[guid=([^\]]+)\]", output)
|
|
240
|
+
return PairResult(endpoint, match.group(1) if match else "")
|
|
241
|
+
|
|
242
|
+
def disconnect(self, endpoint: str) -> None:
|
|
243
|
+
if not re.fullmatch(r"adb-[A-Za-z0-9._-]+\._adb-tls-connect\._tcp\.?", endpoint):
|
|
244
|
+
endpoint = normalize_endpoint(endpoint)
|
|
245
|
+
self._run("disconnect", endpoint)
|
|
246
|
+
|
|
247
|
+
def reconnect(self, address: str) -> None:
|
|
248
|
+
self._run("-s", address, "reconnect")
|
|
249
|
+
|
|
250
|
+
def diagnostics(self) -> dict[str, str]:
|
|
251
|
+
result = {
|
|
252
|
+
"executable": str(self.executable),
|
|
253
|
+
"version": self._version,
|
|
254
|
+
"server_port": str(self.settings.server_port),
|
|
255
|
+
}
|
|
256
|
+
for key, args in {"server": ("server-status",), "mdns": ("mdns", "check")}.items():
|
|
257
|
+
try:
|
|
258
|
+
result[key] = self._run(*args)
|
|
259
|
+
except DroidockError as exc:
|
|
260
|
+
result[key] = str(exc)
|
|
261
|
+
return result
|
droidock/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .display import (
|
|
17
|
+
show_auto_connect,
|
|
18
|
+
show_device,
|
|
19
|
+
show_diagnostics,
|
|
20
|
+
show_disconnected,
|
|
21
|
+
show_pairing,
|
|
22
|
+
show_settings,
|
|
23
|
+
show_snapshot,
|
|
24
|
+
)
|
|
25
|
+
from .errors import DroidockError
|
|
26
|
+
from .interactive import InteractiveCli
|
|
27
|
+
from .manager import ConnectionManager
|
|
28
|
+
from .store import DeviceStore
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
add_completion=False,
|
|
32
|
+
help="Save Android device profiles, discover devices, pair, and reconnect automatically.",
|
|
33
|
+
)
|
|
34
|
+
console = Console()
|
|
35
|
+
JsonOutput = Annotated[bool, typer.Option("--json", help="Print structured JSON for scripts.")]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def guarded(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
39
|
+
@functools.wraps(function)
|
|
40
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
41
|
+
try:
|
|
42
|
+
return function(*args, **kwargs)
|
|
43
|
+
except DroidockError as exc:
|
|
44
|
+
if kwargs.get("json_output"):
|
|
45
|
+
typer.echo(json.dumps({"error": {"code": exc.code, "message": str(exc)}}, ensure_ascii=False))
|
|
46
|
+
else:
|
|
47
|
+
Console(stderr=True).print(str(exc), style="red", markup=False)
|
|
48
|
+
raise typer.Exit(1) from exc
|
|
49
|
+
|
|
50
|
+
return wrapper
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def manager(ctx: typer.Context) -> ConnectionManager:
|
|
54
|
+
if ctx.obj is None:
|
|
55
|
+
ctx.obj = ConnectionManager(DeviceStore(ctx.find_root().params.get("data_dir")))
|
|
56
|
+
return ctx.obj
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def emit(value: object) -> None:
|
|
60
|
+
typer.echo(json.dumps(value, ensure_ascii=False, indent=2))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def show_version(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
typer.echo(f"droidock {__version__}")
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback(invoke_without_command=True)
|
|
70
|
+
@guarded
|
|
71
|
+
def root(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
data_dir: Annotated[
|
|
74
|
+
Path | None, typer.Option(help="Directory for saved device profiles.", envvar="DROIDOCK_HOME")
|
|
75
|
+
] = None,
|
|
76
|
+
plain: Annotated[bool, typer.Option(help="Use numbered menus instead of arrow keys.")] = False,
|
|
77
|
+
version: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--version", callback=show_version, is_eager=True, help="Show the version and exit."),
|
|
80
|
+
] = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
if ctx.invoked_subcommand is None:
|
|
83
|
+
InteractiveCli(manager(ctx), plain=plain).run()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
@guarded
|
|
88
|
+
def devices(ctx: typer.Context, json_output: JsonOutput = False) -> None:
|
|
89
|
+
"""List current connections and nearby wireless devices without requesting reconnection."""
|
|
90
|
+
current = manager(ctx)
|
|
91
|
+
snapshot = current.scan()
|
|
92
|
+
if json_output:
|
|
93
|
+
emit(asdict(snapshot))
|
|
94
|
+
else:
|
|
95
|
+
show_snapshot(console, snapshot, current.store.read().default_device)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
@guarded
|
|
100
|
+
def register(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
address: Annotated[
|
|
103
|
+
str, typer.Argument(help="USB serial or wireless address of an already connected device.")
|
|
104
|
+
],
|
|
105
|
+
name: Annotated[str | None, typer.Option(help="Name for the saved device.")] = None,
|
|
106
|
+
json_output: JsonOutput = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Verify a connected device and save its identity on this PC."""
|
|
109
|
+
current = manager(ctx)
|
|
110
|
+
record = current.register(address, name=name)
|
|
111
|
+
if json_output:
|
|
112
|
+
emit(asdict(record))
|
|
113
|
+
else:
|
|
114
|
+
show_device(
|
|
115
|
+
console, record, title="Device registered", default_device=current.store.read().default_device
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
@guarded
|
|
121
|
+
def connect(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
device: Annotated[
|
|
124
|
+
str | None, typer.Argument(help="Saved name, device ID, or serial. Omit to use the default device.")
|
|
125
|
+
] = None,
|
|
126
|
+
endpoint: Annotated[
|
|
127
|
+
str | None, typer.Option(help="Current connection IP:port, separate from the pairing port.")
|
|
128
|
+
] = None,
|
|
129
|
+
name: Annotated[str | None, typer.Option(help="Name to assign when registering a new device.")] = None,
|
|
130
|
+
json_output: JsonOutput = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Connect to the selected device and verify its identity."""
|
|
133
|
+
current = manager(ctx)
|
|
134
|
+
if endpoint:
|
|
135
|
+
expected = current.device(device) if device else None
|
|
136
|
+
record = current.connect_endpoint(endpoint, name=name, expected=expected)
|
|
137
|
+
else:
|
|
138
|
+
if name:
|
|
139
|
+
raise DroidockError("Use --name together with --endpoint to register a device at a new address.")
|
|
140
|
+
record = current.connect(device)
|
|
141
|
+
if json_output:
|
|
142
|
+
emit(asdict(record))
|
|
143
|
+
else:
|
|
144
|
+
show_device(
|
|
145
|
+
console, record, title="Connection verified", default_device=current.store.read().default_device
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
@guarded
|
|
151
|
+
def pair(
|
|
152
|
+
ctx: typer.Context,
|
|
153
|
+
endpoint: Annotated[str | None, typer.Argument(help="IP:port shown on the pairing screen.")] = None,
|
|
154
|
+
code_stdin: Annotated[
|
|
155
|
+
bool, typer.Option(help="Read the six-digit code from standard input instead of a command argument.")
|
|
156
|
+
] = False,
|
|
157
|
+
json_output: JsonOutput = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Pair wirelessly. Omit the address to open the pairing and registration wizard."""
|
|
160
|
+
if json_output and (not endpoint or not code_stdin):
|
|
161
|
+
raise DroidockError("Specify a pairing address and use --code-stdin with --json.")
|
|
162
|
+
if not endpoint:
|
|
163
|
+
if code_stdin:
|
|
164
|
+
raise DroidockError("Specify a pairing address when using --code-stdin.")
|
|
165
|
+
InteractiveCli(manager(ctx)).pair()
|
|
166
|
+
return
|
|
167
|
+
if code_stdin:
|
|
168
|
+
code = sys.stdin.readline().strip()
|
|
169
|
+
else:
|
|
170
|
+
code = typer.prompt("Six-digit pairing code shown on the device", hide_input=True)
|
|
171
|
+
result = manager(ctx).pair(endpoint, code)
|
|
172
|
+
if json_output:
|
|
173
|
+
emit(
|
|
174
|
+
{"status": "paired", **asdict(result), "next": "connect --endpoint <current-connection-IP:port>"}
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
show_pairing(console, result)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command("auto-connect")
|
|
181
|
+
@guarded
|
|
182
|
+
def auto_connect(ctx: typer.Context, json_output: JsonOutput = False) -> None:
|
|
183
|
+
"""Try connecting once to each saved device with automatic connection enabled."""
|
|
184
|
+
current = manager(ctx)
|
|
185
|
+
result = current.auto_connect()
|
|
186
|
+
if json_output:
|
|
187
|
+
emit(asdict(result))
|
|
188
|
+
else:
|
|
189
|
+
show_auto_connect(console, result, current.store.read().devices)
|
|
190
|
+
if result.errors:
|
|
191
|
+
raise typer.Exit(1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
@guarded
|
|
196
|
+
def watch(
|
|
197
|
+
ctx: typer.Context,
|
|
198
|
+
interval: Annotated[float, typer.Option(min=1, help="Discovery interval in seconds.")] = 5,
|
|
199
|
+
once: Annotated[
|
|
200
|
+
bool, typer.Option(help="Run automatic connection once and show the resulting status.")
|
|
201
|
+
] = False,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Discover and reconnect saved devices while running. Press Ctrl+C to stop."""
|
|
204
|
+
current = manager(ctx)
|
|
205
|
+
for snapshot in current.watch(interval=interval):
|
|
206
|
+
show_snapshot(console, snapshot, current.store.read().default_device)
|
|
207
|
+
if once:
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
@guarded
|
|
213
|
+
def diagnose(ctx: typer.Context, json_output: JsonOutput = False) -> None:
|
|
214
|
+
"""Show ADB, server, and device diagnostics with connection guidance."""
|
|
215
|
+
report = manager(ctx).diagnostics()
|
|
216
|
+
if json_output:
|
|
217
|
+
emit(report)
|
|
218
|
+
else:
|
|
219
|
+
show_diagnostics(console, report)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.command()
|
|
223
|
+
@guarded
|
|
224
|
+
def settings(
|
|
225
|
+
ctx: typer.Context,
|
|
226
|
+
key: Annotated[str | None, typer.Argument(help="Setting name. Omit to show current settings.")] = None,
|
|
227
|
+
value: Annotated[str | None, typer.Argument(help="Value to save.")] = None,
|
|
228
|
+
json_output: JsonOutput = False,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""View or update settings. Example: settings auto_connect_on_start false"""
|
|
231
|
+
current = manager(ctx)
|
|
232
|
+
if key is not None:
|
|
233
|
+
values = asdict(current.settings)
|
|
234
|
+
if key not in values or value is None:
|
|
235
|
+
raise DroidockError("Check the setting name and value. Available settings: " + ", ".join(values))
|
|
236
|
+
existing = values[key]
|
|
237
|
+
try:
|
|
238
|
+
if isinstance(existing, bool):
|
|
239
|
+
if value.lower() not in {"true", "false"}:
|
|
240
|
+
raise ValueError
|
|
241
|
+
parsed: object = value.lower() == "true"
|
|
242
|
+
elif isinstance(existing, int):
|
|
243
|
+
parsed = int(value)
|
|
244
|
+
elif isinstance(existing, float):
|
|
245
|
+
parsed = float(value)
|
|
246
|
+
else:
|
|
247
|
+
parsed = "" if value == "auto" else value
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
raise DroidockError("Invalid value format. Use true or false for boolean settings.") from exc
|
|
250
|
+
current.configure(**{key: parsed})
|
|
251
|
+
if json_output:
|
|
252
|
+
emit({"store": str(current.store.path), **asdict(current.settings)})
|
|
253
|
+
else:
|
|
254
|
+
if key is not None:
|
|
255
|
+
console.print("Settings saved.", style="green")
|
|
256
|
+
show_settings(console, current.settings, current.store.path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
@guarded
|
|
261
|
+
def profile(
|
|
262
|
+
ctx: typer.Context,
|
|
263
|
+
device: str,
|
|
264
|
+
name: str | None = None,
|
|
265
|
+
auto_connect: Annotated[bool | None, typer.Option("--auto-connect/--no-auto-connect")] = None,
|
|
266
|
+
default: bool = False,
|
|
267
|
+
json_output: JsonOutput = False,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Update a saved device name, automatic connection preference, or default selection."""
|
|
270
|
+
current = manager(ctx)
|
|
271
|
+
record = current.update_device(device, name=name, auto_connect=auto_connect, default=default)
|
|
272
|
+
if json_output:
|
|
273
|
+
emit(asdict(record))
|
|
274
|
+
else:
|
|
275
|
+
show_device(console, record, default_device=current.store.read().default_device)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
@guarded
|
|
280
|
+
def disconnect(ctx: typer.Context, device: str, json_output: JsonOutput = False) -> None:
|
|
281
|
+
"""Disconnect verified wireless connections for this device and disable automatic connection."""
|
|
282
|
+
count = manager(ctx).disconnect(device)
|
|
283
|
+
if json_output:
|
|
284
|
+
emit({"disconnected": count})
|
|
285
|
+
else:
|
|
286
|
+
show_disconnected(console, count)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command()
|
|
290
|
+
@guarded
|
|
291
|
+
def forget(ctx: typer.Context, device: str, yes: bool = False) -> None:
|
|
292
|
+
"""Delete the saved profile on this PC. Android pairing authorization is retained."""
|
|
293
|
+
if not yes and not typer.confirm("Delete this device profile from the PC?"):
|
|
294
|
+
raise typer.Abort()
|
|
295
|
+
manager(ctx).forget(device)
|
|
296
|
+
typer.echo("Device profile deleted.")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def main() -> None:
|
|
300
|
+
for stream in (sys.stdout, sys.stderr):
|
|
301
|
+
if isinstance(stream, io.TextIOWrapper) and not stream.isatty():
|
|
302
|
+
stream.reconfigure(encoding="utf-8")
|
|
303
|
+
try:
|
|
304
|
+
app()
|
|
305
|
+
except (KeyboardInterrupt, EOFError):
|
|
306
|
+
return
|
droidock/discovery.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf
|
|
7
|
+
|
|
8
|
+
from .models import ADB_SERVICE_KINDS, Service, normalize_endpoint
|
|
9
|
+
|
|
10
|
+
SERVICE_TYPES = {f"{service_type}.local.": kind for service_type, kind in ADB_SERVICE_KINDS.items()}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MdnsDiscovery:
|
|
14
|
+
"""Independent multicast discovery, merged with ADB's results by ConnectionManager."""
|
|
15
|
+
|
|
16
|
+
def discover(self, seconds: float) -> tuple[list[Service], list[str]]:
|
|
17
|
+
if seconds <= 0:
|
|
18
|
+
return [], []
|
|
19
|
+
discovered: set[Service] = set()
|
|
20
|
+
lock = threading.Lock()
|
|
21
|
+
warnings: list[str] = []
|
|
22
|
+
|
|
23
|
+
def changed(
|
|
24
|
+
zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange
|
|
25
|
+
) -> None:
|
|
26
|
+
if state_change is ServiceStateChange.Removed:
|
|
27
|
+
return
|
|
28
|
+
info = zeroconf.get_service_info(service_type, name, timeout=500)
|
|
29
|
+
if not info or not info.port:
|
|
30
|
+
return
|
|
31
|
+
for address in info.parsed_scoped_addresses(IPVersion.All):
|
|
32
|
+
host = f"[{address}]" if ":" in address else address
|
|
33
|
+
item = Service(
|
|
34
|
+
name.removesuffix("." + service_type),
|
|
35
|
+
SERVICE_TYPES[service_type],
|
|
36
|
+
normalize_endpoint(f"{host}:{info.port}"),
|
|
37
|
+
"zeroconf",
|
|
38
|
+
)
|
|
39
|
+
with lock:
|
|
40
|
+
discovered.add(item)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with Zeroconf(ip_version=IPVersion.All) as zeroconf:
|
|
44
|
+
browser = ServiceBrowser(zeroconf, list(SERVICE_TYPES), handlers=[changed])
|
|
45
|
+
try:
|
|
46
|
+
time.sleep(seconds)
|
|
47
|
+
finally:
|
|
48
|
+
browser.cancel()
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
warnings.append(f"Wireless discovery is unavailable: {exc}")
|
|
51
|
+
return sorted(discovered, key=lambda item: (item.kind, item.instance, item.endpoint)), warnings
|