Compare commits

...
21 Commits
Author SHA1 Message Date
duffyduck cc91497923 release: bump version to 0.2.5.1 2026-09-30 21:18:09 +02:00
duffyduckandClaude Opus 4.8 10e7faa92b fix(app): verlorene erste Sprachaufnahme — Watchdog, Reihenfolge, Startzustand
Drei App-seitige Ursachen fuer 'erst beim zweiten Aufnehmen' (tsc --noEmit: 0 Fehler):

1) No-Speech-Watchdog: Der Aufrufer uebergibt die Sprechpausen-Toleranz
   (sttEndpointMs, Default 2400ms, per Setting bis 1000ms). Die misst ab dem
   ENDE des Sprechens, der Watchdog lief aber ab dem START der Aufnahme. Bis ein
   Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s (600ms
   Mindestpuffer + 700ms Transkriptions-Takt + Netzweg). Schon kurzes Ueberlegen
   nach dem Gong brach die Aufnahme ab. Jetzt +2500ms Zuschlag.

2) stt_stream_start ging ERST NACH PcmStreamRecorder.start() raus — die ersten
   Chunks (200ms-Takt) trafen auf eine Bridge ohne Sitzung und wurden verworfen
   (Wortanfang weg; bei kurzen Kommandos blieb nichts uebrig). Jetzt davor.

3) recordingState wurde erst nach mehreren awaits auf 'recording' gesetzt. In
   diesem Fenster war isStreamingRecording() true, der Zustand aber 'idle': ein
   Abbruch dort liess den Start weiterlaufen, der Recorder blieb haengen und die
   NAECHSTE Aufnahme wurde abgelehnt. Zustand wird jetzt sofort gesetzt, plus
   Session-Guard nach den awaits.

Zusaetzlich: ohne RVS-Verbindung wird gar nicht erst aufgenommen (send()
verwirft still, kein Puffer) — nach App-Resume fiel sonst die erste Aufnahme
komplett aus. Nachtraeglich gepuffertes Audio waere sinnlos, die STT-Sitzung
laeuft in Echtzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 13:02:55 +02:00
duffyduckandClaude Opus 4.8 d030cd7bc6 fix(voice): verlorene Sprachnachrichten — Allow-List, Kaltstart, Dedup, Voice-Router
Vier unabhaengige Ursachen fuer 'erst beim zweiten Aufnehmen' bzw. 'wird gar
nicht verarbeitet' (server-/box-seitig, ohne App-Update wirksam):

1) rvs/server.js: stt_lease, stt_lease_request, stt_transcribe_blob,
   stt_transcribe_result und chat_ack fehlten in ALLOWED_TYPES und wurden STILL
   verworfen (dieselbe Falle wie bei worker_*/host_*). Folgen: das Lease lief
   immer in den 250ms-Timeout -> jede Aufnahme startete verspaetet und OHNE
   targetInstance nahmen alle STT-Worker dieselbe Session an (doppelte
   stt_endpoint); chat_ack kam nie an -> getippte Nachrichten galten in der App
   nach 3 Versuchen als fehlgeschlagen, obwohl sie verarbeitet waren.

2) xtts/whisper/bridge.py: Beim Kaltstart wurde die Session erst NACH dem
   Modell-Load angelegt — alle Chunks der ersten Aufnahme liefen ins Leere und
   der Take war weg (die zweite klappte, weil das Modell dann warm war). Session
   wird jetzt sofort angelegt, der Endpointer wartet solange (runner.model None).

3) bridge/aria_bridge.py: Text-Dedup-Fenster 5s -> 1.5s. Es soll nur den
   Zwilling zweier Worker abfangen; mit 5s fielen echte Wiederholungen weg —
   also genau das Nachsprechen nach einem verschluckten Take, und kurze
   Kommandos ('lauter', 'nächstes').

4) bridge/aria_bridge.py Voice-Router: Komma als Prefix-Trenner entfernt (nur
   noch ':' und '-'), Fuzzy-Schwelle 0.6 -> 0.82, Sticky wird nicht mehr bei
   jeder Nachricht verlaengert. Vorher matchten normale Saetze wie 'In Ordnung,
   mach mal ...' als Projekt-Prefix: die Nachricht landete still in einem
   Projekt und war im Hauptchat unsichtbar — verifiziert mit Beispielsaetzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:05:06 +02:00
duffyduckandClaude Opus 4.8 36641e6519 fix(proxy): 'spawn E2BIG' — System-Prompt ueber Datei statt Kommandozeile
Gefunden ueber die Brain-Logs: mehrere Nachrichten blieben unbeantwortet, weil
POST /chat mit 502 endete. Ursache:
  Proxy HTTP 500: {"error":{"message":"spawn E2BIG"}}
execve() begrenzt argv+envp ZUSAMMEN auf ARG_MAX — im Proxy-Container nur
131072 Bytes (getconf ARG_MAX). Der System-Prompt wurde als einzelnes Argument
uebergeben und war im Fehlerfall 76881 Zeichen gross (gepinnte Erinnerungen +
Skills + Projektkontext) -> Start scheitert, Turn verloren, App bekommt 502.
11 solcher Faelle im aktuellen Log.

