Compare commits

...
Author SHA1 Message Date
duffyduck ac40afc856 release(agent): bump to 0.0.1.1 2026-09-30 02:18:08 +02:00
duffyduckandClaude Opus 4.8 ff6d474f29 fix: Umbenennen greift nicht — Android lieferte den Intent nicht aus
Android vergleicht beim Activity-Start Komponente/Action/Daten, aber NICHT die
Extras. Ein zweites Provisioning galt damit als 'derselbe Intent': die Activity
wurde nur nach vorn geholt, onCreate/onNewIntent liefen nie — der alte Name blieb
stehen (und kam nach dem Loeschen aus der Liste sofort wieder).

- Satellit: eindeutige Daten-URI (aria://provision/<ms>) je Aufruf -> der Intent
  ist nie 'gleich'. Zusaetzlich wird die am-start-Ausgabe auf 'brought to the
  front'/'not started' geprueft und als Fehler gemeldet statt als Erfolg.
- Android: MainActivity launchMode=singleTop -> erneutes Provisioning landet
  zuverlaessig in onNewIntent (statt neuer Instanz); setIntent() ergaenzt.
Android-Version 0.0.1.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:16:21 +02:00
duffyduckandClaude Opus 4.8 44aef71813 fix: Leerzeichen im Geraetenamen + alte Agenten aus der Liste entfernbar
1) 'adb shell' haengt seine Argumente zu EINER Kommandozeile zusammen, die auf
   dem Geraet nochmal von der Shell zerlegt wird — '-e name Wohnzimmer Fire TV'
   kam als 'Wohnzimmer' an, der Rest fiel weg. Alle Werte werden jetzt mit
   shlex.quote gequotet.
2) Umbenennen erzeugt eine neue hostId, der alte Eintrag blieb fuer immer in der
   Diagnostic stehen (nach 5 min nur rot, nie weg). Neuer ✕-Button je Agent
   (host_forget) mit Rueckfrage; laeuft der Agent noch, meldet er sich von selbst
   wieder — das Entfernen stoppt ihn also nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:07:27 +02:00
duffyduckandClaude Opus 4.8 27381e7038 feat(diagnostic): Geraetename beim Rollout am PC vergeben + 'Nur einrichten'
Fire TV meldet sich in der Netz-Erkennung oft nur mit der Seriennummer
(0d94400fa5b63301) — genau der Name, den man auf dem Fernseher nicht tippen will.

- Namensfeld im Rollout-Dialog (am PC getippt), wird beim Einrichten mitgegeben.
- Vorbelegung filtert technische Namen (Hex-Seriennummern, IPs) raus.
- Neuer Button 'Nur einrichten': setzt Zugang + Namen per adb.provision, OHNE die
  APK neu zu installieren — zum Umbenennen bereits ausgerollter Geraete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:01:55 +02:00
duffyduck 21d389cc26 release(agent): bump to 0.0.1.0 2026-09-30 01:56:49 +02:00
duffyduckandClaude Opus 4.8 eae036a9fc feat: Fern-Einrichtung des Agents per ADB (kein Abtippen am Fire TV)
Am Fire TV gibt es keine Kamera (kein QR) und Host/Token mit der Fernbedienung
einzugeben ist eine Qual. Der Satellit hat aber ohnehin schon eine ADB-Verbindung
— darueber bekommt der Agent seine Zugangsdaten direkt.

- Android: MainActivity.applyProvisioning() liest Intent-Extras (host/port/tls/
  token/name/control/reply_mode), speichert sie, startet den Dienst und zeigt
  'Von ARIA eingerichtet' (Transparenz). Auch via onNewIntent.
- Satellit: adb.provision (am start mit Extras) und adb.status (installiert?
  laeuft? welche Version?). adb.install richtet direkt mit ein, wenn rvs_host/
  rvs_token mitkommen -> ein Klick = installieren + einrichten + online.
- Diagnostic: Checkbox 'Gleich einrichten' (default an) + 'Status pruefen'-Button.
  Der RVS-Zugang wird SERVERSEITIG ergaenzt (params.provision) — das Token steht
  nie im Browser-Formular.
- Allowlists (Code + .env.example) und README ergaenzt, inkl. Sicherheitshinweis.
Android-Version 0.0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:54:38 +02:00
duffyduckandClaude Opus 4.8 621b5525a8 feat(satellite): eigene MAC-Adresse melden + in der Diagnostic anzeigen
NET bekommt ein 'mac'-Feld (_own_mac): liest /sys/class/net des Interfaces, das
zur primaeren IP gehoert — wichtig, weil sonst die Docker-Bridge-MAC (02:42:...)
statt der echten Karte kaeme. Fallback uuid.getnode(). Wird ueber sat_hello.net
mitgeschickt (Server reicht net bereits durch) und in der Satelliten-Zeile hinter
der IP angezeigt; auch im Startup-Log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:41:48 +02:00
duffyduckandClaude Opus 4.8 6da567aee6 docs(satellite): Workaround fuer KeyError 'ContainerConfig' (docker-compose 1.29.2)
Bug der alten Python-Version beim Ersetzen eines Containers mit Volume:
get_container_data_volumes liest image_config['ContainerConfig'], das neuere
Docker-Versionen nicht mehr schreiben. Fix: 'docker compose' (v2) nutzen oder
den alten Container vorher mit 'rm -sf' entfernen (dann greift der create- statt
recreate-Pfad). Trat schon beim frueheren Satelliten-Build auf — jetzt dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:36:44 +02:00
duffyduck 7071614434 release(agent): bump to 0.0.0.9 2026-09-30 01:36:14 +02:00
duffyduckandClaude Opus 4.8 8301342728 feat(satellite): ADB-Rollout wartet auf die Geraete-Bestaetigung (ein Klick genuegt)
Bisher: 1. Klick -> 'unauthorized' (Fehler), am Fire TV bestaetigen, 2. Klick ->
klappt. Jetzt wartet der Satellit nach dem ersten connect bis zu ADB_AUTH_WAIT_SEC
(90s) auf die Autorisierung und installiert danach automatisch weiter — der
Dialog auf dem Fernseher erscheint ja erst DURCH das connect.

- _adb_wait_authorized(): pollt adb devices alle 2s und verbindet neu (nach dem
  Bestaetigen wechselt der Zustand oft erst mit frischem connect auf 'device').
- adb.install/adb.connect nutzen es; scheitert es doch, nennt die Meldung Wartezeit
  und Zustand statt nur 'unauthorized'.
- Brain-Timeout fuer adb.* auf 320s (Warten + Download + Installation).
- Diagnostic: Hinweis erscheint SOFORT beim Klick ('Jetzt auf den Bildschirm des
  Geraets schauen'), nicht erst als Fehler hinterher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:34:18 +02:00
duffyduck 6a8dd662fa release(agent): bump to 0.0.0.8 2026-09-30 01:25:49 +02:00
duffyduck a48d8442b1 docs(android-agent): Fire TV (Overlay/Sprache/D-Pad) + ADB-Rollout per Satellit 2026-09-29 19:18:19 +02:00
duffyduckandClaude Opus 4.8 47735642f2 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>
2026-09-29 19:17:53 +02:00
duffyduckandClaude Opus 4.8 e15f7a5bc0 feat(android-agent): Fire TV als ARIA-Endpunkt — Sprache, Overlay, Ausgabe-Modus
Der Stick wird vom gesteuerten Geraet zum Gespraechspartner:

- VoiceSession: Mikro -> stt_stream_start/stt_audio_chunk(pcm,16k mono s16le,
  6400B)/stt_stream_end (exakt das App-Protokoll, keine Server-Aenderung noetig);
  Antwort via chat(sender=aria) + audio_pcm(base64, 24k) in AudioTrack. AEC/NS an.
  TURN-OWNERSHIP: der RVS broadcastet an alle — es wird nur ausgegeben, was aus der
  EIGENEN Frage entstand (audioRequestId -> messageId), sonst reden Handy und Stick
  gleichzeitig.
- OverlayView: TYPE_APPLICATION_OVERLAY, Einblendung ueber dem laufenden Bild
  (Text + optional Bild), nicht fokussierbar (Fernbedienung bleibt frei).
- Speaker: ARIAs echte Stimme (fertiges Audio) bevorzugt, Geraete-TTS als Fallback,
  Lautstaerke-Steuerung.
- Neue Aktionen: notify (Einblendung/Sprache, mode ueberschreibbar), speak, volume,
  listen/listen_stop.
- Einstellung replyMode (overlay|speech) in der App, pro Anfrage ueberschreibbar.
- Sprech-Taste in der UI (Fire TV: OK-Taste) + Mikro-/Overlay-Berechtigungen.
Version 0.0.1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 19:12:17 +02:00
duffyduckandClaude Opus 4.8 39f908dd4a feat(android-agent): Fire TV / Android TV Support (eine APK fuer Handy + Stick)
Kein zweiter Agent: dieselbe APK laeuft auf Fire TV, Fähigkeiten capability-gated
(wie beim Desktop-Agent).

- Manifest: touchscreen/faketouch/leanback als required=false (sonst gilt die App
  als Touch-Pflicht bzw. laeuft nicht mehr aufs Handy), LEANBACK_LAUNCHER (ohne
  das fehlt die App im Fire-TV-Menue), TV-Banner (Launcher zeigt Banner statt Icon).
- RvsClient: isTv-Erkennung (leanback/television/UiModeManager) -> caps bekommen
  'tv', os meldet 'Fire TV / Android TV'.
- AriaAccessibilityService.globalKey: D-Pad (up/down/left/right/ok) via Fokus-
  Navigation, Medientasten via dispatchMediaKeyEvent, Lautstaerke/Mute via
  AudioManager — deutsche Aliase (hoch/runter/lauter/stumm).
- MainActivity/Layout: auf TV QR-Scan ausgeblendet (keine Kamera), erster Fokus
  gesetzt, Buttons fokussierbar (Fernbedienung).
Version 0.0.0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 18:26:09 +02:00
duffyduckandClaude Opus 4.8 176997a800 feat(host-agent): GUI-Steuerung fuer Desktop (capability-gated)
Desktop-Agenten (Win/Linux/Mac) koennen jetzt bedienen, nicht nur sehen — mit
DENSELBEN Actions/Brain-Tools wie Android (ui_tap/ui_text/ui_key/ui_swipe/
app_launch), ein Werkzeugset fuer Handy und Rechner.

- host_agent.py: _gui_input_method() erkennt X11(xdotool)/Wayland(ydotool)/
  macOS(osascript+cliclick)/Windows(PowerShell). CAPS werden dynamisch erweitert
  -> reiner Terminal-Server (kein DISPLAY) meldet KEINE ui_*-Caps. _do_ui_tap/
  text/key/swipe + _do_app_launch je Methode; info liefert 'gui'.
- Brain: Tool-Beschreibungen decken Desktop ab (Koordinaten aus dem Screenshot,
  kein ui_dump; button/double bei tap; command bei app_launch); _UI_ACTIONS
  reicht die neuen Params durch.
- README: Fähigkeiten/Plattform-Tabelle + Helfer je OS + HiDPI-Hinweis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 14:30:38 +02:00
duffyduck 5b7e205ede release(agent): bump to 0.0.0.7 2026-09-25 13:52:39 +02:00
duffyduckandClaude Opus 4.8 47cc1b7f08 feat(android-agent): Sperre-Erkennung — Steuern nur entsperrt (v0.0.0.7)
Erkenntnis aus dem Live-Test: bei gesperrtem Bildschirm greifen Gesten/Tasten
nicht (Android-Sicherheit), der Agent meldete aber still 'ok' -> Verwirrung
(home,home,home, Screenshot zeigt trotzdem alte App). Fix: KeyguardManager.
isKeyguardLocked() gate fuer Steuer-Aktionen (ui_tap/text/swipe/key/app_launch)
-> klare Meldung 'Geraet ist gesperrt - bitte entsperren'. Sehen (screenshot/
ui_dump/info) bleibt auch gesperrt erlaubt. README-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:44:49 +02:00
duffyduckandClaude Opus 4.8 f4b7148cf9 feat(diagnostic): Agent-Historie pro Agent — Akkordeon je Nachricht
Jede Host-Aktion wird als Historie-Eintrag festgehalten und in der Diagnostic
pro User-Nachricht gruppiert aufklappbar dargestellt (mit Screenshot-Thumbnails).

- Brain: trace_id+msg pro chat(); _dispatch_host als Wrapper um _dispatch_host_inner
  emittiert je Aktion einen Eintrag an POST /internal/agent-history (Datei-Pfad aus
  Screenshot-Text erkannt).
- Bridge: /internal/agent-history persistiert nach /shared/config/agent_history/
  <hostId>.jsonl (Ringpuffer 200) + broadcastet agent_history; agent_history_query
  -> agent_history_list.
- RVS: agent_history/_query/_list in ALLOWED_TYPES (sonst still verworfen).
- Diagnostic-Server: relay browser<->RVS + /uploads/<file> Bild-Endpoint (nur
  /shared/uploads, kein Traversal).
- index.html: 'Historie'-Button je Host, Modal mit Akkordeon (Gruppe=Nachricht,
  aufklappbar, Screenshot-Thumbs), Live-Refresh bei agent_history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:32:40 +02:00
duffyduck 5941be0f21 release(agent): bump to 0.0.0.6 2026-09-24 21:57:38 +02:00
duffyduckandClaude Opus 4.8 2038b676e6 feat(android-agent): Meilenstein 3 — steuern (tap/text/swipe/key/app_launch)
Bedienungshilfe kann jetzt bedienen: canPerformGestures + dispatchGesture (tap/
swipe), ACTION_SET_TEXT (text), performGlobalAction (back/home/recents/notif).
app_launch via getLaunchIntentForPackage (+ Label-Suche, QUERY_ALL_PACKAGES).
caps erweitert. Brain-Tools host_ui_tap/_text/_swipe/_key/host_app_launch ->
generischer _UI_ACTIONS-Dispatch. Tap-Koordinaten = ui_dump-x/y (echte Pixel,
nicht der skalierte Screenshot). Damit voller Ablauf sehen->steuern. Version 0.0.0.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:53:02 +02:00
duffyduck 8b84ee3b9c release(agent): bump to 0.0.0.5 2026-09-24 21:44:05 +02:00
duffyduckandClaude Opus 4.8 c86cd59b5c fix(android-agent): Screenshot — Vordergrund-Dienst-Typ mediaProjection
Geraet (Android 12) meldete beim Projizieren:
'Media projections require a foreground service ...
FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION'. Der Dienst lief als reiner dataSync.
Fix: foregroundServiceType=dataSync|mediaProjection + Permissions
FOREGROUND_SERVICE_MEDIA_PROJECTION/-DATA_SYNC; beim Projektions-Start
startForeground(..., MEDIA_PROJECTION|DATA_SYNC) VOR getMediaProjection().
Normale Starts nutzen explizit nur dataSync (Android-14-konform). Version 0.0.0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:42:23 +02:00
duffyduck 96012dc986 release(agent): bump to 0.0.0.4 2026-09-24 21:16:56 +02:00
duffyduckandClaude Opus 4.8 80d7f62eaa fix(android-agent): Screenshot-Timeout — nie stumm scheitern + gekappte Aufnahme
Timeout hiess: kein host_result kam zurueck. Ursachen adressiert:
1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception
   liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr).
