feat(satellite): APK-Rollout per ADB — Fire TV & Android aus der Ferne bespielen
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 <noreply@anthropic.com>
This commit is contained in:
+17
-1
@@ -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':'<geraet>', "
|
||||
"'url':'<apk-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."
|
||||
|
||||
+79
-1
@@ -1594,6 +1594,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APK-Rollout-Modal (Agent auf Fire TV / Android-Gerät installieren) -->
|
||||
<div class="modal-overlay" id="apk-modal">
|
||||
<div class="modal-box" style="max-width:620px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="apk-title">ARIA-Agent ausrollen</h3>
|
||||
<button class="modal-close" onclick="closeApkDeploy()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:16px;font-size:13px;color:#E0E0F0;">
|
||||
<div style="color:#8888AA;margin-bottom:10px;">
|
||||
Installiert die Agent-App per ADB über das Netzwerk. Voraussetzung am Gerät:
|
||||
<strong>ADB-Debugging aktiv</strong> (Fire TV: Einstellungen › Mein Fire TV ›
|
||||
Entwickleroptionen) — beim ersten Mal erscheint dort ein Dialog
|
||||
„USB-Debugging zulassen?", den Du bestätigen musst.
|
||||
</div>
|
||||
<label style="display:block;margin-bottom:4px;color:#8888AA;">APK-URL</label>
|
||||
<input id="apk-url" type="text" style="width:100%;padding:6px;background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;color:#E0E0F0;"
|
||||
placeholder="https://…/aria-host-agent-android-agent-v0.0.1.0.apk">
|
||||
<div style="color:#666;font-size:11px;margin-top:4px;">
|
||||
Aus dem Gitea-Release. Der Satellit lädt die Datei selbst (einmal) und
|
||||
installiert sie auf dem Gerät.
|
||||
</div>
|
||||
<div style="margin-top:14px;">
|
||||
<button class="btn" onclick="runApkDeploy()">📦 Jetzt ausrollen</button>
|
||||
<span id="apk-status" style="margin-left:10px;color:#8888AA;"></span>
|
||||
</div>
|
||||
<pre id="apk-log" style="margin-top:12px;background:#0D0D1A;border-radius:6px;padding:10px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#8888AA;font-size:12px;display:none;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent-Historie-Modal (Befehle + Screenshots pro Nachricht) -->
|
||||
<div class="modal-overlay" id="agent-history-modal">
|
||||
<div class="modal-box" style="max-width:760px;">
|
||||
@@ -2199,6 +2229,7 @@
|
||||
}
|
||||
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
|
||||
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
|
||||
if (msg.type === 'agent_history_list') {
|
||||
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
|
||||
renderAgentHistory(msg.payload.events || []);
|
||||
@@ -4680,6 +4711,47 @@
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
|
||||
let apkTarget = null; // {satId, ip, name}
|
||||
let apkRequestId = null;
|
||||
|
||||
function openApkDeploy(satId, ip, name) {
|
||||
apkTarget = { satId, ip, name };
|
||||
document.getElementById('apk-title').textContent = 'ARIA-Agent ausrollen → ' + name;
|
||||
document.getElementById('apk-status').textContent = '';
|
||||
const log = document.getElementById('apk-log');
|
||||
log.style.display = 'none'; log.textContent = '';
|
||||
document.getElementById('apk-modal').classList.add('open');
|
||||
}
|
||||
function closeApkDeploy() {
|
||||
document.getElementById('apk-modal').classList.remove('open');
|
||||
apkTarget = null; apkRequestId = null;
|
||||
}
|
||||
function runApkDeploy() {
|
||||
if (!apkTarget) return;
|
||||
const url = document.getElementById('apk-url').value.trim();
|
||||
if (!url) { document.getElementById('apk-status').textContent = 'APK-URL fehlt'; return; }
|
||||
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
|
||||
document.getElementById('apk-status').textContent = 'läuft … (kann 1–2 Minuten dauern)';
|
||||
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
|
||||
requestId: apkRequestId, params: { ip: apkTarget.ip, url } });
|
||||
}
|
||||
function onSatResult(p) {
|
||||
if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
|
||||
const status = document.getElementById('apk-status');
|
||||
const log = document.getElementById('apk-log');
|
||||
log.style.display = 'block';
|
||||
if (p.ok) {
|
||||
const r = p.result || {};
|
||||
status.innerHTML = '<span style="color:#34C759;">✓ erfolgreich</span>';
|
||||
log.textContent = r.message || JSON.stringify(r, null, 2);
|
||||
} else {
|
||||
status.innerHTML = '<span style="color:#FF6E6E;">✗ fehlgeschlagen</span>';
|
||||
log.textContent = p.error || 'Unbekannter Fehler';
|
||||
}
|
||||
apkRequestId = null;
|
||||
}
|
||||
|
||||
// ── Agent-Historie (Befehle + Screenshots pro Nachricht) ──────────
|
||||
let agentHistoryHost = null;
|
||||
|
||||
@@ -4788,18 +4860,24 @@
|
||||
if (d.model) tags.push(escapeHtml(d.model));
|
||||
if (d.snmp && d.snmp.descr) tags.push(escapeHtml(String(d.snmp.descr).slice(0, 40)));
|
||||
if (d.dialAppUrl) tags.push('DIAL');
|
||||
if (d.adbCapable) tags.push('ADB');
|
||||
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
||||
const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length;
|
||||
const keyBtn = d.ip ? '<button class="btn secondary" title="Zugangsdaten (SNMP / HTTP / FritzBox)" ' +
|
||||
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
|
||||
'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : '';
|
||||
// APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.).
|
||||
const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type));
|
||||
const apkBtn = canAdb ? '<button class="btn secondary" title="ARIA-Agent (APK) auf dieses Gerät ausrollen" ' +
|
||||
'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
|
||||
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : '';
|
||||
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;display:flex;align-items:center;">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||
'</div>' + keyBtn +
|
||||
'</div>' + keyBtn + apkBtn +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
+16
-1
@@ -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/
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
+262
-3
@@ -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 <paket>')."}
|
||||
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}>(.*?)</{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
|
||||
|
||||
Reference in New Issue
Block a user