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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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