Fix: --system-prompt-file <tmpfile> statt --system-prompt <text> (die CLI
unterstuetzt das). Damit faellt die Groessengrenze praktisch weg. Die Temp-Datei
wird bei close/error/catch wieder entfernt; schlaegt das Schreiben fehl, wird
wie bisher das Argument genutzt (kleine Prompts laufen weiter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 10:58:45 +02:00
duffyduckandClaude Opus 4.8 9b11c16479 fix(android-agent): Sprachausgabe — ARIAs echte Stimme statt (fehlender) Geraete-TTS
Der Agent meldete 'gesprochen', es kam aber kein Ton: Fire TV Sticks haben in
der Regel keine (deutsche) TTS-Engine, und wir haben weder setLanguage noch den
Rueckgabewert von speak() geprueft -> stiller Fehlschlag.

- speak/notify fordern jetzt ARIAs ECHTE Stimme ueber den bestehenden Serverpfad
  an: tts_request -> Bridge -> F5/XTTS -> audio_pcm, das der Agent abspielt.
  Keine Server-Aenderung noetig (beide Typen stehen in der RVS-Allow-List).
  VoiceSession merkt sich die angeforderte messageId und spielt genau dieses
  Audio ab — die Turn-Ownership bleibt sonst unveraendert (Handy redet nicht mit).
- Geraete-TTS nur noch auf ausdrueckliche Anforderung (engine='device') und nun
  ehrlich: fehlende Engine, fehlende deutsche Sprachdaten und ein abgelehntes
  speak() werden als Fehler gemeldet statt als Erfolg.
Android-Version 0.0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:47:22 +02:00
duffyduckandClaude Opus 4.8 8f9e3e5c79 feat: Jellyfin-Integration — ARIA steuert die Wiedergabe ueber die API
Statt sich durch die Fire-TV-Oberflaeche zu klicken (fehleranfaellig: ARIA ist am
Titel vorbeigescrollt und auf Home gelandet) spricht ARIA den Jellyfin-Server
direkt an: suchen -> Sitzung des Abspielgeraets finden -> abspielen.

- Diagnostic (Einstellungen > Integrationen): Jellyfin-Abschnitt mit MEHREREN
  Servern (+-Button; eigener, vom Kumpel ...), einer davon Standard. Pro Server
  Name/URL/API-Key + Test-Knopf (prueft /System/Info, meldet Name und Version).
  Gespeichert in /shared/config/jellyfin.json mit Datei-Rechten 0600 (API-Keys).
- Brain: jellyfin_search / jellyfin_devices / jellyfin_play / jellyfin_control.
  Serverwahl per Name oder Standard. 'resume' ist echt implementiert (liest
  PlaybackPositionTicks und setzt startPositionTicks) statt nur zu existieren.
  Fehlertexte sind handlungsleitend: 401 -> API-Key pruefen; kein Geraet -> die
  Jellyfin-App muss offen sein, sonst erst host_app_launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:23:30 +02:00
duffyduck f1ae18b658 release(agent): bump to 0.0.1.2 2026-09-30 02:51:29 +02:00
duffyduckandClaude Opus 4.8 27f9399eda feat(diagnostic): bereits vergebener Geraetename wird beim Ausrollen vorgeschlagen
Bisher stand im Namensfeld die technische Kennung aus der Netz-Erkennung
(F8FCE10B4E6C@AS-AFTKA[AirPlay]) — auch wenn das Geraet laengst 'Fire-TV
Schlafzimmer' heisst. Jetzt in dieser Reihenfolge:
  1. Name, den der Agent auf DIESEM Geraet traegt — Zuordnung ueber die IP, die
     der Agent neu in host_hello mitmeldet (localIp()); Server reicht sie durch.
  2. zuletzt hier eingegebener Name (localStorage je Satellit+IP).
  3. Name aus der Erkennung, aber nur wenn menschenlesbar — Seriennummern, IPs
     und Kennungen mit @/[]/: werden verworfen (Feld bleibt leer).
Android-Version 0.0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:49:38 +02:00
duffyduckandClaude Opus 4.8 c131820be0 feat(android-agent): Audio-Fokus — ARIA redet nicht mehr ueber den laufenden Film
Bisher wurde KEIN Audio-Fokus angefordert: Android mischt dann beide Tonspuren,
d.h. Netflix/Jellyfin/Spotify liefen in voller Lautstaerke weiter und ARIA war
kaum zu verstehen — und beim Zuhoeren nahm das Mikrofon den Filmton mit auf.

AudioFocusHelper mit drei Modi:
- speak/notify: AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK ('duck', Standard) -> Medien
  werden leiser und danach automatisch wieder lauter. Per focus-Param auch
  'pause' (Medien anhalten) oder 'none'.
- listen: AUDIOFOCUS_GAIN_TRANSIENT ('pause') -> Medien halten an, solange das
  Mikrofon offen ist; danach laufen sie weiter.
- Fokus wird in allen Pfaden zurueckgegeben (Completion, Fehler, stop, shutdown),
  sonst blieben die Medien dauerhaft leise.
Brain: focus-Parameter in host_notify/host_speak. Android-Version 0.0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:46:14 +02:00
duffyduckandClaude Opus 4.8 e43b44725b feat(android-agent): Bildschirm-Aufnahme nach Neustart automatisch scharf schalten
Die BERECHTIGUNGEN bleiben dauerhaft (pm grant / appops in appops.xml), die
MediaProjection-SITZUNG aber nicht — Android laesst sie nicht speichern. Nach
Reboot/App-Neustart war der Agent also verbunden, konnte aber keinen Screenshot
machen, bis jemand am Fernseher 'Bildschirm-Zugriff erlauben' drueckt.

- CaptureActivity: unsichtbarer Helfer (Theme.Translucent, noHistory,
  excludeFromRecents), fordert die Projektion an und reicht das Ergebnis an den
  Dienst. Mit gesetztem AppOp PROJECT_MEDIA laeuft das OHNE Dialog durch.
- AgentService startet sie beim Dienststart automatisch nach (2,5s Verzoegerung),
  aber nur wenn captureEnabled gesetzt ist — wer die Aufnahme nie wollte, bekommt
  auch keinen Dialog.
- AgentConfig.captureEnabled: wird gesetzt, sobald die Aufnahme einmal lief, und
  kann beim Fern-Einrichten mitgegeben werden (nur wenn der Satellit PROJECT_MEDIA
  wirklich erteilen konnte).
Android-Version 0.0.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:41:46 +02:00
duffyduckandClaude Opus 4.8 586a2bf134 feat: Brain-Tools fuer Overlay/Sprache + Rechte automatisch per ADB erteilen
1) ARIA hatte KEIN Werkzeug fuer die Einblendung — der Agent kann notify/speak/
   volume laengst, aber im Brain fehlten die Tools. Sie hat deshalb improvisiert
   ('cmd notification post', gibt es auf Android 9 nicht) und ist gescheitert.
   Neu: host_notify (Overlay/Sprache, mode ueberschreibbar), host_speak,
   host_volume — mit dem ausdruecklichen Hinweis, es NICHT ueber adb zu versuchen.