2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite,
   MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger
   Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s.
lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:14:15 +02:00
27 changed files with 2636 additions and 80 deletions
+194 -3
View File
@@ -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 "
@@ -1384,6 +1394,113 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_tap",
"description": (
"Klickt/tippt auf einem Host-Agenten auf eine Bildschirm-Position (x,y). "
"ANDROID: nimm die Koordinaten aus host_ui_dump (echte Pixel) — NICHT aus "
"dem Screenshot (der ist skaliert). DESKTOP (Windows/Linux/Mac): es gibt "
"kein ui_dump; nimm die Koordinaten DIREKT aus dem host_screenshot (voller "
"Auflösung = echte Pixel). button/double optional (Rechtsklick/Doppelklick)."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"x": {"type": "integer", "description": "X (Pixel)."},
"y": {"type": "integer", "description": "Y (Pixel)."},
"button": {"type": "string", "description": "left (Default) | right | middle."},
"double": {"type": "boolean", "description": "Doppelklick."},
},
"required": ["host", "x", "y"],
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_text",
"description": (
"Schreibt Text auf einem Host-Agenten. ANDROID: in das Feld an x/y (aus "
"host_ui_dump, editable=true). DESKTOP: tippt in das AKTUELL fokussierte "
"Feld (x/y optional) — ggf. vorher mit host_ui_tap ins Feld klicken."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"x": {"type": "integer", "description": "X des Feldes (aus ui_dump)."},
"y": {"type": "integer", "description": "Y des Feldes (aus ui_dump)."},
"text": {"type": "string", "description": "Einzugebender Text."},
},
"required": ["host", "x", "y", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_swipe",
"description": (
"Wischt/scrollt auf einem Host-Agenten von (x1,y1) nach (x2,y2). Zum "
"Scrollen nach unten z.B. von weiter unten nach weiter oben wischen."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"x1": {"type": "integer"}, "y1": {"type": "integer"},
"x2": {"type": "integer"}, "y2": {"type": "integer"},
"duration_ms": {"type": "integer", "description": "Dauer in ms (Default 300)."},
},
"required": ["host", "x1", "y1", "x2", "y2"],
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_key",
"description": (
"Drueckt eine Taste auf einem Host-Agenten. ANDROID: back | home | recents "
"| notifications. DESKTOP (Win/Linux/Mac): Tastennamen/Kombis wie Return, "
"Escape, Tab, Delete, Up/Down, ctrl+c, alt+F4 (Linux/xdotool-Syntax)."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"key": {"type": "string",
"description": "Android: back/home/recents/notifications. Desktop: Return, Escape, ctrl+c, …"},
},
"required": ["host", "key"],
},
},
},
{
"type": "function",
"function": {
"name": "host_app_launch",
"description": (
"Startet eine App auf einem Host-Agenten. ANDROID: per Paketname (package, "
"z.B. 'com.google.android.gm') ODER Namens-Suche (query). DESKTOP "
"(Win/Linux/Mac): per App-Name (query, z.B. 'firefox'/'Safari') ODER "
"beliebigem Startbefehl (command). Danach mit host_screenshot weiterarbeiten."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"package": {"type": "string", "description": "Android-Paketname (optional)."},
"query": {"type": "string", "description": "App-Name/Teilstring (optional)."},
"command": {"type": "string", "description": "Desktop: Startbefehl (optional)."},
},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
@@ -2099,6 +2216,11 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Trace fuer die Agent-Historie (Diagnostic gruppiert Host-Aktionen pro
# dieser einen User-Nachricht). Best-effort, nur Anzeige.
self._trace_id = os.urandom(6).hex()
self._trace_msg = user_message[:160]
# Projekt-Kontext pro Request statt aus globalem State
active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
@@ -2545,10 +2667,18 @@ 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.
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
# Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec" 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."
@@ -2557,8 +2687,49 @@ class Agent:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
'was wurde pro Nachricht auf diesem Agent gemacht')."""
text = self._dispatch_host_inner(name, arguments)
try:
self._emit_agent_history(name, arguments, text)
except Exception:
pass
return text
def _emit_agent_history(self, name: str, arguments: dict, text: str) -> None:
"""Best-effort: ein Historie-Eintrag pro Host-Aktion an die Bridge."""
if name == "host_list":
return
host = (arguments.get("host") or "").strip()
if not host:
return
ok = not text.lstrip().upper().startswith("FEHLER")
m = re.search(r"/shared/uploads/\S+?\.(?:png|jpg|jpeg|webp)", text)
body = {
"host": host,
"trace_id": getattr(self, "_trace_id", ""),
"msg": getattr(self, "_trace_msg", ""),
"tool": name,
"action": name[len("host_"):] if name.startswith("host_") else name,
"args": {k: v for k, v in arguments.items() if k != "host"},
"ok": ok,
"summary": (text.strip().splitlines()[0] if text.strip() else "")[:200],
"file": m.group(0) if m else None,
"ts": int(time.time() * 1000),
}
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}/internal/agent-history",
data=data, method="POST",
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5).close()
except Exception:
pass
def _dispatch_host_inner(self, name: str, arguments: dict) -> str:
"""host_list / host_exec / host_read / host_write / host_info /
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
host_screenshot / host_ui_* — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict:
@@ -2686,6 +2857,24 @@ class Agent:
+ json.dumps(nodes, ensure_ascii=False, indent=2)
)
# ── Steuern (Meilenstein 3) ──────────────────────────────
_UI_ACTIONS = {
"host_ui_tap": ("ui_tap", ["x", "y", "button", "double"]),
"host_ui_text": ("ui_text", ["x", "y", "text"]),
"host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
"host_ui_key": ("ui_key", ["key"]),
"host_app_launch": ("app_launch", ["package", "query", "command"]),
}
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
result = _post("/internal/host",
{"host": host, "action": action, "params": params}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
return f"OK ({host}): {r.get('message', 'ausgefuehrt')}"
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
@@ -3483,7 +3672,9 @@ class Agent:
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump"):
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
"host_ui_key", "host_app_launch"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+67
View File
@@ -3589,6 +3589,17 @@ class ARIABridge:
self._hosts[hid]["last_seen"] = time.time()
return
elif msg_type == "agent_history_query":
# Diagnostic fragt die gespeicherte Historie eines Agenten ab.
hid = (payload.get("host") or "").strip()
events = self._agent_history_read(hid) if hid else []
await self._send_to_rvs({
"type": "agent_history_list",
"payload": {"host": hid, "events": events},
"timestamp": int(time.time() * 1000),
})
return
elif msg_type == "worker_hello":
iid = (payload.get("instanceId") or "").strip()
if iid:
@@ -4580,6 +4591,17 @@ class ARIABridge:
timeout=float(data.get("timeout") or 60.0),
)
await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/agent-history":
# Brain: ein Historie-Eintrag pro Host-Aktion (fuer die Diagnostic).
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
self._agent_history_append(data)
await self._send_to_rvs({"type": "agent_history", "payload": data,
"timestamp": int(time.time() * 1000)})
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4929,6 +4951,51 @@ class ARIABridge:
"online": (now - h.get("last_seen", 0)) < 300,
} for h in self._hosts.values()]
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
_AGENT_HISTORY_MAX = 200
def _agent_history_path(self, host: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
return os.path.join(self._AGENT_HISTORY_DIR, f"{safe}.jsonl")
def _agent_history_append(self, entry: dict) -> None:
"""Haengt einen Historie-Eintrag an <hostId>.jsonl (Ringpuffer)."""
host = (entry.get("host") or "").strip()
if not host:
return
try:
os.makedirs(self._AGENT_HISTORY_DIR, exist_ok=True)
path = self._agent_history_path(host)
lines = []
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
lines.append(json.dumps(entry, ensure_ascii=False))
lines = lines[-self._AGENT_HISTORY_MAX:]
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
except Exception as exc:
logger.warning("[history] append fehlgeschlagen: %s", exc)
def _agent_history_read(self, host: str, limit: int = 200) -> list:
try:
path = self._agent_history_path(host)
if not os.path.exists(path):
return []
out = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
pass
return out[-limit:]
except Exception:
return []
async def _host_request(self, host: str = "", action: str = "",
params: Optional[dict] = None,
timeout: float = 60.0) -> dict:
+280 -3
View File
@@ -1594,6 +1594,71 @@
</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()">&times;</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 <strong>ersten Mal</strong> erscheint dort
„USB-Debugging zulassen?" — einfach bestätigen, während der Rollout läuft:
er wartet darauf und macht dann automatisch weiter.
</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>
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;">
<input type="checkbox" id="apk-provision" checked>
<span>Gleich einrichten (RVS-Zugang übertragen)</span>
</label>
<div style="color:#666;font-size:11px;margin-left:26px;">
Spart das Abtippen auf dem Fernseher: Host und Token gehen direkt vom
Server an den Agenten — QR-Scan geht am Fire TV ja nicht.
</div>
<div style="margin-top:10px;margin-left:26px;">
<label style="display:block;margin-bottom:4px;color:#8888AA;">Name des Geräts</label>
<input id="apk-name" type="text" style="width:100%;padding:6px;background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;color:#E0E0F0;"
placeholder="z.B. Wohnzimmer Fire TV">
<div style="color:#666;font-size:11px;margin-top:4px;">
So spricht ARIA das Gerät an („mach einen Screenshot vom Wohnzimmer Fire TV").
Hier am PC eingeben — auf dem Fernseher wäre das Tippen mühsam.
</div>
</div>
<div style="margin-top:14px;">
<button class="btn" onclick="runApkDeploy()">📦 Jetzt ausrollen</button>
<button class="btn secondary" onclick="runApkProvision()" style="margin-left:6px;"
title="Nur Zugang/Namen setzen — App bleibt wie sie ist">Nur einrichten</button>
<button class="btn secondary" onclick="runApkStatus()" style="margin-left:6px;">Status prüfen</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;">
<div class="modal-header">
<h3 id="agent-history-title">Historie</h3>
<button class="modal-close" onclick="closeAgentHistory()">&times;</button>
</div>
<div style="padding:6px 16px;color:#8888AA;font-size:11px;">
Jede Zeile = eine Anfrage an ARIA. Aufklappen zeigt, was auf diesem Agenten dafür gemacht wurde.
</div>
<div class="modal-body" id="agent-history-body" style="padding:10px 16px 16px;max-height:70vh;overflow-y:auto;"></div>
</div>
</div>
<!-- Memory-Modal (Neu + Editieren) -->
<div class="modal-overlay" id="memory-modal">
<div class="modal-box" style="max-width:640px;">
@@ -2185,6 +2250,20 @@
}
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 || []);
}
return;
}
if (msg.type === 'agent_history') {
// Live: wenn das Modal fuer diesen Host offen ist, neu abfragen (einfach + robust).
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
send({ action: 'agent_history_query', host: agentHistoryHost });
}
return;
}
if (msg.type === 'sat_creds_list_result') {
const p = msg.payload || msg;
const sat = p.satellite || '';
@@ -4643,13 +4722,204 @@
const caps = (h.caps || []).join(', ') || '—';
const ctrl = h.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#FF6E6E;">CONTROL_ENABLED=false</span>';
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<div style="display:flex;align-items:center;gap:8px;">' +
'<div style="flex:1;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
'<button onclick="openAgentHistory(\'' + escapeHtml(h.hostId) + '\')" style="background:#12121E;color:#FFD60A;border:1px solid #FFD60A44;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">🕘 Historie</button>' +
'<button onclick="forgetHost(\'' + escapeHtml(h.hostId) + '\',\'' + escapeHtml(h.name || h.hostId) + '\')" title="Aus der Liste entfernen (z.B. alter Name nach dem Umbenennen). Meldet sich der Agent wieder, erscheint er von selbst."' +
' style="background:#12121E;color:#8888AA;border:1px solid #1E1E2E;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer;margin-left:4px;">✕</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
'</div>';
}).join('');
}
/** Entfernt einen Agenten aus der Liste (z.B. den alten Namen nach dem
* Umbenennen). Läuft er noch, meldet er sich von selbst wieder. */
function forgetHost(hostId, name) {
if (!confirm('"' + name + '" aus der Liste entfernen?\n\n' +
'Das stoppt den Agenten NICHT — läuft er noch, erscheint er ' +
'beim nächsten Lebenszeichen wieder.')) return;
send({ action: 'host_forget', hostId });
}
// ── 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 = '';
// Namensfeld vorbelegen — aber nicht mit Seriennummern/IP-Platzhaltern,
// die die Netz-Erkennung bei Fire TV oft liefert (z.B. 0d94400fa5b63301).
const looksTechnical = !name || /^[0-9a-f]{8,}$/i.test(name) ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name);
document.getElementById('apk-name').value = looksTechnical ? '' : name;
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').innerHTML =
'<span style="color:#FFD60A;">läuft …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.innerHTML = '👀 <strong style="color:#FFD60A;">Jetzt auf den Bildschirm des Geräts schauen.</strong>\n' +
'Beim ersten Mal erscheint dort „USB-Debugging zulassen?" — bestätigen\n' +
'(Haken bei „Immer erlauben"). Ich warte bis zu 90 Sekunden darauf und\n' +
'installiere dann automatisch weiter. Kein zweiter Klick nötig.\n\n' +
'Danach: APK laden + installieren, zusammen 1–3 Minuten.';
const params = { ip: apkTarget.ip, url };
// Server ergaenzt Host/Port/Token — steht bewusst NICHT im Browser-Formular.
if (document.getElementById('apk-provision').checked) {
params.provision = true;
// Am PC getippter Name gewinnt; sonst der aus der Netz-Erkennung (der
// ist bei Fire TV oft nur die Seriennummer).
params.name = document.getElementById('apk-name').value.trim() || apkTarget.name;
}
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
requestId: apkRequestId, params });
}
/** Nur einrichten: RVS-Zugang + Namen setzen, ohne die APK neu zu installieren
* (z.B. um ein bereits ausgerolltes Gerät umzubenennen). */
function runApkProvision() {
if (!apkTarget) return;
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').innerHTML = '<span style="color:#FFD60A;">richte ein …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.textContent = 'Übertrage Zugang und Namen an den Agenten …\n' +
'Die App startet dabei kurz auf dem Bildschirm — das ist normal.';
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.provision',
requestId: apkRequestId,
params: { ip: apkTarget.ip, provision: true,
name: document.getElementById('apk-name').value.trim() || apkTarget.name } });
}
/** Prüft, ob der Agent auf dem Gerät installiert ist und läuft. */
function runApkStatus() {
if (!apkTarget) return;
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').innerHTML = '<span style="color:#FFD60A;">prüfe …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.textContent = 'Frage das Gerät über ADB ab …';
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.status',
requestId: apkRequestId, params: { ip: apkTarget.ip } });
}
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>';
let txt = r.message || JSON.stringify(r, null, 2);
if (typeof r.installed !== 'undefined') { // adb.status
txt = (r.running ? '🟢 Agent läuft' : r.installed ? '🟡 installiert, läuft nicht' : '🔴 nicht installiert') +
(r.version ? '\nVersion: ' + r.version : '') +
'\nPaket: ' + (r.package || '') + '\nGerät: ' + (r.target || '');
if (!r.running && r.installed) {
txt += '\n\nTipp: Auf dem Gerät die App öffnen und „Speichern & Verbinden" —' +
'\noder hier „Gleich einrichten" nutzen, dann startet sie von selbst.';
}
} else if (r.provisioned === false) {
status.innerHTML = '<span style="color:#FF9500;">⚠ installiert, Einrichtung offen</span>';
}
log.textContent = txt;
} 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;
function openAgentHistory(hostId) {
const h = (hosts || []).find(x => x.hostId === hostId);
agentHistoryHost = hostId;
document.getElementById('agent-history-title').textContent =
'Historie: ' + ((h && (h.name || h.hostId)) || hostId);
document.getElementById('agent-history-body').innerHTML =
'<span style="color:#8888AA;">lade …</span>';
document.getElementById('agent-history-modal').classList.add('open');
send({ action: 'agent_history_query', host: hostId });
}
function closeAgentHistory() {
document.getElementById('agent-history-modal').classList.remove('open');
agentHistoryHost = null;
}
function toggleHistGroup(gi) {
const el = document.getElementById('hist-group-' + gi);
const caret = document.getElementById('hist-caret-' + gi);
if (!el) return;
const open = el.style.display !== 'none';
el.style.display = open ? 'none' : 'block';
if (caret) caret.textContent = open ? '▶' : '▼';
}
function renderAgentHistory(events) {
const body = document.getElementById('agent-history-body');
if (!body) return;
if (!events || !events.length) {
body.innerHTML = '<span style="color:#8888AA;">Noch keine Aktionen für diesen Agenten aufgezeichnet. ' +
'Sobald ARIA etwas auf ihm tut (Screenshot, tippen, …), erscheint es hier.</span>';
return;
}
// Nach trace_id gruppieren (eine Gruppe = eine User-Nachricht), Reihenfolge erhalten.
const groups = [], idx = {};
events.forEach(ev => {
const key = ev.trace_id || ('_' + ev.ts);
if (!(key in idx)) { idx[key] = groups.length; groups.push({ msg: ev.msg || '', ts: ev.ts, items: [] }); }
groups[idx[key]].items.push(ev);
});
groups.reverse(); // neueste Nachricht oben
body.innerHTML = groups.map((g, gi) => {
const t = new Date(g.ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const bad = g.items.some(e => e.ok === false);
const dot = bad ? '#FF9500' : '#34C759';
const head = '<div onclick="toggleHistGroup(' + gi + ')" style="cursor:pointer;padding:8px 10px;background:#12121E;border-radius:6px;display:flex;align-items:center;gap:8px;">' +
'<span id="hist-caret-' + gi + '" style="color:#8888AA;">' + (gi === 0 ? '▼' : '▶') + '</span>' +
'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';"></span>' +
'<span style="color:#666;font-size:11px;">' + t + '</span>' +
'<span style="color:#E0E0F0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(g.msg || '(ohne Text)') + '</span>' +
'<span style="color:#8888AA;font-size:11px;white-space:nowrap;">' + g.items.length + ' Aktion(en)</span></div>';
const rows = g.items.map(e => {
const good = e.ok !== false;
const ic = good ? '✓' : '✗';
const icColor = good ? '#34C759' : '#FF6E6E';
const args = (e.args && Object.keys(e.args).length)
? ' <span style="color:#666;font-size:11px;">' + escapeHtml(JSON.stringify(e.args)) + '</span>' : '';
let thumb = '';
if (e.file) {
const base = String(e.file).split('/').pop();
thumb = '<div style="margin-top:5px;"><img src="/uploads/' + encodeURIComponent(base) +
'" loading="lazy" style="max-width:180px;max-height:320px;border:1px solid #1E1E2E;border-radius:6px;cursor:pointer;" ' +
'onclick="window.open(this.src)" onerror="this.style.display=\'none\'"></div>';
}
return '<div style="padding:6px 10px 6px 26px;border-bottom:1px solid #15151F;">' +
'<span style="color:' + icColor + ';">' + ic + '</span> ' +
'<code style="color:#FFD60A;">' + escapeHtml(e.action || e.tool || '?') + '</code>' + args +
(e.summary ? '<div style="color:#8888AA;font-size:11px;margin-top:2px;">' + escapeHtml(e.summary) + '</div>' : '') +
thumb + '</div>';
}).join('');
return '<div style="margin-bottom:6px;">' + head +
'<div id="hist-group-' + gi + '" style="display:' + (gi === 0 ? 'block' : 'none') + ';">' + rows + '</div></div>';
}).join('');
}
function scanSatellite(id) {
satScanning = id;
renderSatellites();
@@ -4683,18 +4953,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>';
}
@@ -4710,7 +4986,8 @@
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') +
(s.net && s.net.mac ? ' · MAC: <span style="font-family:monospace;">' + escapeHtml(s.net.mac) + '</span>' : '') + '</div>' +
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
devHtml +
'</div>';
+58
View File
@@ -1098,6 +1098,13 @@ 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).
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
@@ -1944,6 +1951,19 @@ const server = http.createServer((req, res) => {
"Expires": "0",
});
res.end(fs.readFileSync(htmlPath, "utf-8"));
} else if (req.url.startsWith("/uploads/")) {
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
const file = path.join("/shared/uploads", base);
if (!file.startsWith("/shared/uploads/") || !fs.existsSync(file)) {
res.writeHead(404); res.end("not found"); return;
}
const ext = path.extname(file).toLowerCase();
const ct = ext === ".png" ? "image/png"
: (ext === ".jpg" || ext === ".jpeg") ? "image/jpeg"
: ext === ".webp" ? "image/webp" : "application/octet-stream";
res.writeHead(200, { "Content-Type": ct, "Cache-Control": "max-age=86400" });
res.end(fs.readFileSync(file));
} else if (req.url === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
@@ -2792,10 +2812,48 @@ wss.on("connection", (ws) => {
} else if (msg.action === "host_list") {
// Browser will die aktuelle Host-Agenten-Liste.
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
} else if (msg.action === "host_forget") {
// Alten/umbenannten Agenten aus der Liste werfen. Meldet er sich wieder
// (host_hello), taucht er automatisch neu auf — das ist also gefahrlos.
const id = String(msg.hostId || "");
if (id && hosts.delete(id)) {
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
broadcastHosts();
}
} else if (msg.action === "rooms_query") {
// 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.
const params = msg.params && typeof msg.params === "object" ? msg.params : {};
// Fern-Einrichtung: der RVS-Zugang wird SERVERSEITIG ergaenzt — so muss
// niemand Host/Token im Browser eintippen (und das Token steht nicht im
// Browser-Formular). Gilt fuer adb.provision und adb.install mit Setup.
if (params.provision === true) {
delete params.provision;
params.rvs_host = RVS_HOST;
params.rvs_port = Number(RVS_PORT) || 443;
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
sendToRVS_raw({
type: "sat_command",
payload: {
satellite: String(msg.satellite || ""),
device: String(msg.device || ""),
action: String(msg.cmd || ""),
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).
sendToRVS_raw({ type: "agent_history_query",
payload: { host: String(msg.host || "") }, timestamp: Date.now() });
} else if (msg.action === "worker_list") {
// Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
+27 -8
View File
@@ -15,22 +15,41 @@ Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
| `read` | Datei lesen (Base64, mit Offset/Limit) |
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP, `gui`-Methode |
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
| `ui_tap` | Mausklick auf x,y (button/double optional) *(nur mit GUI)* |
| `ui_text` | Text ins fokussierte Feld tippen *(nur mit GUI)* |
| `ui_key` | Taste/Kombi (Return, Escape, ctrl+c …) *(nur mit GUI)* |
| `ui_swipe` | Maus ziehen x1,y1 → x2,y2 *(nur mit GUI)* |
| `app_launch` | App/Programm starten (`app`/`query` oder `command`) *(nur mit GUI)* |
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`.
ARIA nutzt diese über die Brain-Tools `host_list`/`host_exec`/`host_read`/
`host_write`/`host_info`/`host_screenshot` sowie `host_ui_tap`/`host_ui_text`/
`host_ui_key`/`host_ui_swipe`/`host_app_launch` (dieselben Tools wie beim
Android-Agenten — ein Werkzeugset für Handy **und** Rechner).
> **GUI-Steuerung ist capability-gated.** Der Agent meldet die `ui_*`/`app_launch`-
> Fähigkeiten in seinen `caps` nur, wenn eine grafische Session da ist — ein reiner
> Terminal-/Server-Linux (kein `DISPLAY`/`WAYLAND_DISPLAY`) bekommt sie **nicht**
> (dort bleibt es bei `exec` & Co.). ARIA arbeitet mit dem **Screenshot** als Augen
> (voller Auflösung = echte Pixel; auf dem Desktop gibt es kein `ui_dump`).
>
> Nötige Helfer je System: **Linux/X11** → `xdotool`; **Linux/Wayland** → `ydotool`
> (Daemon, meist root); **macOS** → Text/Tasten via Bordmittel (`osascript`), Maus
> via `cliclick` (`brew install cliclick`); **Windows** → Bordmittel (PowerShell).
> HiDPI/Retina-Hinweis: Screenshot-Pixel können 2× der Klick-Koordinaten sein — bei
> Fehlklicks Koordinaten halbieren.
## Plattformen
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
Screenshot-Methode und Root/Admin-Check je OS automatisch):
| | exec | Root/Admin | Screenshot |
|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) |
| | exec | Root/Admin | Screenshot | GUI-Steuerung (`ui_*`) |
|---|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) | `xdotool` (X11) · `ydotool` (Wayland) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) | `osascript` (Text/Tasten) · `cliclick` (Maus) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) | PowerShell (Bordmittel) |
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
+82 -6
View File
@@ -46,6 +46,78 @@ ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_du
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
### Fire TV / Android TV
**Dieselbe APK** läuft auf Handy und Stick — die TV-Fähigkeiten schalten sich
automatisch frei (`caps` enthalten dann `tv`). Zusätzlich auf dem TV:
| Action | Was |
|---|---|
| `notify` | **Einblendung über dem laufenden Bild** (Text/Bild) — ohne Netflix & Co. zu stoppen; oder als Sprache, je nach Einstellung (`mode` überschreibt) |
| `speak` | Sprachausgabe über die TV-Lautsprecher (ARIAs echte Stimme, sonst Geräte-TTS) |
| `volume` | Lautstärke setzen (`percent`) |
| `listen` / `listen_stop` | Mikrofon öffnen/schließen — ARIA-Sprachdialog auf dem Gerät |
| `ui_key` | zusätzlich D-Pad: `up/down/left/right/ok`, `play_pause/next/prev`, `lauter/leiser/stumm` |
**Sprachdialog:** Die App spricht das gleiche Protokoll wie die ARIA-App
(`stt_stream_*` rein, `chat` + `audio_pcm` raus) — **serverseitig ist nichts
anzupassen**. In den App-Einstellungen wählst Du, wie ARIA hier antwortet:
**Einblendung** (Standard) oder **Sprache**; sie kann es pro Anfrage überschreiben.
> **Turn-Ownership:** Der RVS verteilt jede Antwort an *alle* Clients im Raum.
> Damit nicht Handy **und** Stick gleichzeitig losreden, gibt dieses Gerät nur
> aus, was aus **seiner eigenen** Frage entstanden ist.
> **Mikrofon der Fire-TV-Fernbedienung:** Die Mikro-Taste ist systemseitig mit
> Alexa belegt; ob Fremd-Apps an das Remote-Mikro kommen, ist je nach Stick-
> Generation unterschiedlich und **muss am Gerät ausprobiert werden**. Sicher
> funktioniert der **Sprech-Knopf in der App** (OK-Taste) — und falls das Remote-
> Mikro nicht hergibt, tut es ein USB-Mikro am Stick.
Berechtigungen einmalig in der App: **„Einblendung erlauben"** (Über anderen Apps
anzeigen) und beim ersten Sprechen das **Mikrofon**.
### Ausrollen per Satellit (ohne Datei-Kopieren)
Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per ADB
über das Netz installieren:
1. Am Fire TV: **Einstellungen › Mein Fire TV › Entwickleroptionen › ADB-Debugging** an.
2. Diagnostic → Satelliten → scannen. ADB-fähige Geräte zeigen ein **ADB**-Tag
und einen **📦**-Button.
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
erscheint auf dem TV „USB-Debugging zulassen?" → bestätigen (Haken „Immer erlauben").
Der Rollout **wartet darauf** und macht dann automatisch weiter.
**Einrichtung ohne Tippen:** Am Fire TV gibt es keine Kamera (kein QR-Scan) und
Host/Token mit der Fernbedienung einzugeben ist eine Qual. Deshalb ist im Rollout-
Dialog **„Gleich einrichten"** vorausgewählt: die Diagnostic kennt den RVS-Zugang
selbst und gibt ihn dem Agenten über dieselbe ADB-Verbindung mit
(`adb.provision` → `am start` mit Intent-Extras). Der Agent speichert die Daten,
startet den Dienst und ist **sofort online** — null Eingaben am Fernseher.
Das Token geht dabei **serverseitig** raus, es steht nie im Browser-Formular.
Mit **„Status prüfen"** (`adb.status`) siehst Du jederzeit, ob der Agent auf dem
Gerät installiert ist, welche Version läuft und ob der Dienst aktiv ist.
> **Sicherheitshinweis:** Die Einrichtung per Intent geht an die (exportierte)
> Haupt-Activity. Wer ADB-Zugriff auf das Gerät hat, kann den Agenten damit
> umkonfigurieren — wer ADB hat, kontrolliert das Gerät ohnehin. Die App zeigt
> beim Empfang einen Hinweis („Von ARIA eingerichtet"), damit es nicht unbemerkt
> passiert. Auf einem dedizierten Stick ist das unkritisch; schalte ADB-Debugging
> nach dem Ausrollen ab, wenn Du ganz sicher gehen willst.
ARIA kann dasselbe: `satellite_command` mit `action='adb.install'`,
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
auf beliebig viele Sticks installieren.
> **⚠️ Gerät muss ENTSPERRT sein, damit ARIA es steuern kann.** Gesten und
> Systemtasten (`ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch`) greifen bei
> gesperrtem Bildschirm nicht — Android-Sicherheit. Der Agent erkennt das und
> meldet „Gerät ist gesperrt — bitte erst entsperren", statt still „ok" zu sagen.
> **Sehen** (`screenshot`, `ui_dump`, `info`) funktioniert dagegen auch bei
> gesperrtem Gerät.
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
@@ -111,10 +183,12 @@ identisch, nur der Wecker ändert sich.
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
(Version-Param → Gitea-Release-Asset, wie die App).
3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
`performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
`host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT
aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`.
## Bauen
@@ -190,5 +264,7 @@ Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung od
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
„Releases", nie im Tree.**
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`),
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern).
> Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 3
versionName '0.0.0.3'
versionCode 101
versionName '0.0.1.1'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -3,15 +3,33 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
Muss vom Nutzer einmalig erlaubt werden (Spezialberechtigung). -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Sprache: Mikrofon (Fire-TV-Fernbedienung / USB-Mikro) fuer ARIA. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Fire TV / Android TV: dieselbe APK laeuft auf Stick UND Handy.
touchscreen/faketouch NICHT erforderlich (Fire TV hat keinen), und
leanback ebenfalls optional (sonst laeuft sie nicht mehr aufs Handy). -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
@@ -20,17 +38,21 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- Ohne LEANBACK_LAUNCHER taucht die App im Fire-TV-Menue NICHT
auf (sie waere installiert, aber nicht startbar). -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AgentService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync|mediaProjection" />
<receiver
android:name=".BootReceiver"
@@ -11,6 +11,9 @@ data class AgentConfig(
var token: String = "",
var name: String = "",
var controlEnabled: Boolean = false,
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
var replyMode: String = "overlay",
) {
companion object {
private const val PREFS = "aria_agent"
@@ -24,6 +27,7 @@ data class AgentConfig(
token = p.getString("token", "") ?: "",
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
)
}
}
@@ -36,6 +40,7 @@ data class AgentConfig(
putString("token", token)
putString("name", name)
putBoolean("control", controlEnabled)
putString("replyMode", replyMode)
apply()
}
}
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
@@ -24,6 +25,7 @@ class AgentService : Service() {
private const val NOTIF_ID = 1
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
const val ACTION_TALK = "de.hackersoft.ariaagent.TALK"
const val EXTRA_RESULT_CODE = "resultCode"
const val EXTRA_RESULT_DATA = "resultData"
@@ -43,7 +45,19 @@ class AgentService : Service() {
override fun onCreate() {
super.onCreate()
createChannel()
startForeground(NOTIF_ID, buildNotification("startet …"))
startForegroundDataSync("startet …")
}
/** Normaler Vordergrund-Start (Typ dataSync). mediaProjection wird NUR beim
* Projizieren gesetzt — auf Android 14 darf man diesen Typ sonst nicht ohne
* Projection-Token verwenden. */
private fun startForegroundDataSync(text: String) {
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIF_ID, buildNotification(text),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, buildNotification(text))
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -52,18 +66,39 @@ class AgentService : Service() {
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
// zuerst startForeground aufrufen (idempotent).
startForeground(NOTIF_ID, buildNotification(status))
startForegroundDataSync(status)
if (intent?.action == ACTION_TALK) {
// Sprech-Taste: laeuft gerade eine Aufnahme -> beenden, sonst starten.
val v = rvs?.voice
if (v == null) startRvs()
else if (v.isRecording) v.stopListening("user") else v.startListening()
return START_STICKY
}
if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) {
try {
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
if (Build.VERSION.SDK_INT >= 29) {
startForeground(
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
ScreenCapturer.start(applicationContext, code, data)
updateNotification("$status · Bildschirm-Zugriff aktiv")
if (ScreenCapturer.active) {
updateNotification("$status · Bildschirm-Zugriff aktiv")
} else {
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
} catch (e: Throwable) {
ScreenCapturer.lastError = e.message ?: e.javaClass.simpleName
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
}
@@ -92,6 +127,7 @@ class AgentService : Service() {
}
override fun onDestroy() {
try { rvs?.voice?.shutdown() } catch (_: Exception) {}
rvs?.stop()
ScreenCapturer.stop()
connected = false
@@ -1,11 +1,18 @@
package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
@@ -82,10 +89,187 @@ class AriaAccessibilityService : AccessibilityService() {
}
}
// ─── Steuern (Meilenstein 3) ────────────────────────────────────
/** Tippt auf Bildschirm-Koordinaten (Pixel wie in ui_dump x/y). */
fun tap(x: Int, y: Int): JSONObject {
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
return gesture(path, 0, 60, "Tippen ($x,$y)")
}
/** Wischt von (x1,y1) nach (x2,y2) ueber dauerMs (Scrollen/Swipen). */
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int): JSONObject {
val path = Path().apply {
moveTo(x1.toFloat(), y1.toFloat())
lineTo(x2.toFloat(), y2.toFloat())
}
return gesture(path, 0, durationMs.coerceIn(50, 5000).toLong(),
"Wischen ($x1,$y1 -> $x2,$y2)")
}
private fun gesture(path: Path, startMs: Long, durationMs: Long, desc: String): JSONObject {
val g = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, startMs, durationMs))
.build()
val latch = CountDownLatch(1)
val ok = AtomicBoolean(false)
val dispatched = dispatchGesture(g, object : GestureResultCallback() {
override fun onCompleted(d: GestureDescription?) { ok.set(true); latch.countDown() }
override fun onCancelled(d: GestureDescription?) { latch.countDown() }
}, null)
if (!dispatched) return errMsg("Geste konnte nicht ausgeloest werden ($desc)")
try { latch.await(6, TimeUnit.SECONDS) } catch (_: InterruptedException) {}
return if (ok.get()) okMsg("$desc ausgefuehrt") else errMsg("$desc abgebrochen/timeout")
}
/** Schreibt Text in ein Eingabefeld an (x,y) — oder in das fokussierte Feld. */
fun setText(x: Int, y: Int, text: String): JSONObject {
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val node = editableAt(root, x, y)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.takeIf { it.isEditable }
?: return errMsg("kein Textfeld an ($x,$y) gefunden — vorher ui_tap aufs Feld?")
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val done = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
return if (done) okMsg("Text gesetzt (${text.length} Zeichen)")
else errMsg("Text setzen fehlgeschlagen (Feld nicht editierbar?)")
}
/** Tiefste editierbare Node, deren Rahmen (x,y) enthaelt. */
private fun editableAt(node: AccessibilityNodeInfo?, x: Int, y: Int): AccessibilityNodeInfo? {
if (node == null) return null
var found: AccessibilityNodeInfo? = null
for (i in 0 until node.childCount) {
editableAt(node.getChild(i), x, y)?.let { found = it }
}
if (found != null) return found
val r = Rect(); node.getBoundsInScreen(r)
return if (node.isEditable && r.contains(x, y)) node else null
}
/** Globale Taste: back/home/recents/notifications — plus D-Pad (Fire TV). */
fun globalKey(name: String): JSONObject {
val key = name.trim().lowercase()
// D-Pad/Medien: auf dem Fernseher wird damit navigiert (kein Touch).
DPAD_KEYS[key]?.let { return dpad(it, key) }
val action = when (key) {
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
else -> return errMsg("Taste '$name' unbekannt. Verfuegbar: back, home, " +
"recents, notifications, up, down, left, right, ok/center, " +
"play_pause, next, prev, volume_up, volume_down, mute")
}
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
else errMsg("Taste '$name' fehlgeschlagen")
}
/** D-Pad/Medien-Taste — via Fokus-Navigation bzw. Medien-Session. */
private fun dpad(keyCode: Int, label: String): JSONObject {
// Lautstaerke/Stumm gehen ueber den AudioManager (zuverlaessiger als KeyEvent).
when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP,
android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
android.view.KeyEvent.KEYCODE_VOLUME_MUTE -> {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP -> android.media.AudioManager.ADJUST_RAISE
android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> android.media.AudioManager.ADJUST_LOWER
else -> android.media.AudioManager.ADJUST_TOGGLE_MUTE
}
am.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, dir,
android.media.AudioManager.FLAG_SHOW_UI)
return okMsg("Taste '$label' ausgefuehrt")
}
}
// Medientasten an die aktive Wiedergabe-Session.
if (keyCode in MEDIA_KEYS) {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val now = android.os.SystemClock.uptimeMillis()
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_UP, keyCode, 0))
return okMsg("Taste '$label' ausgefuehrt")
}
// Richtungen/OK: ueber die Bedienungshilfe-Fokus-Navigation.
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER) {
val target = focus ?: return errMsg("nichts fokussiert — vorher navigieren")
return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK))
okMsg("OK/Auswahl ausgefuehrt") else errMsg("Auswahl fehlgeschlagen")
}
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
else -> View.FOCUS_RIGHT
}
val from = focus ?: root
val next = from.focusSearch(dir)
?: return errMsg("kein Element in Richtung '$label' gefunden")
return if (next.performAction(AccessibilityNodeInfo.ACTION_FOCUS))
okMsg("Taste '$label' ausgefuehrt") else errMsg("Taste '$label' fehlgeschlagen")
}
private fun okMsg(m: String): JSONObject =
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
private fun errMsg(m: String): JSONObject =
JSONObject().put("ok", false).put("error", m)
companion object {
@Volatile
var instance: AriaAccessibilityService? = null
val available: Boolean get() = instance != null
/** Fernseher-Navigation (Fire TV): D-Pad, Medien- und Lautstaerke-Tasten. */
private val DPAD_KEYS: Map<String, Int> = mapOf(
"up" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"hoch" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"down" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"runter" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"left" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"links" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"right" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"rechts" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"ok" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"center" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"select" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"enter" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"play_pause" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
"play" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
"pause" to android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
"stop" to android.view.KeyEvent.KEYCODE_MEDIA_STOP,
"next" to android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
"prev" to android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
"forward" to android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
"rewind" to android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
"volume_up" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"lauter" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"volume_down" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"leiser" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"mute" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
"stumm" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
)
private val MEDIA_KEYS: Set<Int> = setOf(
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_STOP,
android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
)
}
}
@@ -13,6 +13,7 @@ import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -41,6 +42,12 @@ class MainActivity : AppCompatActivity() {
}
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val micPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) talkToAria() else toast("Ohne Mikrofon-Berechtigung kein Sprachdialog")
}
private val projection = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { res ->
@@ -90,6 +97,22 @@ class MainActivity : AppCompatActivity() {
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
projection.launch(mpm.createScreenCaptureIntent())
}
findViewById<Button>(R.id.btnTalk).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) talkToAria()
else micPerm.launch(Manifest.permission.RECORD_AUDIO)
}
findViewById<Button>(R.id.btnOverlayPerm).setOnClickListener {
if (OverlayView.canDraw(this)) { toast("Einblendung ist bereits erlaubt"); return@setOnClickListener }
try {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
android.net.Uri.parse("package:$packageName")))
toast("'ARIA Host-Agent' erlauben")
} catch (_: Exception) {
try { startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) }
catch (_: Exception) { toast("Einstellung nicht gefunden") }
}
}
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
@@ -99,7 +122,17 @@ class MainActivity : AppCompatActivity() {
}
}
loadIntoUi(AgentConfig.load(this))
// Fern-Einrichtung (z.B. Fire TV, wo QR-Scan und Tippen wegfallen): der
// Satellit startet die App per ADB mit den Zugangsdaten als Extras.
// Hat das geklappt, ist die UI schon befuellt — sonst normal laden.
if (!applyProvisioning(intent)) loadIntoUi(AgentConfig.load(this))
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
if (isTvDevice()) {
findViewById<Button>(R.id.btnScan).visibility = android.view.View.GONE
host.requestFocus()
}
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@@ -149,6 +182,9 @@ class MainActivity : AppCompatActivity() {
name.setText(c.name)
tls.isChecked = c.tls
control.isChecked = c.controlEnabled
findViewById<RadioButton>(
if (c.replyMode == "speech") R.id.modeSpeech else R.id.modeOverlay
).isChecked = true
}
private fun saveAndConnect() {
@@ -159,6 +195,7 @@ class MainActivity : AppCompatActivity() {
token = token.text.toString().trim(),
name = name.text.toString().trim(),
controlEnabled = control.isChecked,
replyMode = if (findViewById<RadioButton>(R.id.modeSpeech).isChecked) "speech" else "overlay",
)
if (!c.isValid()) {
toast("Host und Token sind Pflicht")
@@ -175,5 +212,63 @@ class MainActivity : AppCompatActivity() {
statusView.text = "Status: ${AgentService.status} $dot"
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
setIntent(intent)
applyProvisioning(intent)
}
/**
* Fern-Einrichtung per Intent-Extras (vom Satelliten via ADB gestartet).
*
* Auf einem Fire TV gibt es keine Kamera (kein QR) und Tippen mit der
* Fernbedienung ist muehsam — deshalb kann der Satellit die Zugangsdaten
* direkt mitgeben:
* adb shell am start -n de.hackersoft.ariaagent/.MainActivity \
* -a de.hackersoft.ariaagent.PROVISION \
* -e host rvs.example.com --ei port 443 --ez tls true -e token XYZ
*
* Gibt true zurueck, wenn eingerichtet wurde (dann startet der Dienst gleich).
*/
private fun applyProvisioning(intent: Intent): Boolean {
val host = intent.getStringExtra("host")?.trim().orEmpty()
val token = intent.getStringExtra("token")?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return false
val old = AgentConfig.load(this)
val cfg = AgentConfig(
host = host,
port = intent.getIntExtra("port", old.port.takeIf { it > 0 } ?: 443),
tls = intent.getBooleanExtra("tls", old.tls),
token = token,
name = intent.getStringExtra("name")?.trim().takeUnless { it.isNullOrEmpty() } ?: old.name,
controlEnabled = intent.getBooleanExtra("control", true),
replyMode = intent.getStringExtra("reply_mode")?.trim()
.takeUnless { it.isNullOrEmpty() } ?: old.replyMode,
)
cfg.save(this)
AgentService.start(this)
// Sichtbar machen, dass die Konfiguration von aussen kam (Transparenz).
toast("Von ARIA eingerichtet: ${cfg.host} — Agent gestartet")
loadIntoUi(cfg)
refreshStatus()
return true
}
/** Sprech-Taste: startet/stoppt die Aufnahme im laufenden Dienst. */
private fun talkToAria() {
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
ContextCompat.startForegroundService(this,
Intent(this, AgentService::class.java).setAction(AgentService.ACTION_TALK))
toast("Sprich jetzt — nochmal drücken beendet die Aufnahme")
}
/** Fire TV / Android TV (kein Touchscreen, Bedienung per Fernbedienung). */
private fun isTvDevice(): Boolean = try {
packageManager.hasSystemFeature("android.software.leanback") ||
packageManager.hasSystemFeature("android.hardware.type.television")
} catch (_: Exception) { false }
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,139 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Base64
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
/**
* Einblendung ueber dem laufenden Bild (Fire TV / Android TV).
*
* ARIA kann Text (und optional ein Bild) UEBER Netflix & Co. anzeigen, ohne die
* Wiedergabe zu stoppen — fuer Antworten, Hinweise, Tuerklingel-Schnappschuesse.
* Technisch ein TYPE_APPLICATION_OVERLAY-Fenster; der Nutzer muss die
* Spezialberechtigung "Ueber anderen Apps anzeigen" einmalig erteilen.
*
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
* App darunter, die Einblendung schluckt keine Tastendruecke.
*/
object OverlayView {
private val main = Handler(Looper.getMainLooper())
private var root: LinearLayout? = null
private var hideRunnable: Runnable? = null
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
fun canDraw(ctx: Context): Boolean =
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
/**
* Zeigt eine Einblendung. [seconds] = Anzeigedauer (0 = bis zum naechsten
* hide()). [imageB64] optional ein PNG/JPEG als Base64.
* Muss NICHT vom Main-Thread gerufen werden.
*/
fun show(ctx: Context, title: String, text: String, seconds: Int, imageB64: String?) {
main.post {
try {
hideNow(ctx)
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val box = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(36, 28, 36, 28)
background = GradientDrawable().apply {
cornerRadius = 22f
setColor(Color.parseColor("#E60D0D1A")) // dunkel, leicht transparent
setStroke(3, Color.parseColor("#3FFF9F"))
}
}
if (title.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(title)
setTextColor(Color.parseColor("#3FFF9F"))
textSize = 22f
setPadding(0, 0, 0, 10)
})
}
if (text.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(text)
setTextColor(Color.parseColor("#E0E0F0"))
textSize = 19f
maxLines = 8
})
}
imageB64?.let { b64 ->
try {
val bytes = Base64.decode(b64, Base64.DEFAULT)
val bmp = android.graphics.BitmapFactory
.decodeByteArray(bytes, 0, bytes.size)
if (bmp != null) {
box.addView(ImageView(ctx).apply {
setImageBitmap(bmp)
adjustViewBounds = true
maxHeight = 520
setPadding(0, 14, 0, 0)
})
}
} catch (_: Exception) { /* Bild optional */ }
}
val type = if (Build.VERSION.SDK_INT >= 26)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
android.graphics.PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.END
x = 48
y = 48
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
}
wm.addView(box, lp)
root = box
if (seconds > 0) {
hideRunnable = Runnable { hideNow(ctx) }
main.postDelayed(hideRunnable!!, seconds * 1000L)
}
} catch (_: Exception) {
root = null
}
}
}
/** Blendet eine laufende Einblendung aus. */
fun hide(ctx: Context) {
main.post { hideNow(ctx) }
}
private fun hideNow(ctx: Context) {
hideRunnable?.let { main.removeCallbacks(it) }
hideRunnable = null
val v: View? = root
root = null
if (v != null) {
try {
(ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(v)
} catch (_: Exception) { /* schon weg */ }
}
}
}
@@ -33,7 +33,34 @@ class RvsClient(
@Volatile private var running = false
private var pingThread: Thread? = null
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
val voice: VoiceSession by lazy {
VoiceSession(appCtx, config,
send = { t, p -> ws?.let { send(it, t, p) } },
onEvent = { msg -> onStatus(true, msg) })
}
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
private val isTv: Boolean by lazy {
try {
val pm = appCtx.packageManager
pm.hasSystemFeature("android.software.leanback") ||
pm.hasSystemFeature("android.hardware.type.television") ||
(appCtx.getSystemService(Context.UI_MODE_SERVICE) as android.app.UiModeManager)
.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
} catch (_: Exception) { false }
}
private val caps: List<String> by lazy {
val base = mutableListOf("info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
// Ausgabe auf dem Geraet: Einblendung ueber dem Bild + Sprachausgabe.
"notify", "speak", "volume",
// Sprachdialog: Mikro oeffnen/schliessen (ARIA kann es auch anstossen).
"listen", "listen_stop")
if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick
base
}
fun start() {
running = true
@@ -97,7 +124,8 @@ class RvsClient(
val p = JSONObject()
p.put("hostId", config.hostId())
p.put("name", config.displayName())
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
val kind = if (isTv) "Fire TV / Android TV" else "Android"
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
@@ -117,7 +145,17 @@ class RvsClient(
private fun handle(webSocket: WebSocket, text: String) {
val msg = try { JSONObject(text) } catch (_: Exception) { return }
if (msg.optString("type") != "host_command") return
val type = msg.optString("type")
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
if (type != "host_command") {
if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
catch (_: Throwable) {}
}
return
}
val payload = msg.optJSONObject("payload") ?: JSONObject()
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
@@ -126,16 +164,38 @@ class RvsClient(
&& !target.equals(config.displayName(), true)) return
val action = payload.optString("action")
val result: JSONObject = when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
"app_launch", "app_list", "notify") ->
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
val params = payload.optJSONObject("params") ?: JSONObject()
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
val result: JSONObject = try {
when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
// Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
// nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
action in CONTROL_ACTIONS && isLocked() ->
err("Gerät ist gesperrt — zum Steuern bitte erst entsperren. " +
"(Screenshot/Bildschirm lesen geht auch gesperrt.)")
action == "ui_tap" -> doUiTap(params)
action == "ui_text" -> doUiText(params)
action == "ui_swipe" -> doUiSwipe(params)
action == "ui_key" -> doUiKey(params)
action == "app_launch" -> doAppLaunch(params)
action == "notify" -> doNotify(params)
action == "speak" -> doSpeak(params)
action == "volume" -> doVolume(params)
action == "listen" -> voice.startListening()
action == "listen_stop" -> voice.stopListening("remote")
action == "app_list" ->
err("Aktion '$action' kommt spaeter (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
}
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
result.put("requestId", payload.optString("requestId"))
result.put("hostId", config.hostId())
@@ -143,6 +203,18 @@ class RvsClient(
send(webSocket, "host_result", result)
}
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
private val VOICE_TYPES = setOf(
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
private fun isLocked(): Boolean = try {
val km = appCtx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
km.isKeyguardLocked
} catch (_: Exception) { false }
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
@@ -153,7 +225,7 @@ class RvsClient(
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
}
val png = ScreenCapturer.capture()
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?")
?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res)
@@ -161,12 +233,137 @@ class RvsClient(
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject {
val svc = AriaAccessibilityService.instance
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
val svc = a11y() ?: return a11yMissing()
return svc.dump()
}
private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance
private fun a11yMissing(): JSONObject =
err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " +
"antippen und 'ARIA Host-Agent' einschalten.")
/** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */
private fun doUiTap(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).")
return svc.tap(p.optInt("x"), p.optInt("y"))
}
/** Text in Feld an (x,y) schreiben. */
private fun doUiText(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
val text = p.optString("text")
if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.")
return svc.setText(p.optInt("x"), p.optInt("y"), text)
}
/** Wischen/Scrollen von (x1,y1) nach (x2,y2). */
private fun doUiSwipe(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2"))
return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).")
return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"),
p.optInt("duration_ms", 300))
}
/** Globale Taste: back/home/recents/notifications. */
private fun doUiKey(p: JSONObject): JSONObject {
val svc = a11y() ?: return a11yMissing()
return svc.globalKey(p.optString("key"))
}
/** App starten (per Paketname oder Namens-Suche). */
private fun doAppLaunch(p: JSONObject): JSONObject {
val pm = appCtx.packageManager
var pkg = p.optString("package").trim()
val query = p.optString("query").trim()
if (pkg.isBlank() && query.isNotBlank()) {
pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.")
}
if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).")
val intent = pm.getLaunchIntentForPackage(pkg)
?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.")
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
appCtx.startActivity(intent)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "App '$pkg' gestartet"))
}
/**
* ARIAs Antwort auf dem Geraet ausgeben: Einblendung (Overlay) und/oder Sprache.
* `mode` optional ("overlay" | "speech" | "both") — ohne Angabe gilt die
* Einstellung aus der App (config.replyMode). So kann ARIA pro Anfrage
* ueberschreiben ("...bitte als Sprache"), ohne die Voreinstellung zu aendern.
*/
private fun doNotify(p: JSONObject): JSONObject {
val text = p.optString("text").ifBlank { p.optString("message") }
val title = p.optString("title", "ARIA")
if (text.isBlank() && !p.has("image_base64")) return err("notify braucht 'text'.")
val mode = p.optString("mode").ifBlank { config.replyMode }
val done = mutableListOf<String>()
val problems = mutableListOf<String>()
if (mode == "overlay" || mode == "both") {
if (!OverlayView.canDraw(appCtx)) {
problems += "Einblendung nicht erlaubt — in der Agent-App auf dem Gerät " +
"'Einblendung erlauben' antippen (Berechtigung 'Über anderen Apps anzeigen')"
} else {
OverlayView.show(appCtx, title, text,
p.optInt("seconds", 12),
if (p.has("image_base64")) p.optString("image_base64") else null)
done += "eingeblendet"
}
}
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen" }
}
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
return JSONObject().put("ok", true).put("result", JSONObject().put("message", msg))
}
/** Nur Sprachausgabe (ARIAs Audio, falls mitgeliefert — sonst Geraete-TTS). */
private fun doSpeak(p: JSONObject): JSONObject {
val text = p.optString("text")
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
val r = speakOut(p, text)
return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS. */
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
val audio = p.optString("audio_base64")
return if (audio.isNotBlank())
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"))
else Speaker.speak(appCtx, text)
}
private fun doVolume(p: JSONObject): JSONObject {
if (!p.has("percent")) return err("volume braucht 'percent' (0-100).")
val v = p.optInt("percent")
Speaker.setVolume(appCtx, v)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Lautstärke auf $v% gesetzt"))
}
/** Paketname per Label-Teilstring finden (case-insensitive). */
private fun resolvePackage(query: String): String? {
val pm = appCtx.packageManager
val q = query.lowercase()
val launch = android.content.Intent(android.content.Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
return pm.queryIntentActivities(launch, 0)
.mapNotNull { it.activityInfo }
.firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) }
?.packageName
}
private fun doInfo(): JSONObject {
val res = JSONObject()
res.put("host", config.displayName())
@@ -23,10 +23,16 @@ import java.io.ByteArrayOutputStream
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
*
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
*
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/
object ScreenCapturer {
private const val MAX_SIDE = 1280
private var projection: MediaProjection? = null
private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null
@@ -56,9 +62,12 @@ object ScreenCapturer {
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
w = metrics.widthPixels
h = metrics.heightPixels
dpi = metrics.densityDpi
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
handlerThread = HandlerThread("aria-capture").also { it.start() }
handler = Handler(handlerThread!!.looper)
@@ -94,50 +103,47 @@ object ScreenCapturer {
handler = null
}
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
fun capture(): ByteArray? {
val r = reader ?: return null
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
var image: Image? = null
var tries = 0
while (tries < 20) {
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
while (tries < 37) {
image = r.acquireLatestImage()
if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++
}
if (image == null) return null
if (image == null) {
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
return null
}
return try {
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w
val padded = Bitmap.createBitmap(
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
h, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(buffer)
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
val scaled = downscale(cropped, 1280)
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(buffer)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
if (scaled !== cropped) scaled.recycle()
if (cropped !== padded) cropped.recycle()
padded.recycle()
if (rowPadding == 0) {
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
} else {
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.recycle()
}
bmp.recycle()
lastError = null
out.toByteArray()
} catch (_: Exception) {
} catch (t: Throwable) {
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
null
} finally {
try { image?.close() } catch (_: Exception) {}
}
}
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
val longSide = maxOf(src.width, src.height)
if (longSide <= maxSide) return src
val scale = maxSide.toFloat() / longSide
return Bitmap.createScaledBitmap(
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
)
}
}
@@ -0,0 +1,76 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.speech.tts.TextToSpeech
import android.util.Base64
import java.io.File
import java.util.Locale
/**
* Sprachausgabe auf dem Geraet (v.a. Fire TV: ARIA spricht ueber die TV-Lautsprecher).
*
* Zwei Wege:
* 1) `play(audioBase64)` — ARIAs ECHTE Stimme: fertiges Audio (WAV/MP3) aus dem
* bestehenden TTS-Pfad (F5/XTTS) wird einfach abgespielt. Bevorzugt.
* 2) `speak(text)` — geraeteeigene TextToSpeech als Rueckfallebene, falls kein
* Audio mitgeliefert wurde (klingt nach Standard-Stimme, nicht nach ARIA).
*/
object Speaker {
private var tts: TextToSpeech? = null
private var ttsReady = false
private var player: MediaPlayer? = null
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher. */
fun play(ctx: Context, audioB64: String, format: String = "wav"): Result<Unit> = runCatching {
stop()
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
f.writeBytes(bytes)
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(f.absolutePath)
setOnCompletionListener { mp -> try { mp.release() } catch (_: Exception) {}; player = null }
prepare()
start()
}
}
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
fun speak(ctx: Context, text: String): Result<Unit> = runCatching {
if (tts == null) {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) tts?.language = Locale.GERMAN
}
// Kurz auf die Initialisierung warten (TTS meldet sich asynchron).
var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
}
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
Unit
}
/** Lautstaerke 0..100 (Medien-Kanal). */
fun setVolume(ctx: Context, percent: Int) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val v = (max * percent.coerceIn(0, 100) / 100.0).toInt()
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
}
fun stop() {
try { player?.stop(); player?.release() } catch (_: Exception) {}
player = null
try { tts?.stop() } catch (_: Exception) {}
}
}
@@ -0,0 +1,280 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Base64
import okhttp3.WebSocket
import org.json.JSONObject
import kotlin.random.Random
/**
* Sprachdialog mit ARIA direkt auf dem Geraet (v.a. Fire TV: reden ueber die
* Fernbedienung, Antwort ueber die TV-Lautsprecher bzw. als Einblendung).
*
* Nutzt GENAU das Protokoll der ARIA-App, damit serverseitig nichts angepasst
* werden muss:
* rein : stt_stream_start -> stt_audio_chunk (Feld 'pcm', 16 kHz mono s16le,
* 6400-Byte-Bloecke) -> stt_stream_end
* raus : chat (sender=aria, messageId/speak/converse) + audio_pcm (Feld
* 'base64', 24 kHz mono s16le, chunkweise bis final=true)
*
* WICHTIG — Turn-Ownership: Der RVS ist ein Broadcast-Relay ohne Adressierung.
* Jeder Client im Raum bekommt JEDE Antwort. Ohne Filter wuerden Handy-App und
* Fire TV dieselbe Antwort gleichzeitig vorlesen. Deshalb gibt dieses Geraet nur
* aus, was aus SEINER eigenen Frage entstanden ist (audioRequestId -> messageId).
*/
class VoiceSession(
private val appCtx: Context,
private val config: AgentConfig,
private val send: (type: String, payload: JSONObject) -> Unit,
private val onEvent: (String) -> Unit = {},
) {
companion object {
// Aufnahme — muss exakt zur STT-Bridge passen.
private const val IN_RATE = 16000
private const val CHUNK_BYTES = 6400 // 3200 Samples = 200 ms
// Wiedergabe — F5-TTS liefert 24 kHz (sampleRate steht aber im Payload).
private const val OUT_RATE_DEFAULT = 24000
private const val MAX_RECORD_MS = 60000
}
@Volatile private var recording = false
@Volatile private var recordThread: Thread? = null
private var rec: AudioRecord? = null
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var track: AudioTrack? = null
@Volatile private var playingMessageId: String? = null
/** Eigener Turn: gesetzt beim Aufnehmen, wandert ueber stt_endpoint zur messageId. */
@Volatile private var myAudioRequestId: String? = null
@Volatile private var myMessageId: String? = null
@Volatile private var sessionId: String? = null
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = ""
val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ────────────────────────────────────────
/** Startet die Aufnahme und streamt live an die STT. */
fun startListening(): JSONObject {
if (recording) return JSONObject().put("ok", false).put("error", "Nimmt bereits auf.")
val stamp = System.currentTimeMillis()
val rnd = Random.nextInt(100000, 999999)
val sid = "sttstr_${stamp}_$rnd"
val aid = "audio_${stamp}_$rnd"
sessionId = sid
myAudioRequestId = aid
myMessageId = null
val minBuf = AudioRecord.getMinBufferSize(
IN_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (minBuf <= 0) return JSONObject().put("ok", false)
.put("error", "Kein Mikrofon verfuegbar (Fire-TV-Fernbedienung aktiv? USB-Mikro?)")
val r = try {
AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, IN_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, CHUNK_BYTES * 4))
} catch (e: Throwable) {
return JSONObject().put("ok", false)
.put("error", "Mikrofon-Zugriff fehlgeschlagen: ${e.message}")
}
if (r.state != AudioRecord.STATE_INITIALIZED) {
try { r.release() } catch (_: Exception) {}
return JSONObject().put("ok", false)
.put("error", "Mikrofon nicht initialisiert (Berechtigung erteilt? Mikro vorhanden?)")
}
// Echo/Rauschen daempfen, damit der Stick nicht seine eigene Ausgabe hoert.
try { if (AcousticEchoCanceler.isAvailable()) aec = AcousticEchoCanceler.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
try { if (NoiseSuppressor.isAvailable()) ns = NoiseSuppressor.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
rec = r
recording = true
send("stt_stream_start", JSONObject().apply {
put("requestId", sid)
put("audioRequestId", aid)
put("voice", "")
put("speed", 1.0)
put("interrupted", false)
put("endpointMs", 2400)
put("hardCapMs", MAX_RECORD_MS)
put("sampleRate", IN_RATE)
put("projectId", "")
put("targetInstance", "")
put("language", "de")
})
onEvent("hoert zu …")
recordThread = Thread {
val buf = ByteArray(CHUNK_BYTES)
var seq = 0
try {
r.startRecording()
val until = System.currentTimeMillis() + MAX_RECORD_MS
while (recording && System.currentTimeMillis() < until) {
val n = r.read(buf, 0, buf.size)
if (n <= 0) continue
val slice = if (n == buf.size) buf else buf.copyOf(n)
send("stt_audio_chunk", JSONObject().apply {
put("requestId", sid)
put("pcm", Base64.encodeToString(slice, Base64.NO_WRAP))
put("seq", seq++)
put("targetInstance", "")
})
}
} catch (_: Throwable) {
// Aufnahme abgebrochen — stopListening raeumt auf.
} finally {
stopListening("auto")
}
}.also { it.isDaemon = true; it.start() }
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Hoere zu …"))
}
/** Beendet die Aufnahme (die STT antwortet dann per stt_endpoint). */
fun stopListening(reason: String = "user"): JSONObject {
if (!recording) return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Nahm nicht auf"))
recording = false
try { rec?.stop() } catch (_: Exception) {}
try { rec?.release() } catch (_: Exception) {}
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
rec = null; aec = null; ns = null
sessionId?.let {
send("stt_stream_end", JSONObject().apply {
put("requestId", it); put("reason", reason); put("targetInstance", "")
})
}
onEvent("verarbeite …")
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
val stamp = System.currentTimeMillis()
myAudioRequestId = "audio_${stamp}_${Random.nextInt(100000, 999999)}"
myMessageId = null
send("chat", JSONObject().apply {
put("text", text)
put("voice", ""); put("speed", 1.0); put("projectId", "")
put("clientMsgId", "cmsg_${stamp}_${Random.nextInt(100000, 999999)}")
})
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Frage gesendet"))
}
// ── Eingehende Nachrichten ──────────────────────────────────────
/** Verarbeitet Sprach-/Chat-Nachrichten. true = war fuer uns relevant. */
fun handle(type: String, p: JSONObject): Boolean = when (type) {
"stt_endpoint", "stt_stream_done" -> {
if (p.optString("requestId") == sessionId) {
val text = p.optString("text")
if (type == "stt_endpoint" && text.isNotBlank()) onEvent("verstanden: $text")
if (type == "stt_stream_done") { recording = false; sessionId = null }
}
true
}
"chat" -> {
if (p.optString("sender") == "aria" && isMyTurn(p)) {
myMessageId = p.optString("messageId")
lastReplyText = p.optString("text")
deliverReply(p)
}
true
}
"audio_pcm" -> {
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
val mid = p.optString("messageId")
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p)
true
}
else -> false
}
/** Gehoert diese Antwort zu unserer Frage? */
private fun isMyTurn(p: JSONObject): Boolean {
val aid = myAudioRequestId ?: return false
val pa = p.optString("audioRequestId")
// Sprach-Turn: audioRequestId wird durchgereicht. Text-Turn (ask): die
// naechste ARIA-Antwort nach unserer Frage gehoert uns.
return if (pa.isNotBlank()) pa == aid else myMessageId == null
}
private fun wantSpeech(): Boolean = config.replyMode == "speech"
/** Antwort ausgeben: Einblendung und/oder Sprache (je nach Einstellung). */
private fun deliverReply(p: JSONObject) {
val text = p.optString("text")
if (text.isBlank()) return
if (config.replyMode == "overlay" && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
} else if (config.replyMode == "overlay") {
onEvent("Antwort (Einblendung nicht erlaubt): $text")
}
// Bei "speech" kommt das Audio ueber audio_pcm; falls ARIA nicht spricht
// (speak=false), trotzdem einblenden, damit nichts verloren geht.
if (wantSpeech() && !p.optBoolean("speak", true) && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
}
}
// ── TTS-Wiedergabe (chunkweises PCM) ────────────────────────────
private fun feedAudio(p: JSONObject) {
val mid = p.optString("messageId")
val rate = p.optInt("sampleRate", OUT_RATE_DEFAULT)
if (p.optBoolean("final", false)) {
try { track?.stop() } catch (_: Exception) {}
try { track?.release() } catch (_: Exception) {}
track = null; playingMessageId = null
onEvent("bereit")
return
}
val b64 = p.optString("base64")
if (b64.isBlank()) return
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
if (track == null || playingMessageId != mid) {
try { track?.release() } catch (_: Exception) {}
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT)
track = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(rate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
.setBufferSizeInBytes(maxOf(min, 16384))
.setTransferMode(AudioTrack.MODE_STREAM)
.build().also { it.play() }
playingMessageId = mid
onEvent("spricht …")
}
try { track?.write(pcm, 0, pcm.size) } catch (_: Exception) {}
}
fun shutdown() {
stopListening("shutdown")
try { track?.stop(); track?.release() } catch (_: Exception) {}
track = null
}
}
@@ -0,0 +1,32 @@
<!-- Banner-Kachel fuer den Fire-TV-/Android-TV-Launcher (320x180 dp).
TV-Launcher zeigen Banner statt Icons — ohne das fehlt die Kachel. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h320v180h-320z" />
<!-- Ring (ARIA-Auge) -->
<path
android:fillColor="#3FFF9F"
android:pathData="M74,90 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z M74,90 m-17,0 a17,17 0 1,1 34,0 a17,17 0 1,1 -34,0 Z" />
<!-- Schriftzug "ARIA" als Pfade (keine Font-Abhaengigkeit im Vector) -->
<path
android:fillColor="#E0E0F0"
android:pathData="M140,110 L152,64 L162,64 L174,110 L164,110 L161,99 L153,99 L150,110 Z M155,91 L159,91 L157,82 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M180,110 L180,64 L196,64 A13,13 0 0,1 196,90 L200,110 L190,110 L187,92 L189,92 L189,110 Z M189,72 L189,84 L195,84 A6,6 0 0,0 195,72 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M208,64 L217,64 L217,110 L208,110 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M224,110 L236,64 L246,64 L258,110 L248,110 L245,99 L237,99 L234,110 Z M239,91 L243,91 L241,82 Z" />
<!-- Untertitel-Balken -->
<path
android:fillColor="#3FFF9F"
android:pathData="M140,122 h118 v4 h-118 z" />
</vector>
@@ -29,6 +29,8 @@
android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="false"
android:text="QR-Code scannen" />
<TextView
@@ -96,6 +98,14 @@
android:text="Agent stoppen"
android:paddingTop="8dp" />
<Button
android:id="@+id/btnTalk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="🎤 Mit ARIA sprechen"
android:paddingTop="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -124,5 +134,48 @@
android:text="Bedienungshilfe öffnen"
android:paddingTop="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Antworten (Fernseher)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wie ARIA hier standardmäßig antwortet. Sie kann das pro Anfrage überschreiben (z.B. mit dem Zusatz: bitte als Sprache)."
android:textSize="13sp"
android:paddingBottom="8dp" />
<RadioGroup
android:id="@+id/replyMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/modeOverlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Einblendung über dem Bild (Standard)" />
<RadioButton
android:id="@+id/modeSpeech"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Sprachausgabe über die Lautsprecher" />
</RadioGroup>
<Button
android:id="@+id/btnOverlayPerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Einblendung erlauben"
android:paddingTop="8dp" />
</LinearLayout>
</ScrollView>
@@ -4,5 +4,6 @@
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
+214 -1
View File
@@ -162,10 +162,40 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.0.3"
AGENT_VERSION = "0.0.1.1"
HEARTBEAT_SEC = 25
def _gui_input_method() -> str:
"""Welche Methode zur GUI-Steuerung (Maus/Tastatur) ist verfuegbar?
'' = keine (z.B. Linux-Server ohne X/Wayland -> nur Terminal/exec).
windows -> PowerShell (Bordmittel)
macos -> osascript (Text/Tasten) + cliclick (Maus, falls installiert)
xdotool -> Linux/X11
ydotool -> Linux/Wayland (Daemon noetig)"""
if IS_WINDOWS:
return "windows"
if IS_MAC:
return "macos"
if os.environ.get("DISPLAY") and shutil.which("xdotool"):
return "xdotool"
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("ydotool"):
return "ydotool"
return ""
GUI_METHOD = _gui_input_method()
# macOS: Maus braucht cliclick; Text/Tasten/App gehen per osascript (Bordmittel).
_MAC_MOUSE = IS_MAC and shutil.which("cliclick") is not None
CAPS = ["exec", "read", "write", "info", "screenshot"]
if GUI_METHOD:
# Tastatur/Text/App gehen bei jeder GUI-Methode; Maus (tap/swipe) auf dem Mac
# nur mit cliclick.
CAPS += ["ui_text", "ui_key", "app_launch"]
if GUI_METHOD != "macos" or _MAC_MOUSE:
CAPS += ["ui_tap", "ui_swipe"]
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
@@ -305,6 +335,7 @@ def _do_info(params: dict) -> dict:
"arch": platform.machine(), "python": platform.python_version(),
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
"is_root": _is_admin(),
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
}
try:
import psutil
@@ -390,9 +421,191 @@ def _do_screenshot(params: dict) -> dict:
"Laeuft der Agent in derselben grafischen Session?"}
# ─── GUI-Steuerung (Maus/Tastatur/App) — nur mit grafischer Session ──
def _gui_unavailable() -> dict:
return {"ok": False, "error":
"Keine grafische Session zum Steuern. Linux: X11 (DISPLAY + xdotool) "
"oder Wayland (WAYLAND_DISPLAY + ydotool-Daemon). macOS: cliclick fuer "
"Maus. Auf einem reinen Terminal-/Server-System gibt es keine GUI."}
def _run_gui(argv: list, desc: str) -> dict:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
if r.returncode == 0:
return {"ok": True, "result": {"message": f"{desc} ausgefuehrt"}}
return {"ok": False, "error": f"{desc} fehlgeschlagen: "
+ (r.stderr or r.stdout or f"exit {r.returncode}").strip()[:200]}
except Exception as exc:
return {"ok": False, "error": f"{desc}: {exc}"}
def _ps(script: str, desc: str) -> dict:
return _run_gui(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], desc)
def _osa(script: str, desc: str) -> dict:
return _run_gui(["osascript", "-e", script], desc)
_WIN_MOUSE_DECL = (
"Add-Type -Name U -Namespace W -MemberDefinition '"
"[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y);"
"[DllImport(\"user32.dll\")] public static extern void mouse_event("
"uint f,uint x,uint y,uint d,int e);';"
)
def _do_ui_tap(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
x = _to_int(params.get("x")); y = _to_int(params.get("y"))
if x is None or y is None:
return {"ok": False, "error": "ui_tap braucht x,y (Bildschirm-Pixel aus dem Screenshot)."}
button = str(params.get("button", "left")).lower()
double = bool(params.get("double"))
desc = f"Klick ({x},{y})"
if GUI_METHOD == "xdotool":
btn = {"left": "1", "middle": "2", "right": "3"}.get(button, "1")
argv = ["xdotool", "mousemove", "--sync", str(x), str(y), "click"]
if double:
argv += ["--repeat", "2", "--delay", "120"]
return _run_gui(argv + [btn], desc)
if GUI_METHOD == "ydotool":
code = {"left": "0xC0", "right": "0xC1", "middle": "0xC2"}.get(button, "0xC0")
_run_gui(["ydotool", "mousemove", "-a", str(x), str(y)], "move")
r = _run_gui(["ydotool", "click", code], desc)
if double and r.get("ok"):
_run_gui(["ydotool", "click", code], desc)
return r
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Maus-Steuerung braucht cliclick (brew install cliclick)."}
cmd = ("dc:" if double else ("rc:" if button == "right" else "c:")) + f"{x},{y}"
return _run_gui(["cliclick", cmd], desc)
if GUI_METHOD == "windows":
down, up = ("0x0002", "0x0004") if button != "right" else ("0x0008", "0x0010")
click = f"[W.U]::mouse_event({down},0,0,0,0);[W.U]::mouse_event({up},0,0,0,0);"
script = _WIN_MOUSE_DECL + f"[W.U]::SetCursorPos({x},{y});" + click + (click if double else "")
return _ps(script, desc)
return _gui_unavailable()
def _do_ui_text(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
text = str(params.get("text", ""))
if not text:
return {"ok": False, "error": "ui_text braucht 'text'."}
desc = f"Text ({len(text)} Zeichen)"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "type", "--clearmodifiers", "--", text], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "type", "--", text], desc)
if GUI_METHOD == "macos":
esc = text.replace("\\", "\\\\").replace('"', '\\"')
return _osa(f'tell application "System Events" to keystroke "{esc}"', desc)
if GUI_METHOD == "windows":
safe = re.sub(r"([+^%~(){}\[\]])", r"{\1}", text).replace('"', '`"')
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait(\"{safe}\")", desc)
return _gui_unavailable()
def _do_ui_key(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
key = str(params.get("key", "")).strip()
if not key:
return {"ok": False, "error": "ui_key braucht 'key' (z.B. Return, Escape, ctrl+c)."}
desc = f"Taste '{key}'"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "key", "--clearmodifiers", key], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "key", key], desc) # best effort (Keycodes)
if GUI_METHOD == "macos":
codes = {"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51,
"escape": 53, "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
"home": 115, "end": 119, "pageup": 116, "pagedown": 121}
k = key.lower()
if k in codes:
return _osa(f'tell application "System Events" to key code {codes[k]}', desc)
return _osa(f'tell application "System Events" to keystroke "{key}"', desc)
if GUI_METHOD == "windows":
m = {"return": "{ENTER}", "enter": "{ENTER}", "escape": "{ESC}", "esc": "{ESC}",
"tab": "{TAB}", "backspace": "{BACKSPACE}", "delete": "{DEL}",
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
"home": "{HOME}", "end": "{END}", "pageup": "{PGUP}", "pagedown": "{PGDN}"}
send = m.get(key.lower(), key)
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait('{send}')", desc)
return _gui_unavailable()
def _do_ui_swipe(params: dict) -> dict:
"""Auf dem Desktop = Maus ziehen von (x1,y1) nach (x2,y2)."""
if not GUI_METHOD:
return _gui_unavailable()
x1 = _to_int(params.get("x1")); y1 = _to_int(params.get("y1"))
x2 = _to_int(params.get("x2")); y2 = _to_int(params.get("y2"))
if None in (x1, y1, x2, y2):
return {"ok": False, "error": "ui_swipe braucht x1,y1,x2,y2."}
desc = f"Ziehen ({x1},{y1} -> {x2},{y2})"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "mousemove", "--sync", str(x1), str(y1),
"mousedown", "1", "mousemove", "--sync", str(x2), str(y2),
"mouseup", "1"], desc)
if GUI_METHOD == "ydotool":
_run_gui(["ydotool", "mousemove", "-a", str(x1), str(y1)], "move")
_run_gui(["ydotool", "click", "0x40"], "down")
_run_gui(["ydotool", "mousemove", "-a", str(x2), str(y2)], "move")
return _run_gui(["ydotool", "click", "0x80"], desc)
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Ziehen braucht cliclick."}
return _run_gui(["cliclick", f"dd:{x1},{y1}", f"du:{x2},{y2}"], desc)
if GUI_METHOD == "windows":
script = (_WIN_MOUSE_DECL +
f"[W.U]::SetCursorPos({x1},{y1});[W.U]::mouse_event(0x0002,0,0,0,0);"
f"Start-Sleep -Milliseconds 80;[W.U]::SetCursorPos({x2},{y2});"
"[W.U]::mouse_event(0x0004,0,0,0,0);")
return _ps(script, desc)
return _gui_unavailable()
def _do_app_launch(params: dict) -> dict:
"""Startet eine App/ein Programm (nicht-blockierend). 'app' = Name/Pfad,
'command' = beliebiger Startbefehl."""
app = str(params.get("app") or params.get("query") or "").strip()
command = str(params.get("command") or "").strip()
if not app and not command:
return {"ok": False, "error": "app_launch braucht 'app' (Name) oder 'command'."}
try:
if command:
argv = command if IS_WINDOWS else ["/bin/sh", "-c", command]
subprocess.Popen(argv, shell=bool(IS_WINDOWS),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"Befehl gestartet: {command}"}}
if IS_MAC:
subprocess.Popen(["open", "-a", app])
elif IS_WINDOWS:
subprocess.Popen(["cmd", "/c", "start", "", app])
else:
launcher = shutil.which(app) or app
head = ["setsid"] if shutil.which("setsid") else []
subprocess.Popen(head + [launcher],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"App gestartet: {app}"}}
except Exception as exc:
return {"ok": False, "error": f"Start fehlgeschlagen: {exc}"}
ACTIONS = {
"exec": _do_exec, "read": _do_read, "write": _do_write,
"info": _do_info, "screenshot": _do_screenshot,
"ui_tap": _do_ui_tap, "ui_text": _do_ui_text, "ui_key": _do_ui_key,
"ui_swipe": _do_ui_swipe, "app_launch": _do_app_launch,
}
+5
View File
@@ -88,6 +88,11 @@ const ALLOWED_TYPES = new Set([
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
"host_hello", "host_ping", "host_command", "host_result",
// Agent-Historie: Brain schickt pro Host-Aktion ein agent_history-Event (via
// Bridge), Diagnostic fragt die gespeicherte Historie per agent_history_query
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
// der RVS sie still an der Allow-List und die Historie bleibt leer.
"agent_history", "agent_history_query", "agent_history_list",
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
+19 -1
View File
@@ -41,7 +41,25 @@ 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)
# adb.provision Agent FERN einrichten: RVS-Zugang uebergeben, statt ihn mit der
# Fernbedienung abzutippen (params: ip, rvs_host, rvs_token, ...)
# adb.status laeuft/installiert der Agent auf dem Geraet? (params: ip)
# 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.provision,adb.status
# ─── 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/
+4 -1
View File
@@ -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 .
+12
View File
@@ -32,6 +32,18 @@ docker compose logs -f # "Netz: primary_ip=192.168.0.x" + "[scan] N Ger
```
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
> **Fehler `KeyError: 'ContainerConfig'` beim Neubauen?**
> Das ist ein **Bug im alten `docker-compose` 1.29.2** (Python-Version), nicht im
> Satelliten: Beim *Ersetzen* eines bestehenden Containers mit Volume liest es ein
> Feld aus dem Image, das neuere Docker-Versionen nicht mehr schreiben. Zwei Fixes:
> ```bash
> docker compose up -d --build # v2 (mit LEERZEICHEN) — falls vorhanden
> # oder mit der alten Version: alten Container erst weg, dann neu anlegen
> docker-compose rm -sf satellite && docker-compose up -d --build
> ```
> Der `./data`-Ordner (Credentials, ADB-Schlüssel, APK-Cache) bleibt dabei erhalten —
> er ist ein Bind-Mount, kein Container-Volume.
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
`satellite/`-Ordner liegen):
+6
View File
@@ -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
+490 -5
View File
@@ -134,10 +134,24 @@ 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,adb.provision,adb.status").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")
# Beim ERSTEN Verbinden zeigt das Geraet "USB-Debugging zulassen?" auf dem
# Bildschirm. Statt mit Fehler abzubrechen (und den Nutzer nochmal klicken zu
# lassen) wartet der Satellit so lange auf die Bestaetigung.
ADB_AUTH_WAIT_SEC = float(os.environ.get("ADB_AUTH_WAIT_SEC", "90") or "90")
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 +283,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"]
@@ -316,7 +333,48 @@ def _net_context() -> dict:
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
return {"primary_ip": p, "ips": ips, "warning": warning}
return {"primary_ip": p, "ips": ips, "mac": _own_mac(p), "warning": warning}
def _own_mac(ip: str) -> str:
"""MAC der eigenen (primaeren) Netzwerkkarte — identifiziert die Kiste
eindeutig (DHCP-Reservierung, WOL). Liest /sys/class/net (Linux); faellt auf
uuid.getnode() zurueck, wenn das Interface nicht zuzuordnen ist."""
import subprocess
try:
base = "/sys/class/net"
if os.path.isdir(base):
# Interface zur primaeren IP finden (ueber die Adressen im /proc-Netz).
want = ""
if ip:
try:
out = subprocess.run(["ip", "-o", "-4", "addr", "show"],
capture_output=True, text=True, timeout=3).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[3].split("/")[0] == ip:
want = parts[1]
break
except Exception:
pass
names = [want] if want else []
names += [n for n in sorted(os.listdir(base)) if n != "lo" and n not in names]
for n in names:
try:
with open(os.path.join(base, n, "address"), "r") as f:
mac = f.read().strip()
if mac and mac != "00:00:00:00:00:00":
return mac.lower()
except Exception:
continue
except Exception:
pass
try:
import uuid
node = uuid.getnode()
return ":".join(f"{(node >> e) & 0xFF:02x}" for e in range(40, -8, -8))
except Exception:
return ""
NET = _net_context()
@@ -404,6 +462,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 +625,18 @@ 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)
if action == "adb.provision":
return await loop.run_in_executor(None, _do_adb_provision, params)
if action == "adb.status":
return await loop.run_in_executor(None, _do_adb_status, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -1046,6 +1118,381 @@ 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 _adb_wait_authorized(target: str, seconds: float) -> tuple:
"""Wartet, bis das Geraet autorisiert ist ('device').
Beim ersten Kontakt erscheint auf dem Fernseher/Handy der Dialog
"USB-Debugging zulassen?". Frueher brach die Installation hier ab und man
musste nach dem Bestaetigen ein zweites Mal starten — jetzt wartet der
Satellit einfach, bis der Nutzer bestaetigt hat. Gibt (ok, zustand).
"""
import time as _time
deadline = _time.time() + max(0.0, seconds)
last = _adb_state(target)
while _time.time() < deadline:
if last == "device":
return True, last
_time.sleep(2.0)
# Erneut verbinden: nach dem Bestaetigen wechselt der Zustand oft erst
# mit einem frischen connect von 'unauthorized'/'offline' auf 'device'.
_adb_run(["connect", target], timeout=15)
last = _adb_state(target)
return last == "device", last
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)
state = _adb_state(target)
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
authed, state = _adb_wait_authorized(target, wait)
if not authed:
return {"ok": False, "error":
"Nicht autorisiert — auf dem Bildschirm des Geraets "
"'USB-Debugging zulassen?' bestaetigen. "
f"(gewartet: {wait:.0f}s, Zustand: '{state or '?'}')"}
msg = f"verbunden (nach Bestaetigung am Geraet)"
elif not ok:
return {"ok": False, "error": msg}
return {"ok": True, "result": {"target": target, "state": state, "message": msg}}
AGENT_PACKAGE = os.environ.get("AGENT_PACKAGE", "de.hackersoft.ariaagent")
def _adb_app_installed(target: str, package: str) -> bool:
rc, out, _ = _adb_run(["-s", target, "shell", "pm", "list", "packages", package],
timeout=30)
return rc == 0 and f"package:{package}" in out
def _adb_app_running(target: str, package: str) -> bool:
"""Laeuft der Vordergrund-Dienst des Agents? (Fire TV / Android)."""
rc, out, _ = _adb_run(["-s", target, "shell", "dumpsys", "activity", "services",
package], timeout=30)
return rc == 0 and "ServiceRecord" in out
def _do_adb_status(params: dict) -> dict:
"""Ist der ARIA-Agent auf dem Geraet installiert und laeuft er?"""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
installed = _adb_app_installed(target, package)
running = _adb_app_running(target, package) if installed else False
ver = ""
if installed:
rc, out, _ = _adb_run(["-s", target, "shell", "dumpsys", "package", package],
timeout=30)
m = re.search(r"versionName=(\S+)", out or "")
ver = m.group(1) if m else ""
return {"ok": True, "result": {
"target": target, "package": package, "installed": installed,
"running": running, "version": ver,
"message": ("Agent laeuft" if running else
"installiert, laeuft aber nicht" if installed else
"nicht installiert")}}
def _do_adb_provision(params: dict) -> dict:
"""Richtet den ARIA-Agent auf dem Geraet FERN ein (RVS-Zugang uebergeben).
Auf einem Fire TV gibt es keine Kamera (kein QR-Scan) und Tippen mit der
Fernbedienung ist muehsam. Der Satellit startet die App deshalb per ADB
direkt mit den Zugangsdaten als Intent-Extras.
params: {ip, rvs_host, rvs_token, rvs_port?, rvs_tls?, name?, control?,
reply_mode?, package?}
"""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
rvs_host = (params.get("rvs_host") or "").strip()
rvs_token = (params.get("rvs_token") or "").strip()
if not rvs_host or not rvs_token:
return {"ok": False, "error": "rvs_host und rvs_token erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
state = _adb_state(target)
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
authed, state = _adb_wait_authorized(target, wait)
if not authed:
return {"ok": False, "error":
"Nicht autorisiert — 'USB-Debugging zulassen?' auf dem "
f"Bildschirm bestaetigen. (Zustand: '{state or '?'}')"}
elif not ok:
return {"ok": False, "error": msg}
if not _adb_app_installed(target, package):
return {"ok": False, "error": f"'{package}' ist nicht installiert — "
"erst adb.install ausfuehren."}
# WICHTIG: 'adb shell' haengt die Argumente zu EINER Kommandozeile zusammen,
# die auf dem Geraet nochmal von der Shell zerlegt wird. Werte mit Leerzeichen
# ("Wohnzimmer Fire TV") wuerden dabei abgeschnitten -> jeden Wert quoten.
import shlex
q = shlex.quote
# Der Intent MUSS eindeutig sein: Android vergleicht beim Start Komponente,
# Action und Daten — NICHT die Extras. Ohne eigene Daten-URI gilt ein zweites
# Provisioning als "gleicher Intent", die Activity wird nur nach vorn geholt
# und onCreate/onNewIntent laufen nie -> der alte Name bliebe stehen.
stamp = str(int(time.time() * 1000))
args = ["-s", target, "shell", "am", "start",
"-n", f"{package}/.MainActivity",
"-a", f"{package}.PROVISION",
"-d", q(f"aria://provision/{stamp}"),
"-e", "host", q(rvs_host),
"-e", "token", q(rvs_token),
"--ei", "port", str(int(params.get("rvs_port") or 443)),
"--ez", "tls", "true" if _as_bool(params.get("rvs_tls"), True) else "false",
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false"]
if params.get("name"):
args += ["-e", "name", q(str(params["name"]))]
if params.get("reply_mode"):
args += ["-e", "reply_mode", q(str(params["reply_mode"]))]
rc, out, err = _adb_run(args, timeout=60)
txt = f"{out} {err}".strip()
if rc != 0 or "Error" in txt:
return {"ok": False, "error": (txt or f"exit {rc}")[:400]}
# Android meldet das, wenn es den Intent als "schon offen" abtut und die
# Activity nur nach vorn holt — dann kaeme die neue Konfiguration NICHT an.
if "brought to the front" in txt.lower() or "not started" in txt.lower():
return {"ok": False, "error":
"Android hat den Einrichtungs-Intent nicht ausgeliefert (Activity "
"war schon offen). Auf dem Geraet die Agent-App einmal schliessen "
"und erneut 'Nur einrichten' druecken. Details: " + txt[:200]}
return {"ok": True, "result": {
"target": target, "package": package,
"message": f"Agent eingerichtet (RVS {rvs_host}) und gestartet"}}
def _as_bool(v, default: bool) -> bool:
if v is None:
return default
if isinstance(v, bool):
return v
return str(v).strip().lower() in ("1", "true", "yes", "on", "ja")
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)
state = _adb_state(target)
# Erstkontakt: Das Geraet zeigt jetzt "USB-Debugging zulassen?" auf dem
# Bildschirm. Statt abzubrechen warten wir auf die Bestaetigung — ein Klick
# im Diagnostic genuegt, kein zweiter Anlauf noetig.
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
logger.info("[adb] %s: warte bis zu %.0fs auf Autorisierung (Zustand '%s')",
target, wait, state or "?")
authed, state = _adb_wait_authorized(target, wait)
if not authed:
hint = ("Das Geraet hat die Verbindung nicht autorisiert. Auf dem "
"Bildschirm erscheint 'USB-Debugging zulassen?' — bitte "
"bestaetigen (Haken bei 'Immer erlauben'). "
f"Ich habe {wait:.0f}s gewartet, Zustand: '{state or 'nicht erreichbar'}'.")
return {"ok": False, "error": hint if (state or ok) else msg}
elif not ok:
return {"ok": False, "error": msg}
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():
res = {"target": target, "apk_mb": size_mb,
"message": f"APK installiert ({size_mb} MB)"}
# Direkt einrichten, wenn die RVS-Daten mitkamen — auf einem Fire TV
# spart das der Fernbedienung das Abtippen von Host und Token.
if params.get("rvs_host") and params.get("rvs_token"):
prov = _do_adb_provision(params)
res["provisioned"] = bool(prov.get("ok"))
if prov.get("ok"):
res["message"] += " und eingerichtet — der Agent sollte gleich online sein"
else:
res["message"] += (f". Einrichtung fehlgeschlagen: "
f"{prov.get('error', '?')}")
return {"ok": True, "result": res}
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 +1560,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 +1601,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 +1637,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
@@ -1381,7 +1865,8 @@ class Satellite:
def main() -> None:
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
logger.info("Netz: primary_ip=%s mac=%s alle=%s",
NET.get("primary_ip"), NET.get("mac") or "?", NET.get("ips"))
if NET.get("warning"):
logger.warning("⚠ %s", NET["warning"])
try: