From 47735642f22ec381fade00c6c426c2d448066f5f Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 29 Sep 2026 19:17:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(satellite):=20APK-Rollout=20per=20ADB=20?= =?UTF-8?q?=E2=80=94=20Fire=20TV=20&=20Android=20aus=20der=20Ferne=20bespi?= =?UTF-8?q?elen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Satellit kann Android-Geraete im LAN erkennen und die Agent-APK ausrollen, statt sie von Hand aufs Geraet zu kopieren. - satellite.py: adb.devices/connect/install/shell. _apk_fetch laedt die APK per HTTP (gecached in /data/apk, sha256 optional) statt sie durchs RVS zu schicken β€” der RVS ist ein BROADCAST-Relay (jede base64-APK ginge an alle Clients, und das Satelliten-max_size liegt bei 8 MB). adb.install ist selbstheilend (connect -> install -r) und uebersetzt die typischen Fehler in klare Ansagen (unauthorized, Signatur-Mismatch, Speicher voll). Imports lokal in den Funktionen (Hausmuster). - Discovery: mDNS-Typen _amzn-wplay/_adb-tls-connect/_androidtvremote2, _mdns_kind -> firetv/android-adb/androidtv, plus _enrich_adb (TCP-Probe 5555) -> adbCapable. - Dockerfile: adb; compose: HOME=/data (ADB-Key persistent, sonst fragt das Geraet nach jedem Rebuild neu) + ANDROID_ADB_SERVER_PORT=5038 (Host-Kollision). - .env.example: neue Aktionen + Warnung, dass die Allowlist den Code-Default ueberschreibt (sonst lehnt ein bestehender Satellit adb.* ab). - Brain: adb.* in der satellite_command-Beschreibung; WICHTIG Timeout 200s fuer adb.*/ssh.exec β€” der Bridge-Default von 20s haette jede Installation abgewuergt. - Diagnostic: sat_command (Browser->RVS) und sat_result (RVS->Browser) gab es bisher GAR NICHT; beide ergaenzt. πŸ“¦-Button je ADB-faehigem Geraet + Rollout- Modal mit Status/Log, ADB-Tag in der Geraeteliste. Co-Authored-By: Claude Opus 4.8 --- aria-brain/agent.py | 18 ++- diagnostic/index.html | 80 ++++++++++- diagnostic/server.js | 17 +++ satellite/.env.example | 17 ++- satellite/Dockerfile | 5 +- satellite/docker-compose.yml | 6 + satellite/satellite.py | 265 ++++++++++++++++++++++++++++++++++- 7 files changed, 401 insertions(+), 7 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 8e14b2d..f0408df 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1241,6 +1241,16 @@ META_TOOLS = [ "oder Key). Fuer Geraete mit hinterlegten Zugangsdaten " "(community/v3/Login/SSH) nutzt der Satellit diese automatisch β€” Du musst " "keine Passwoerter mitgeben. " + "ANDROID/FIRE TV ausrollen: 'adb.install' (params={'ip':'', " + "'url':''}) installiert/aktualisiert eine App auf einem Android-" + "Geraet im Netz β€” so kommt der ARIA-Agent auf einen Fire TV Stick, ohne " + "eine Datei von Hand zu kopieren. Voraussetzung: auf dem Geraet ist ADB-" + "Debugging aktiv (Fire TV: Einstellungen > Mein Fire TV > Entwickleroptionen) " + "und der Dialog 'USB-Debugging zulassen?' wurde bestaetigt. Weiter: " + "'adb.devices' (welche Geraete sind verbunden), 'adb.connect' " + "(params={'ip':'...'}) und 'adb.shell' (params={'ip':'...','cmd':'...'}) " + "fuer Diagnose. In der Geraeteliste (satellite_devices) haben ausrollbare " + "Geraete adbCapable=true; Fire TV Sticks type='firetv'. " "'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND " "um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen " "Seiten NICHT blind paginieren: setze params['contains'] (String oder " @@ -2657,10 +2667,16 @@ class Agent: if not sat or not action: return "FEHLER: satellite und action sind Pflicht." params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {} + # Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber + # WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout", + # obwohl die Installation noch laeuft. + slow = action.startswith("adb.") or action == "ssh.exec" + sat_timeout = 200.0 if slow else 20.0 result = _post("/internal/satellite", { "op": "command", "satellite": sat, "device": arguments.get("device") or "", "action": action, "params": params, - }, 30) + "timeout": sat_timeout, + }, sat_timeout + 20) if result.get("ok"): r = result.get("result") return f"OK β€” {r}" if r else "OK β€” Aktion ausgefuehrt." diff --git a/diagnostic/index.html b/diagnostic/index.html index 598961b..af40640 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1594,6 +1594,36 @@ + + + '; }).join('') + ''; } diff --git a/diagnostic/server.js b/diagnostic/server.js index 365d863..6e91f1f 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1098,6 +1098,9 @@ function connectRVS(forcePlain) { } else if (msg.type === "rooms_info") { // Antwort des RVS auf rooms_query β†’ an den Browser (Raum-Diagnose). broadcast({ type: "rooms_info", payload: msg.payload || {} }); + } else if (msg.type === "sat_result") { + // Ergebnis einer Satelliten-Aktion β†’ an den Browser (z.B. APK-Rollout). + broadcast({ type: "sat_result", payload: msg.payload || {} }); } else if (msg.type === "agent_history" || msg.type === "agent_history_list") { // Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list) // der Bridge β†’ an den Browser (Agent-Historie). @@ -2813,6 +2816,20 @@ wss.on("connection", (ws) => { // Browser will die RVS-Raum-Diagnose β€” via persistente rvsWs anfragen, // die Antwort (rooms_info) kommt auf derselben Verbindung zurueck. sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() }); + } else if (msg.action === "sat_command") { + // Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen). + // Die Antwort (sat_result) kommt ueber die rvsWs zurueck. + sendToRVS_raw({ + type: "sat_command", + payload: { + satellite: String(msg.satellite || ""), + device: String(msg.device || ""), + action: String(msg.cmd || ""), + params: msg.params && typeof msg.params === "object" ? msg.params : {}, + requestId: String(msg.requestId || ""), + }, + timestamp: Date.now(), + }); } else if (msg.action === "agent_history_query") { // Browser will die gespeicherte Historie eines Agenten β€” die Bridge // antwortet mit agent_history_list (auf derselben rvsWs). diff --git a/satellite/.env.example b/satellite/.env.example index 1772093..24ca4d3 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -41,7 +41,22 @@ CONTROL_ENABLED=true # fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login) # fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login) # ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh') -CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec +# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url) +# adb.connect ADB-Verbindung aufbauen (params: ip) +# adb.devices verbundene ADB-Geraete auflisten +# adb.shell Shell-Kommando auf dem Android-Geraet (params: ip, cmd) +# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion +# ergaenzt, MUSS sie hier nachgetragen werden β€” sonst lehnt der Satellit sie ab. +CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell + +# ─── ADB / Android-Rollout (optional) ────────────────────────────── +# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren. +# Voraussetzung am Geraet: ADB-Debugging AN (Fire TV: Einstellungen > Mein Fire TV +# > Entwickleroptionen > ADB-Debugging) und den Dialog "USB-Debugging zulassen?" +# einmalig bestaetigen (Haken "Immer erlauben"). +ADB_PORT=5555 +ADB_TIMEOUT_SEC=180 # install dauert 30-60s, grosszuegig lassen +APK_CACHE_DIR=/data/apk # heruntergeladene APKs (1x laden, N Geraete) # ─── Credential-Store (optional) ─────────────────────────────────── # Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/ diff --git a/satellite/Dockerfile b/satellite/Dockerfile index abc0d10..e519140 100644 --- a/satellite/Dockerfile +++ b/satellite/Dockerfile @@ -7,8 +7,11 @@ WORKDIR /app # net-snmp-CLI (snmpget/snmpwalk) fuer die snmp.*-Aktionen β€” z.B. Drucker- # Tintenstaende zuverlaessig aus der Printer-MIB statt HTML zu scrapen. +# adb: Android-Geraete im LAN (v.a. Fire TV Sticks) per Netzwerk-Debugging +# erreichen und APKs ausrollen (adb.*-Aktionen). In Debian bookworm heisst das +# Paket schlicht "adb" (nicht mehr android-tools-adb). RUN apt-get update \ - && apt-get install -y --no-install-recommends snmp \ + && apt-get install -y --no-install-recommends snmp adb \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/satellite/docker-compose.yml b/satellite/docker-compose.yml index fee1c7b..14756e0 100644 --- a/satellite/docker-compose.yml +++ b/satellite/docker-compose.yml @@ -21,6 +21,12 @@ services: network_mode: host env_file: .env restart: unless-stopped + environment: + # ADB-Schluessel in /data persistieren: sonst erzeugt adb nach jedem + # Rebuild einen NEUEN Schluessel und das Fire TV fragt erneut + # "USB-Debugging zulassen?" (Autorisierung geht verloren). + - HOME=/data + - ANDROID_ADB_SERVER_PORT=5038 # 5037 kann auf dem Host belegt sein volumes: # Persistenter Credential-Store (verschluesselt) + Schluesseldatei. - ./data:/data diff --git a/satellite/satellite.py b/satellite/satellite.py index 8c03613..b0b89ba 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -134,10 +134,20 @@ CONTROL_ALLOWLIST = [ a.strip() for a in os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer," - "snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec").split(",") + "snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec," + "adb.devices,adb.connect,adb.install,adb.shell").split(",") if a.strip() ] +# adb.*: Android-Geraete (v.a. Fire TV) im LAN per Netzwerk-Debugging erreichen. +# install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger +# Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN +# Download brauchen. +ADB_PORT = int(os.environ.get("ADB_PORT", "5555") or "5555") +ADB_TIMEOUT_SEC = float(os.environ.get("ADB_TIMEOUT_SEC", "180") or "180") +APK_CACHE_DIR = os.environ.get("APK_CACHE_DIR", "/data/apk") +APK_MAX_BYTES = int(os.environ.get("APK_MAX_BYTES", str(64 * 1024 * 1024)) or str(64 * 1024 * 1024)) + SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300") DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6") DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120") @@ -269,6 +279,9 @@ MDNS_TYPES = [ "_smb._tcp.local.", # NAS / Fileshares "_workstation._tcp.local.", # generische Hosts "_http._tcp.local.", # Web-UIs (Router, NAS, IoT) + "_amzn-wplay._tcp.local.", # Amazon Whisperplay β€” zuverlaessigster Fire-TV-Marker + "_adb-tls-connect._tcp.local.", # Android 11+ Wireless Debugging + "_androidtvremote2._tcp.local.", # Android TV Remote ] CAPABILITIES = ["discover"] @@ -404,6 +417,8 @@ def _mdns_kind(service_type: str) -> str: "_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit", "_printer": "printer", "_ipp": "printer", "_smb": "fileshare", "_workstation": "host", "_http": "web", + "_amzn-wplay": "firetv", "_adb-tls-connect": "android-adb", + "_androidtvremote2": "androidtv", } for k, v in m.items(): if service_type.startswith(k): @@ -565,6 +580,14 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict: return await loop.run_in_executor(None, _do_fritzbox, action, params) if action == "ssh.exec": return await loop.run_in_executor(None, _do_ssh, params) + if action == "adb.devices": + return await loop.run_in_executor(None, _do_adb_devices, params) + if action == "adb.connect": + return await loop.run_in_executor(None, _do_adb_connect, params) + if action == "adb.install": + return await loop.run_in_executor(None, _do_adb_install, params) + if action == "adb.shell": + return await loop.run_in_executor(None, _do_adb_shell, params) return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."} except Exception as exc: return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"} @@ -1046,6 +1069,205 @@ def _do_ssh(params: dict) -> dict: "stderr": err[:4000], **win}} +# ─── ADB: Android-Geraete im LAN (Fire TV Sticks) ─────────────────── + +def _adb_run(args: list, timeout: float = 0) -> tuple: + """Fuehrt adb aus. Gibt (rc, stdout, stderr). Muster wie _snmp_run.""" + import subprocess + try: + r = subprocess.run(["adb"] + args, capture_output=True, text=True, + timeout=timeout or ADB_TIMEOUT_SEC) + return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip() + except FileNotFoundError: + return 127, "", ("adb fehlt im Container β€” 'adb' ins satellite/Dockerfile " + "aufnehmen und neu bauen.") + except subprocess.TimeoutExpired: + return 124, "", f"adb-Timeout nach {timeout or ADB_TIMEOUT_SEC:.0f}s." + + +def _adb_target(params: dict) -> str: + """Ziel als ip:port (Port default 5555).""" + host = _snmp_target(params) + if not host: + return "" + return host if ":" in host else f"{host}:{int(params.get('port') or ADB_PORT)}" + + +def _adb_connect(target: str) -> tuple: + """Verbindet zum Geraet. Gibt (ok, meldung).""" + rc, out, err = _adb_run(["connect", target], timeout=25) + txt = f"{out} {err}".strip() + low = txt.lower() + if "connected to" in low: # connected / already connected + return True, txt + if "unauthorized" in low: + return False, ("Geraet hat die Verbindung nicht autorisiert. Auf dem " + "Bildschirm des Geraets den Dialog 'USB-Debugging zulassen?' " + "bestaetigen (Haken bei 'Immer erlauben').") + if rc == 127 or "adb fehlt" in low: + return False, txt + return False, (txt or "Verbindung fehlgeschlagen") + \ + " β€” ADB-Debugging aktiv? (Fire TV: Einstellungen > Mein Fire TV > " \ + "Entwickleroptionen > ADB-Debugging)" + + +def _adb_state(target: str) -> str: + """'device' | 'unauthorized' | 'offline' | '' (unbekannt).""" + rc, out, _ = _adb_run(["devices"], timeout=20) + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] == target: + return parts[1] + return "" + + +def _do_adb_devices(params: dict) -> dict: + """Listet die dem Satelliten bekannten ADB-Geraete.""" + rc, out, err = _adb_run(["devices", "-l"], timeout=20) + if rc not in (0,): + return {"ok": False, "error": err or "adb devices fehlgeschlagen"} + devices = [] + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2: + devices.append({"target": parts[0], "state": parts[1], + "info": " ".join(parts[2:])}) + return {"ok": True, "result": {"devices": devices, "count": len(devices)}} + + +def _do_adb_connect(params: dict) -> dict: + target = _adb_target(params) + if not target: + return {"ok": False, "error": "ip/host erforderlich."} + ok, msg = _adb_connect(target) + if not ok: + return {"ok": False, "error": msg} + return {"ok": True, "result": {"target": target, "state": _adb_state(target), + "message": msg}} + + +def _do_adb_shell(params: dict) -> dict: + """Shell-Kommando auf dem Android-Geraet (Diagnose, z.B. 'pm list packages').""" + target = _adb_target(params) + cmd = (params.get("cmd") or params.get("command") or "").strip() + if not target: + return {"ok": False, "error": "ip/host erforderlich."} + if not cmd: + return {"ok": False, "error": "cmd erforderlich."} + ok, msg = _adb_connect(target) + if not ok: + return {"ok": False, "error": msg} + rc, out, err = _adb_run(["-s", target, "shell", cmd], timeout=60) + win = _window_text(out, params) + return {"ok": rc == 0, "result": {"target": target, "exit_code": rc, + "stderr": err[:2000], **win}, + **({} if rc == 0 else {"error": err[:500] or f"exit {rc}"})} + + +def _apk_fetch(params: dict) -> tuple: + """Besorgt die APK lokal. Gibt (pfad, fehler). + Quelle: 'url' (HTTP-Pull, gecached in APK_CACHE_DIR) ODER 'apk_base64'. + HTTP-Pull ist der Normalweg β€” der RVS ist ein Broadcast-Relay, eine APK + als base64 ginge an JEDEN Client im Raum.""" + import base64 + import hashlib + import requests + os.makedirs(APK_CACHE_DIR, exist_ok=True) + url = (params.get("url") or "").strip() + b64 = params.get("apk_base64") + want_sha = (params.get("sha256") or "").strip().lower() + + if b64: + try: + data = base64.b64decode(b64) + except Exception as exc: + return "", f"apk_base64 ungueltig: {exc}" + elif url: + digest = hashlib.sha256(url.encode()).hexdigest()[:16] + cached = os.path.join(APK_CACHE_DIR, f"{digest}.apk") + if os.path.isfile(cached) and os.path.getsize(cached) > 0: + return cached, "" + try: + headers = {} + if params.get("token"): + headers["Authorization"] = f"token {params['token']}" + with requests.get(url, stream=True, timeout=60, headers=headers, + verify=not params.get("insecure")) as r: + r.raise_for_status() + total = 0 + tmp = cached + ".part" + with open(tmp, "wb") as f: + for chunk in r.iter_content(65536): + total += len(chunk) + if total > APK_MAX_BYTES: + f.close(); os.remove(tmp) + return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB." + f.write(chunk) + os.replace(tmp, cached) + except Exception as exc: + return "", f"APK-Download fehlgeschlagen: {exc}" + data = None + else: + return "", "url ODER apk_base64 erforderlich." + + if data is not None: + if len(data) > APK_MAX_BYTES: + return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB." + path = os.path.join(APK_CACHE_DIR, + hashlib.sha256(data).hexdigest()[:16] + ".apk") + with open(path, "wb") as f: + f.write(data) + cached = path + + if want_sha: + h = hashlib.sha256() + with open(cached, "rb") as f: + for blk in iter(lambda: f.read(65536), b""): + h.update(blk) + if h.hexdigest().lower() != want_sha: + return "", "sha256 stimmt nicht β€” Download verworfen." + return cached, "" + + +def _do_adb_install(params: dict) -> dict: + """Rollt eine APK auf ein Android-Geraet aus (Fire TV & Co.). + params: {ip|host|device, url|apk_base64, sha256?, port?, downgrade?, timeout?} + Selbstheilend: verbindet, installiert, gibt den Status zurueck.""" + target = _adb_target(params) + if not target: + return {"ok": False, "error": "ip/host erforderlich."} + apk, err = _apk_fetch(params) + if err: + return {"ok": False, "error": err} + + ok, msg = _adb_connect(target) + if not ok: + return {"ok": False, "error": msg} + state = _adb_state(target) + if state and state != "device": + return {"ok": False, "error": f"Geraet im Zustand '{state}' β€” " + "bei 'unauthorized' den Dialog auf dem Bildschirm bestaetigen."} + + args = ["-s", target, "install", "-r"] # -r = Update statt Neuinstallation + if params.get("downgrade"): + args.append("-d") + args.append(apk) + rc, out, err2 = _adb_run(args, timeout=float(params.get("timeout") or ADB_TIMEOUT_SEC)) + txt = f"{out} {err2}".strip() + size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1) + if rc == 0 and "success" in txt.lower(): + return {"ok": True, "result": {"target": target, "apk_mb": size_mb, + "message": f"APK installiert ({size_mb} MB)"}} + low = txt.lower() + if "signatures do not match" in low or "update_incompatible" in low: + return {"ok": False, "error": "Signatur passt nicht zur installierten Version β€” " + "die alte App muss zuerst deinstalliert werden (adb.shell " + "'pm uninstall ')."} + if "insufficient" in low or "no space" in low: + return {"ok": False, "error": "Nicht genug Speicher auf dem Geraet."} + return {"ok": False, "error": (txt or f"Installation fehlgeschlagen (exit {rc})")[:500]} + + def _to_int(s: str): try: return int(str(s).strip()) @@ -1113,6 +1335,13 @@ def _parse_http_headers(text: str) -> dict: return headers +def _looks_amazon(dev: dict) -> bool: + """Fire TV? Amazon-Hersteller oder Modell-Praefix AFT (AFTMM, AFTKA, ...).""" + blob = f"{dev.get('manufacturer', '')} {dev.get('name', '')}".lower() + model = str(dev.get("model", "")).upper() + return "amazon" in blob or "fire tv" in blob or model.startswith("AFT") + + def _xml_tag(xml: str, tag: str) -> str: m = re.search(rf"<{tag}>(.*?)", xml, re.IGNORECASE | re.DOTALL) return m.group(1).strip() if m else "" @@ -1147,10 +1376,12 @@ class Satellite: self._devices = _merge_devices(mdns, ssdp, arp) if SNMP_DISCOVERY: await self._enrich_snmp(self._devices) + await self._enrich_adb(self._devices) self._devices_ts = time.time() n_snmp = sum(1 for d in self._devices if d.get("snmpCapable")) - logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)", - len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp) + n_adb = sum(1 for d in self._devices if d.get("adbCapable")) + logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)", + len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb) finally: self._scanning = False return self._devices @@ -1181,6 +1412,34 @@ class Satellite: await asyncio.gather(*(_one(d) for d in devices)) + async def _enrich_adb(self, devices: list) -> None: + """Prueft parallel, welche Geraete ADB-Netzwerk-Debugging offen haben + (TCP 5555). Damit sieht ARIA/die Diagnostic sofort, auf welche Geraete + (v.a. Fire TV Sticks) sich eine APK ausrollen laesst.""" + sem = asyncio.Semaphore(16) + + async def _one(dev: dict) -> None: + ip = dev.get("ip") or "" + if not ip: + return + async with sem: + try: + fut = asyncio.open_connection(ip, ADB_PORT) + reader, writer = await asyncio.wait_for(fut, timeout=1.5) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + except Exception: + return + dev["adbCapable"] = True + dev["adbPort"] = ADB_PORT + if dev.get("type", "") in ("", "host", "unknown", "dial"): + dev["type"] = "firetv" if _looks_amazon(dev) else "android" + + await asyncio.gather(*(_one(d) for d in devices)) + async def _send(self, message: dict) -> None: if self.ws is None: return