2) _adb_grant_permissions: appops PROJECT_MEDIA (Screenshots ohne Dialog) +
   SYSTEM_ALERT_WINDOW (Overlay) + pm grant RECORD_AUDIO (Mikro) werden beim
   Einrichten automatisch gesetzt — kein Zusammenklicken mit der Fernbedienung
   mehr. Best effort; was scheitert, wird als 'manuell noetig' gemeldet. Neue
   Aktion adb.permissions setzt sie einzeln nach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:37:20 +02:00
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
25 changed files with 1526 additions and 78 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20500
versionName "0.2.5.0"
versionCode 20501
versionName "0.2.5.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.0",
"version": "0.2.5.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+50 -15
View File
@@ -1141,7 +1141,7 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> {
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
@@ -1155,12 +1155,28 @@ class AudioService {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
if (rvs.getState() !== 'connected') {
console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)',
rvs.getState());
return { requestId: '', ok: false, error: 'not-connected' };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
this.setState('recording');
this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
@@ -1202,20 +1218,17 @@ class AudioService {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
// Sitzung zu starten, die niemand mehr erwartet.
if (this.streamRequestId !== requestId) {
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
return { requestId: '', ok: false, error: 'superseded' };
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
// gar nichts uebrig.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
@@ -1230,18 +1243,40 @@ class AudioService {
targetInstance: this.streamTargetInstance,
});
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
// der zweite Versuch klappte, weil man da sofort losredet.
const NO_SPEECH_GRACE_MS = 2500;
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
requestId.slice(0, 12), effectiveMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}, effectiveMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
+317 -3
View File
@@ -1501,6 +1501,151 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_search",
"description": (
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
"zuverlaessig und schnell. Server werden im Diagnostic unter "
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_devices",
"description": (
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
"und frage dann erneut."
),
"parameters": {
"type": "object",
"properties": {
"server": {"type": "string", "description": "Server-Name (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_play",
"description": (
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
"3. jellyfin_play. Laeuft die Jellyfin-App auf dem Zielgeraet noch nicht, "
"vorher host_app_launch (query='Jellyfin') aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
"server": {"type": "string", "description": "Server-Name (optional)."},
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
},
"required": ["item_id", "device"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_control",
"description": (
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
"unpause, stop, nextTrack, previousTrack."
),
"parameters": {
"type": "object",
"properties": {
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
"server": {"type": "string", "description": "Server-Name (optional)."},
},
"required": ["device", "command"],
},
},
},
{
"type": "function",
"function": {
"name": "host_notify",
"description": (
"Zeigt eine Nachricht AUF einem Host-Agenten an — bei einem Fire TV / "
"Android TV als EINBLENDUNG ueber dem laufenden Bild (Netflix & Co. "
"laufen weiter), wahlweise als Sprachausgabe. NIMM DIESES TOOL, wenn "
"Stefan etwas 'auf dem Fernseher einblenden/anzeigen' will — versuche "
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
"Berechtigung 'Ueber anderen Apps anzeigen'."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
"text": {"type": "string", "description": "Der anzuzeigende Text."},
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
"mode": {"type": "string", "description": "overlay | speech | both"},
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_speak",
"description": (
"Laesst einen Host-Agenten Text laut vorlesen (Fire TV: ueber die "
"Fernseher-Lautsprecher). Fuer reine Sprachansagen; soll es auch/nur "
"sichtbar sein, nimm host_notify."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"text": {"type": "string", "description": "Was gesprochen werden soll."},
"focus": {"type": "string", "description": "'duck' (Medien leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_volume",
"description": "Setzt die Medien-Lautstaerke eines Host-Agenten (Fire TV / Android).",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"percent": {"type": "integer", "description": "0-100."},
},
"required": ["host", "percent"],
},
},
},
{
"type": "function",
"function": {
@@ -2670,8 +2815,10 @@ class Agent:
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft.
slow = action.startswith("adb.") or action == "ssh.exec"
sat_timeout = 200.0 if slow else 20.0
# 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,
@@ -2684,6 +2831,165 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
# ── Jellyfin (Medien) ─────────────────────────────────────
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
try:
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
servers = (json.load(f) or {}).get("servers") or []
except Exception:
return None
servers = [s for s in servers if s.get("url")]
if not servers:
return None
w = (wanted or "").strip().lower()
if w:
for s in servers:
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
return s
for s in servers:
if s.get("isDefault"):
return s
return servers[0]
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
params: Optional[dict] = None, timeout: float = 15.0):
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
url = server["url"].rstrip("/") + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method, headers={
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
return (json.loads(raw) if raw.strip() else {}), ""
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
"unter Integrationen pruefen.")
return None, f"Jellyfin-Fehler HTTP {exc.code}"
except Exception as exc:
return None, f"Jellyfin nicht erreichbar: {exc}"
def _jellyfin_find_session(self, server: dict, device: str):
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext)."""
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return None, err
d = (device or "").strip().lower()
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
for s in cands:
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
if d and d in blob:
return s, ""
if not d and len(cands) == 1:
return cands[0], ""
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
f"(query='Jellyfin') aufrufen.")
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
server = self._jellyfin_server(arguments.get("server") or "")
if not server:
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
"API-Key anlegen.")
label = server.get("name") or server.get("url")
try:
if name == "jellyfin_search":
q = (arguments.get("query") or "").strip()
if not q:
return "FEHLER: query ist Pflicht."
data, err = self._jellyfin_call(server, "/Items", params={
"searchTerm": q, "Recursive": "true",
"IncludeItemTypes": "Movie,Series,Episode",
"Limit": int(arguments.get("limit") or 10),
"Fields": "ProductionYear,Overview",
})
if err:
return f"FEHLER: {err}"
items = (data or {}).get("Items") or []
if not items:
return f"Keine Treffer fuer '{q}' auf {label}."
lines = []
for it in items:
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
f"[{it.get('Type')}] itemId={it.get('Id')}")
return f"Treffer auf {label}:\n" + "\n".join(lines)
if name == "jellyfin_devices":
sessions, err = self._jellyfin_call(server, "/Sessions")
if err:
return f"FEHLER: {err}"
out = []
for s in (sessions or []):
if not s.get("SupportsRemoteControl"):
continue
now = (s.get("NowPlayingItem") or {}).get("Name")
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
+ (f" — laeuft gerade: {now}" if now else ""))
if not out:
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
if name == "jellyfin_play":
item_id = (arguments.get("item_id") or "").strip()
device = (arguments.get("device") or "").strip()
if not item_id or not device:
return "FEHLER: item_id und device sind Pflicht."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
note = ""
if arguments.get("resume") is not False:
# Fortsetzen: gespeicherte Position des Titels mitgeben.
info, _e = self._jellyfin_call(server, "/Items",
params={"ids": item_id, "Fields": "UserData"})
items = (info or {}).get("Items") or []
ticks = 0
if items:
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
if ticks > 0:
play_params["startPositionTicks"] = ticks
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
params=play_params)
if err:
return f"FEHLER: {err}"
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' gestartet"
f"{note} (Server {label}).")
if name == "jellyfin_control":
device = (arguments.get("device") or "").strip()
cmd = (arguments.get("command") or "").strip()
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
"playpause", "seek"}
if cmd.lower() not in allowed:
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
if err:
return f"FEHLER: {err}"
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {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
@@ -2862,6 +3168,9 @@ class Agent:
"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"]),
"host_notify": ("notify", ["text", "title", "seconds", "mode", "focus"]),
"host_speak": ("speak", ["text", "focus"]),
"host_volume": ("volume", ["percent"]),
}
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
@@ -3669,10 +3978,15 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump",
"host_ui_tap", "host_ui_text", "host_ui_swipe",
"host_ui_key", "host_app_launch"):
"host_ui_key", "host_app_launch",
"host_notify", "host_speak", "host_volume"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+23 -7
View File
@@ -3407,11 +3407,18 @@ class ARIABridge:
# weiter auseinander (man hoert den Wechsel erst).
_norm_txt = " ".join(text.lower().split())
_now_t = asyncio.get_event_loop().time()
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
# Idempotenz ueber audioRequestId weiter oben.
_DUPE_WINDOW_SEC = 1.5
if (_norm_txt
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
text[:60])
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
_DUPE_WINDOW_SEC, text[:60])
return
self._last_endpoint_norm = _norm_txt
self._last_endpoint_at = _now_t
@@ -3777,8 +3784,12 @@ class ARIABridge:
r")\s*[.!?]?\s*$",
re.IGNORECASE,
)
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
_VOICE_META_PROJECT_PREFIX = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
@@ -3833,7 +3844,10 @@ class ARIABridge:
s = SequenceMatcher(None, q, pname).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
# Aehnlichkeit, und die Nachricht verschwand still in einem
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
if best and best_score >= 0.82:
pid = best["id"]
self._voice_sticky_project_id = pid
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
@@ -3845,8 +3859,10 @@ class ARIABridge:
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
# 3) Kein Meta / Prefix → Sticky oder Default
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
# Sticky refreshen
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
# Zeit aus und der Hauptchat uebernimmt wieder.
return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen
self._voice_sticky_project_id = ""
+261 -6
View File
@@ -961,6 +961,33 @@
</div>
</div>
<!-- Jellyfin (Medien) -->
<div class="settings-section">
<h2>Jellyfin (Filme &amp; Serien)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
Standard, wenn ARIA keinen nennt.
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
nicht des Browsers).
</div>
<div class="card" style="max-width:780px;">
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="color:#555570;font-style:italic;">Lade …</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
</div>
<div style="color:#666680;font-size:10px;margin-top:8px;">
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -1605,8 +1632,9 @@
<div style="color:#8888AA;margin-bottom:10px;">
Installiert die Agent-App per ADB über das Netzwerk. Voraussetzung am Gerät:
<strong>ADB-Debugging aktiv</strong> (Fire TV: Einstellungen › Mein Fire TV ›
Entwickleroptionen) — beim ersten Mal erscheint dort ein Dialog
„USB-Debugging zulassen?", den Du bestätigen musst.
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;"
@@ -1615,8 +1643,28 @@
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>
@@ -4516,6 +4564,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
@@ -4706,11 +4755,136 @@
'<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 });
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
async function loadJellyfinConfig() {
try {
const r = await fetch('/api/jellyfin-config');
const d = await r.json();
jellyfinServers = d.servers || [];
} catch (e) {
jellyfinServers = [];
}
renderJellyfin();
}
function addJellyfinServer() {
jellyfinServers.push({
id: 'jf_' + Date.now().toString(36),
name: '', url: '', apiKey: '',
isDefault: jellyfinServers.length === 0,
});
renderJellyfin();
}
function removeJellyfinServer(i) {
const s = jellyfinServers[i];
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
jellyfinServers.splice(i, 1);
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
jellyfinServers[0].isDefault = true;
}
renderJellyfin();
}
function setJellyfinDefault(i) {
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
renderJellyfin();
}
function updateJellyfinField(i, field, value) {
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
}
function renderJellyfin() {
const box = document.getElementById('jellyfin-list');
if (!box) return;
if (!jellyfinServers.length) {
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
return;
}
box.innerHTML = jellyfinServers.map((s, i) =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'</div>' +
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'</div>'
).join('');
}
async function testJellyfinServer(i) {
const s = jellyfinServers[i];
const out = document.getElementById('jf-test-' + i);
if (!s || !out) return;
out.textContent = 'prüfe …';
try {
const r = await fetch('/api/jellyfin-test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
});
const d = await r.json();
out.innerHTML = d.ok
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
} catch (e) {
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
async function saveJellyfinConfig() {
const el = document.getElementById('jellyfin-status');
el.textContent = 'speichere …';
try {
const r = await fetch('/api/jellyfin-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: jellyfinServers }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
jellyfinServers = d.config.servers || [];
renderJellyfin();
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
setTimeout(() => { el.textContent = ''; }, 4000);
} catch (e) {
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
let apkTarget = null; // {satId, ip, name}
let apkRequestId = null;
@@ -4721,6 +4895,20 @@
document.getElementById('apk-status').textContent = '';
const log = document.getElementById('apk-log');
log.style.display = 'none'; log.textContent = '';
// Namensfeld vorbelegen. Reihenfolge:
// 1. Name, den der Agent auf DIESEM Gerät schon trägt (per IP zugeordnet)
// 2. zuletzt hier eingegebener Name (im Browser gemerkt)
// 3. der Name aus der Netz-Erkennung — aber nur, wenn er menschenlesbar ist
// (Fire TV meldet oft Seriennummern wie F8FCE10B4E6C@AS-AFTKA[AirPlay])
const known = (hosts || []).find(h => h.ip && h.ip === ip);
const remembered = (function () {
try { return localStorage.getItem('apkName:' + satId + ':' + ip) || ''; }
catch (e) { return ''; }
})();
const looksTechnical = !name || /^[0-9a-f]{8,}$/i.test(name) ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /[@\[\]:]/.test(name);
document.getElementById('apk-name').value =
(known && known.name) || remembered || (looksTechnical ? '' : name);
document.getElementById('apk-modal').classList.add('open');
}
function closeApkDeploy() {
@@ -4732,9 +4920,63 @@
const url = document.getElementById('apk-url').value.trim();
if (!url) { document.getElementById('apk-status').textContent = 'APK-URL fehlt'; return; }
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').textContent = 'läuft … (kann 1–2 Minuten dauern)';
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;
rememberApkName(params.name);
}
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
requestId: apkRequestId, params: { ip: apkTarget.ip, url } });
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: rememberApkName(document.getElementById('apk-name').value.trim()
|| apkTarget.name) } });
}
/** Merkt sich den vergebenen Namen pro Gerät (Vorschlag beim naechsten Mal). */
function rememberApkName(n) {
try {
if (apkTarget && n) localStorage.setItem('apkName:' + apkTarget.satId + ':' + apkTarget.ip, n);
} catch (e) { /* privater Modus o.ae. */ }
return n;
}
/** 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;
@@ -4744,7 +4986,19 @@
if (p.ok) {
const r = p.result || {};
status.innerHTML = '<span style="color:#34C759;">✓ erfolgreich</span>';
log.textContent = r.message || JSON.stringify(r, null, 2);
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';
@@ -4893,7 +5147,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>';
+137 -3
View File
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
return cur;
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
function readJellyfinConfig() {
try {
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
const servers = Array.isArray(p.servers) ? p.servers : [];
return {
servers: servers.map(s => ({
id: String(s.id || ""),
name: String(s.name || ""),
url: String(s.url || ""),
apiKey: String(s.apiKey || ""),
isDefault: !!s.isDefault,
})).filter(s => s.id && s.url),
};
} catch {
return { servers: [] };
}
}
function writeJellyfinConfig(cfg) {
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
.map(s => ({
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
name: String(s.name || "").trim(),
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
apiKey: String(s.apiKey || "").trim(),
isDefault: !!s.isDefault,
}))
.filter(s => s.url);
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
let seen = false;
for (const s of servers) {
if (s.isDefault && seen) s.isDefault = false;
if (s.isDefault) seen = true;
}
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
return { servers };
}
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
function testJellyfin(url, apiKey) {
return new Promise((resolve) => {
let u;
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
const mod = u.protocol === "https:" ? require("https") : require("http");
const req = mod.request(u, {
method: "GET",
timeout: 8000,
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
}, (res) => {
let body = "";
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
res.on("end", () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
}
if (res.statusCode !== 200) {
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
}
try {
const j = JSON.parse(body);
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
});
});
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
@@ -557,7 +638,7 @@ function hostList() {
const now = Date.now();
return Array.from(hosts.values()).map(h => ({
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
online: (now - (h.last_seen || 0)) < 300000,
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
}));
}
@@ -1087,7 +1168,8 @@ function connectRVS(forcePlain) {
const wasKnown = hosts.has(p.hostId);
hosts.set(p.hostId, {
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
caps: p.caps || [], control: !!p.control, ip: p.ip || "",
last_seen: Date.now(),
});
if (!wasKnown) broadcastHosts();
else hosts.get(p.hostId).last_seen = Date.now();
@@ -2025,6 +2107,39 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readJellyfinConfig()));
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeJellyfinConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", async () => {
try {
const { url, apiKey } = JSON.parse(body);
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -2812,6 +2927,14 @@ 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.
@@ -2819,13 +2942,24 @@ wss.on("connection", (ws) => {
} 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: msg.params && typeof msg.params === "object" ? msg.params : {},
params,
requestId: String(msg.requestId || ""),
},
timestamp: Date.now(),
+19
View File
@@ -87,6 +87,25 @@ Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per A
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
+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 8
versionName '0.0.0.8'
versionCode 102
versionName '0.0.1.2'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -38,6 +38,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -48,6 +49,17 @@
</intent-filter>
</activity>
<!-- Unsichtbarer Helfer: schaltet die Bildschirm-Aufnahme nach einem
Neustart automatisch wieder scharf (die Sitzung ist nicht speicherbar,
nur die Berechtigung). Durchsichtig, ohne Animation -> man sieht nichts. -->
<activity
android:name=".CaptureActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name=".AgentService"
android:exported="false"
@@ -14,6 +14,10 @@ data class AgentConfig(
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
var replyMode: String = "overlay",
/** Soll die Bildschirm-Aufnahme nach einem Neustart automatisch wieder scharf
* geschaltet werden? Wird gesetzt, sobald sie einmal erlaubt wurde — die
* SITZUNG ist naemlich nicht speicherbar, nur die Berechtigung. */
var captureEnabled: Boolean = false,
) {
companion object {
private const val PREFS = "aria_agent"
@@ -28,6 +32,7 @@ data class AgentConfig(
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
captureEnabled = p.getBoolean("capture", false),
)
}
}
@@ -41,6 +46,7 @@ data class AgentConfig(
putString("name", name)
putBoolean("control", controlEnabled)
putString("replyMode", replyMode)
putBoolean("capture", captureEnabled)
apply()
}
}
@@ -93,6 +93,11 @@ class AgentService : Service() {
}
ScreenCapturer.start(applicationContext, code, data)
if (ScreenCapturer.active) {
// Merken: nach einem Neustart automatisch wieder scharf
// schalten (die Sitzung selbst ist nicht speicherbar).
AgentConfig.load(this).takeIf { !it.captureEnabled }?.let {
it.captureEnabled = true; it.save(this)
}
updateNotification("$status · Bildschirm-Zugriff aktiv")
} else {
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
@@ -123,6 +128,19 @@ class AgentService : Service() {
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
}
rvs?.start()
// Bildschirm-Aufnahme nach Geraete-/App-Neustart automatisch wieder scharf
// schalten. Die Berechtigung (AppOp PROJECT_MEDIA) bleibt erhalten, die
// SITZUNG nicht — ohne das muesste man am Fernseher jedes Mal von Hand
// "Bildschirm-Zugriff erlauben" druecken. Ist der AppOp gesetzt, laeuft
// die Anfrage ohne Dialog durch; sonst fragt Android einmal nach.
if (cfg.captureEnabled && !ScreenCapturer.active) {
android.os.Handler(mainLooper).postDelayed({
if (!ScreenCapturer.active) {
try { CaptureActivity.request(applicationContext) } catch (_: Throwable) {}
}
}, 2500)
}
return START_STICKY
}
@@ -0,0 +1,69 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
/**
* Audio-Fokus — damit ARIA nicht einfach ueber den laufenden Film redet.
*
* Ohne Fokus mischt Android beide Tonspuren: Netflix/Jellyfin/Spotify laufen in
* voller Lautstaerke weiter und ARIA ist kaum zu verstehen. Mit Fokus verhaelt
* sie sich wie ein normaler Sprachassistent:
*
* "duck" (Standard) laufende Medien werden LEISER, ARIA spricht darueber,
* danach geht die Lautstaerke von selbst wieder hoch.
* "pause" laufende Medien werden PAUSIERT (Player reagieren auf
* den Fokusverlust) — sinnvoll beim Zuhoeren, damit das
* Mikrofon nicht den Filmton aufnimmt.
* "none" kein Fokus (alte Mischung).
*/
object AudioFocusHelper {
private var request: AudioFocusRequest? = null
private var holder: String = ""
/** Fordert den Fokus an. [mode] = duck | pause | none. */
@Synchronized
fun request(ctx: Context, mode: String, who: String): Boolean {
if (mode == "none") return true
abandon(ctx)
val gain = if (mode == "pause") AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
else AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
val usage = if (who == "listen") AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
else AudioAttributes.USAGE_ASSISTANT
return try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val req = AudioFocusRequest.Builder(gain)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(usage)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// Kurze Unterbrechungen nicht als "verloren" behandeln.
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { }
.build()
request = req
holder = who
am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} catch (_: Throwable) {
false
}
}
/** Gibt den Fokus zurueck — die Medien werden wieder lauter / laufen weiter. */
@Synchronized
fun abandon(ctx: Context) {
val req = request ?: return
request = null
holder = ""
try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (_: Throwable) {
}
}
}
@@ -0,0 +1,65 @@
package de.hackersoft.ariaagent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
/**
* Unsichtbarer Helfer, der die Bildschirm-Aufnahme (neu) scharf schaltet.
*
* Hintergrund: Die Berechtigung (AppOp PROJECT_MEDIA) bleibt dauerhaft erhalten,
* die MediaProjection-SITZUNG aber nicht — Android laesst sie grundsaetzlich
* nicht speichern, sie stirbt mit dem Prozess. Nach einem Neustart des Geraets
* oder der App muesste man sonst jedes Mal von Hand "Bildschirm-Zugriff erlauben"
* druecken (auf einem Fire TV mit der Fernbedienung besonders laestig).
*
* Diese Activity loest die Anfrage automatisch aus und reicht das Ergebnis an den
* Dienst weiter. Ist PROJECT_MEDIA per ADB erlaubt, laeuft das OHNE Dialog durch —
* der Nutzer sieht nichts (durchsichtiges Theme, kein Fenster-Inhalt).
*/
class CaptureActivity : Activity() {
companion object {
private const val REQ = 4711
/** Startet die (Neu-)Scharfschaltung aus dem Dienst heraus. */
fun request(ctx: Context) {
val i = Intent(ctx, CaptureActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
Intent.FLAG_ACTIVITY_NO_ANIMATION)
ctx.startActivity(i)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), REQ)
} catch (e: Throwable) {
ScreenCapturer.lastError = "Anfrage fehlgeschlagen: ${e.message}"
finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ && resultCode == RESULT_OK && data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, data)
androidx.core.content.ContextCompat.startForegroundService(this, i)
} else {
// Abgelehnt oder (ohne AppOp) Dialog weggeklickt — kein Drama, der
// Nutzer kann es in der App jederzeit nachholen.
ScreenCapturer.lastError =
"Bildschirm-Zugriff nicht erteilt (in der Agent-App nachholen)"
}
finish()
overridePendingTransition(0, 0)
}
}
@@ -122,7 +122,10 @@ 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.
@@ -209,6 +212,53 @@ 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,
// Der Satellit erteilt beim Einrichten PROJECT_MEDIA — dann darf sich
// die Bildschirm-Aufnahme nach jedem Neustart selbst scharf schalten.
captureEnabled = intent.getBooleanExtra("capture", old.captureEnabled),
)
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 }
@@ -126,11 +126,24 @@ class RvsClient(
p.put("name", config.displayName())
val kind = if (isTv) "Fire TV / Android TV" else "Android"
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
// Eigene LAN-IP mitmelden: so kann die Diagnostic diesen Agenten dem im
// Netz entdeckten Geraet zuordnen (z.B. um beim Ausrollen den bereits
// vergebenen Namen vorzuschlagen statt der technischen Kennung).
localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
}
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
?.hostAddress
} catch (_: Throwable) { null }
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
@@ -318,7 +331,7 @@ class RvsClient(
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen" }
?: run { done += "gesprochen (ARIAs Stimme)" }
}
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
@@ -329,19 +342,33 @@ class RvsClient(
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 how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
val r = speakOut(p, text)
return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) },
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS. */
/**
* Sprachausgabe. Reihenfolge:
* 1. mitgeliefertes Audio abspielen,
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
* Sticks haben oft gar keine (deutsche) TTS-Engine,
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
* (engine='device') oder wenn kein Server-Audio moeglich ist.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
*/
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)
val focus = p.optString("focus").ifBlank { "duck" }
if (audio.isNotBlank())
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
}
private fun doVolume(p: JSONObject): JSONObject {
@@ -24,28 +24,38 @@ object Speaker {
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 {
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher.
* [focus] = duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
fun play(ctx: Context, audioB64: String, format: String = "wav",
focus: String = "duck"): Result<Unit> = runCatching {
stop()
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
f.writeBytes(bytes)
// Erst Fokus holen, damit Netflix & Co. leiser werden (sonst redet ARIA
// einfach mittendrin mit und man versteht beides nicht).
AudioFocusHelper.request(ctx, focus, "speak")
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(f.absolutePath)
setOnCompletionListener { mp -> try { mp.release() } catch (_: Exception) {}; player = null }
setOnCompletionListener { mp ->
try { mp.release() } catch (_: Exception) {}
player = null
AudioFocusHelper.abandon(ctx) // Medien wieder laut / weiterlaufen
}
setOnErrorListener { _, _, _ -> AudioFocusHelper.abandon(ctx); false }
prepare()
start()
}
}
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
fun speak(ctx: Context, text: String): Result<Unit> = runCatching {
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
if (tts == null) {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
@@ -55,8 +65,26 @@ object Speaker {
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")
if (!ttsReady) throw IllegalStateException(
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
throw IllegalStateException(
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
AudioFocusHelper.request(ctx, focus, "speak")
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) { AudioFocusHelper.abandon(ctx) }
@Deprecated("Deprecated in Java")
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
})
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
if (rc != TextToSpeech.SUCCESS) {
AudioFocusHelper.abandon(ctx)
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
}
Unit
}
@@ -68,9 +96,10 @@ object Speaker {
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
}
fun stop() {
fun stop(ctx: Context? = null) {
try { player?.stop(); player?.release() } catch (_: Exception) {}
player = null
try { tts?.stop() } catch (_: Exception) {}
ctx?.let { AudioFocusHelper.abandon(it) }
}
}
@@ -61,6 +61,10 @@ class VoiceSession(
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = ""
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ────────────────────────────────────────
@@ -100,6 +104,9 @@ class VoiceSession(
rec = r
recording = true
// Beim Zuhoeren laufende Medien ANHALTEN — sonst nimmt das Mikrofon den
// Filmton mit auf und die Spracherkennung versteht nur Kauderwelsch.
AudioFocusHelper.request(appCtx, "pause", "listen")
send("stt_stream_start", JSONObject().apply {
put("requestId", sid)
@@ -154,6 +161,9 @@ class VoiceSession(
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
rec = null; aec = null; ns = null
// Aufnahme vorbei -> Medien laufen weiter (die Antwort holt sich gleich
// selbst wieder Fokus, sobald ARIAs Audio kommt).
AudioFocusHelper.abandon(appCtx)
sessionId?.let {
send("stt_stream_end", JSONObject().apply {
put("requestId", it); put("reason", reason); put("targetInstance", "")
@@ -164,6 +174,32 @@ class VoiceSession(
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
return mid
}
/** 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")
@@ -200,10 +236,15 @@ class VoiceSession(
true
}
"audio_pcm" -> {
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
val mid = p.optString("messageId")
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p)
if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true
}
else -> false
@@ -245,6 +286,8 @@ class VoiceSession(
try { track?.stop() } catch (_: Exception) {}
try { track?.release() } catch (_: Exception) {}
track = null; playingMessageId = null
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
AudioFocusHelper.abandon(appCtx)
onEvent("bereit")
return
}
@@ -253,6 +296,8 @@ class VoiceSession(
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
if (track == null || playingMessageId != mid) {
try { track?.release() } catch (_: Exception) {}
// ARIA antwortet -> laufende Medien leiser (duck), bis sie fertig ist.
AudioFocusHelper.request(appCtx, "duck", "speak")
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT)
track = AudioTrack.Builder()
@@ -276,5 +321,6 @@ class VoiceSession(
stopListening("shutdown")
try { track?.stop(); track?.release() } catch (_: Exception) {}
track = null
AudioFocusHelper.abandon(appCtx)
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ 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.8"
AGENT_VERSION = "0.0.1.2"
HEARTBEAT_SEC = 25
+43 -1
View File
@@ -18,6 +18,9 @@
*/
import { spawn } from "child_process";
import { EventEmitter } from "events";
import fs from "fs";
import os from "os";
import path from "path";
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
export class ClaudeSubprocess extends EventEmitter {
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
timeoutId = null;
isKilled = false;
_stdinClosed = false;
_systemPromptFile = null;
_systemPromptDir = null;
/**
* Start the Claude CLI subprocess with the given prompt
*/
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
// Handle spawn errors (e.g., claude not found)
this.process.on("error", (err) => {
this.clearTimeout();
this._cleanupSystemPrompt();
if (err.message.includes("ENOENT")) {
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
}
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
this.process.on("close", (code) => {
console.error(`[Subprocess] Process closed with code: ${code}`);
this.clearTimeout();
this._cleanupSystemPrompt();
// Process any remaining buffer
if (this.buffer.trim()) {
this.processBuffer();
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
}
catch (err) {
this.clearTimeout();
this._cleanupSystemPrompt();
reject(err);
}
});
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
"--model",
options.model, // Model alias (opus/sonnet/haiku)
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
];
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
// praktisch keine Groessengrenze mehr.
this._systemPromptFile = null;
const sysPrompt = options.systemPrompt || "";
if (sysPrompt) {
try {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
const file = path.join(dir, "system-prompt.txt");
fs.writeFileSync(file, sysPrompt, "utf-8");
this._systemPromptFile = file;
this._systemPromptDir = dir;
args.push("--system-prompt-file", file);
}
catch (err) {
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
args.push("--system-prompt", sysPrompt);
}
}
if (options.sessionId) {
args.push("--session-id", options.sessionId);
}
return args;
}
/** Temporaere System-Prompt-Datei wieder entfernen. */
_cleanupSystemPrompt() {
try {
if (this._systemPromptFile)
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
}
catch (_) { }
this._systemPromptFile = null;
this._systemPromptDir = null;
}
/**
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
+9
View File
@@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([
// 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",
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
"chat_ack",
// 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).
+6 -1
View File
@@ -45,9 +45,14 @@ CONTROL_ENABLED=true
# 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)
# adb.permissions Sonderrechte per ADB erteilen (Bildschirmaufnahme/Overlay/
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# 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
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.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+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):
+288 -12
View File
@@ -135,7 +135,7 @@ CONTROL_ALLOWLIST = [
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,"
"adb.devices,adb.connect,adb.install,adb.shell").split(",")
"adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status").split(",")
if a.strip()
]
@@ -145,6 +145,10 @@ CONTROL_ALLOWLIST = [
# 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))
@@ -329,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()
@@ -588,6 +633,12 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
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)
if action == "adb.permissions":
return await loop.run_in_executor(None, _do_adb_permissions, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -1121,6 +1172,28 @@ def _adb_state(target: str) -> str:
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)
@@ -1140,10 +1213,190 @@ def _do_adb_connect(params: dict) -> dict:
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}
return {"ok": True, "result": {"target": target, "state": _adb_state(target),
"message": 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 _adb_grant_permissions(target: str, package: str) -> dict:
"""Erteilt dem Agenten die Sonderrechte per ADB — das spart das Antippen mit
der Fernbedienung (auf einem Fire TV besonders muehsam):
PROJECT_MEDIA Bildschirmaufnahme ohne Nachfrage-Dialog (Screenshots)
SYSTEM_ALERT_WINDOW Einblendung ueber dem laufenden Bild (Overlay)
RECORD_AUDIO Mikrofon (Sprachdialog)
Best effort: was nicht geht, wird gemeldet, bricht aber nichts ab."""
granted, failed = [], []
ops = [("appops", ["appops", "set", package, "PROJECT_MEDIA", "allow"], "Bildschirmaufnahme"),
("appops", ["appops", "set", package, "SYSTEM_ALERT_WINDOW", "allow"], "Einblendung")]
perms = [("android.permission.RECORD_AUDIO", "Mikrofon")]
for _, cmd, label in ops:
rc, out, err = _adb_run(["-s", target, "shell"] + cmd, timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
for perm, label in perms:
rc, out, err = _adb_run(["-s", target, "shell", "pm", "grant", package, perm], timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
return {"granted": granted, "failed": failed}
def _do_adb_permissions(params: dict) -> dict:
"""Nur die Berechtigungen setzen (ohne Neuinstallation/Einrichtung)."""
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}
r = _adb_grant_permissions(target, package)
msg = "erteilt: " + (", ".join(r["granted"]) or "—")
if r["failed"]:
msg += " · nicht moeglich: " + ", ".join(r["failed"])
return {"ok": True, "result": {"target": target, "message": msg, **r}}
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."}
# Sonderrechte gleich mit erteilen (Bildschirm/Overlay/Mikro) — sonst muesste
# man sie am Fernseher mit der Fernbedienung zusammenklicken.
perms = ({"granted": [], "failed": []} if params.get("skip_permissions")
else _adb_grant_permissions(target, package))
# 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",
# Nur wenn die Bildschirm-Berechtigung wirklich sitzt: dann darf sich
# die Aufnahme nach jedem Neustart selbst scharf schalten (dialogfrei).
"--ez", "capture", "true" if "Bildschirmaufnahme" in perms.get("granted", []) 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]}
msg = f"Agent eingerichtet (RVS {rvs_host}) und gestartet"
if perms.get("granted"):
msg += " · Rechte erteilt: " + ", ".join(perms["granted"])
if perms.get("failed"):
msg += " · manuell noetig: " + ", ".join(perms["failed"])
return {"ok": True, "result": {
"target": target, "package": package, "permissions": perms, "message": msg}}
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:
@@ -1241,12 +1494,23 @@ def _do_adb_install(params: dict) -> dict:
return {"ok": False, "error": err}
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
state = _adb_state(target)
if state and state != "device":
return {"ok": False, "error": f"Geraet im Zustand '{state}' — "
"bei 'unauthorized' den Dialog auf dem Bildschirm bestaetigen."}
# 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"):
@@ -1256,8 +1520,19 @@ def _do_adb_install(params: dict) -> dict:
txt = f"{out} {err2}".strip()
size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1)
if rc == 0 and "success" in txt.lower():
return {"ok": True, "result": {"target": target, "apk_mb": size_mb,
"message": f"APK installiert ({size_mb} MB)"}}
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 — "
@@ -1640,7 +1915,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:
+15 -6
View File
@@ -584,6 +584,12 @@ class SessionManager:
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
# Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT
# transkribieren/finalisieren — sonst kaeme leerer Text heraus und die
# Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter.
if self.runner.model is None:
return
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
@@ -962,19 +968,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
# WICHTIG: Session SOFORT anlegen — auch wenn das Modell
# noch laedt. Sonst laufen alle stt_audio_chunk der ersten
# Aufnahme ins Leere (feed_chunk findet keine Session) und
# der Take ist verloren; erst die zweite Aufnahme klappt,
# weil das Modell dann warm ist. Der Buffer fuellt sich
# jetzt waehrend des Ladens, das Transkribieren wartet.
sessions.start_session(payload)
if needs_load:
async def _load_then_start(p, target):
async def _load_only(target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
asyncio.create_task(_load_only(target_model))
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)