Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc91497923 | ||
|
|
10e7faa92b | ||
|
|
d030cd7bc6 | ||
|
|
36641e6519 | ||
|
|
9b11c16479 | ||
|
|
8f9e3e5c79 | ||
|
|
f1ae18b658 | ||
|
|
27f9399eda | ||
|
|
c131820be0 | ||
|
|
e43b44725b | ||
|
|
586a2bf134 | ||
|
|
ac40afc856 | ||
|
|
ff6d474f29 | ||
|
|
44aef71813 | ||
|
|
27381e7038 | ||
|
|
21d389cc26 | ||
|
|
eae036a9fc | ||
|
|
621b5525a8 | ||
|
|
6da567aee6 | ||
|
|
7071614434 | ||
|
|
8301342728 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20500
|
versionCode 20501
|
||||||
versionName "0.2.5.0"
|
versionName "0.2.5.1"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.5.0",
|
"version": "0.2.5.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -1141,7 +1141,7 @@ class AudioService {
|
|||||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}): Promise<{ requestId: string; ok: boolean }> {
|
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||||
if (this.recordingState !== 'idle') {
|
if (this.recordingState !== 'idle') {
|
||||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||||
return { requestId: '', ok: false };
|
return { requestId: '', ok: false };
|
||||||
@@ -1155,12 +1155,28 @@ class AudioService {
|
|||||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||||
return { requestId: '', ok: false };
|
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)
|
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
|
|
||||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
this.streamRequestId = requestId;
|
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 || '';
|
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||||
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
||||||
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
||||||
@@ -1202,20 +1218,17 @@ class AudioService {
|
|||||||
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = await PcmStreamRecorder.start();
|
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
|
||||||
if (!started) {
|
// Sitzung zu starten, die niemand mehr erwartet.
|
||||||
throw new Error('PcmStreamRecorder.start returned false');
|
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.
|
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
|
||||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
|
||||||
await this._captureMediaActive();
|
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
|
||||||
this._cancelDeferredFocusRelease();
|
// gar nichts uebrig.
|
||||||
AudioFocus?.requestExclusive().catch(() => {});
|
|
||||||
|
|
||||||
this.setState('recording');
|
|
||||||
|
|
||||||
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
|
||||||
rvs.send('stt_stream_start' as any, {
|
rvs.send('stt_stream_start' as any, {
|
||||||
requestId,
|
requestId,
|
||||||
audioRequestId: opts.audioRequestId || '',
|
audioRequestId: opts.audioRequestId || '',
|
||||||
@@ -1230,18 +1243,40 @@ class AudioService {
|
|||||||
targetInstance: this.streamTargetInstance,
|
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.
|
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||||
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
||||||
// hat der User vermutlich nix gesagt → Session beenden.
|
// 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);
|
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||||
if (noSpeechMs > 0) {
|
if (noSpeechMs > 0) {
|
||||||
|
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
|
||||||
this.streamNoSpeechTimer = setTimeout(() => {
|
this.streamNoSpeechTimer = setTimeout(() => {
|
||||||
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||||
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
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(() => {});
|
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||||
}
|
}
|
||||||
}, noSpeechMs);
|
}, effectiveMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||||
|
|||||||
+317
-3
@@ -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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -2670,8 +2815,10 @@ class Agent:
|
|||||||
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
|
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
|
||||||
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
|
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
|
||||||
# obwohl die Installation noch laeuft.
|
# obwohl die Installation noch laeuft.
|
||||||
slow = action.startswith("adb.") or action == "ssh.exec"
|
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
|
||||||
sat_timeout = 200.0 if slow else 20.0
|
# 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", {
|
result = _post("/internal/satellite", {
|
||||||
"op": "command", "satellite": sat,
|
"op": "command", "satellite": sat,
|
||||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||||
@@ -2684,6 +2831,165 @@ class Agent:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {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:
|
def _dispatch_host(self, name: str, arguments: dict) -> str:
|
||||||
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
|
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
|
||||||
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
|
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_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
|
||||||
"host_ui_key": ("ui_key", ["key"]),
|
"host_ui_key": ("ui_key", ["key"]),
|
||||||
"host_app_launch": ("app_launch", ["package", "query", "command"]),
|
"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:
|
if name in _UI_ACTIONS:
|
||||||
action, keys = _UI_ACTIONS[name]
|
action, keys = _UI_ACTIONS[name]
|
||||||
@@ -3669,10 +3978,15 @@ class Agent:
|
|||||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||||
return self._dispatch_satellite(name, arguments)
|
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",
|
if name in ("host_list", "host_exec", "host_read", "host_write",
|
||||||
"host_info", "host_screenshot", "host_ui_dump",
|
"host_info", "host_screenshot", "host_ui_dump",
|
||||||
"host_ui_tap", "host_ui_text", "host_ui_swipe",
|
"host_ui_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)
|
return self._dispatch_host(name, arguments)
|
||||||
if name == "vm_register":
|
if name == "vm_register":
|
||||||
pid = (project_id or "").strip()
|
pid = (project_id or "").strip()
|
||||||
|
|||||||
+23
-7
@@ -3407,11 +3407,18 @@ class ARIABridge:
|
|||||||
# weiter auseinander (man hoert den Wechsel erst).
|
# weiter auseinander (man hoert den Wechsel erst).
|
||||||
_norm_txt = " ".join(text.lower().split())
|
_norm_txt = " ".join(text.lower().split())
|
||||||
_now_t = asyncio.get_event_loop().time()
|
_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
|
if (_norm_txt
|
||||||
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
|
||||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
|
||||||
text[:60])
|
_DUPE_WINDOW_SEC, text[:60])
|
||||||
return
|
return
|
||||||
self._last_endpoint_norm = _norm_txt
|
self._last_endpoint_norm = _norm_txt
|
||||||
self._last_endpoint_at = _now_t
|
self._last_endpoint_at = _now_t
|
||||||
@@ -3777,8 +3784,12 @@ class ARIABridge:
|
|||||||
r")\s*[.!?]?\s*$",
|
r")\s*[.!?]?\s*$",
|
||||||
re.IGNORECASE,
|
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(
|
_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,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3833,7 +3844,10 @@ class ARIABridge:
|
|||||||
s = SequenceMatcher(None, q, pname).ratio()
|
s = SequenceMatcher(None, q, pname).ratio()
|
||||||
if s > best_score:
|
if s > best_score:
|
||||||
best, best_score = p, s
|
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"]
|
pid = best["id"]
|
||||||
self._voice_sticky_project_id = pid
|
self._voice_sticky_project_id = pid
|
||||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
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)
|
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||||
# Sticky refreshen
|
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
|
||||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
# 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")
|
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||||
# Sticky abgelaufen — zurücksetzen
|
# Sticky abgelaufen — zurücksetzen
|
||||||
self._voice_sticky_project_id = ""
|
self._voice_sticky_project_id = ""
|
||||||
|
|||||||
+261
-6
@@ -961,6 +961,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Jellyfin (Medien) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Jellyfin (Filme & 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 -->
|
<!-- FLUX Bildgenerierung -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>FLUX Bildgenerierung</h2>
|
<h2>FLUX Bildgenerierung</h2>
|
||||||
@@ -1605,8 +1632,9 @@
|
|||||||
<div style="color:#8888AA;margin-bottom:10px;">
|
<div style="color:#8888AA;margin-bottom:10px;">
|
||||||
Installiert die Agent-App per ADB über das Netzwerk. Voraussetzung am Gerät:
|
Installiert die Agent-App per ADB über das Netzwerk. Voraussetzung am Gerät:
|
||||||
<strong>ADB-Debugging aktiv</strong> (Fire TV: Einstellungen › Mein Fire TV ›
|
<strong>ADB-Debugging aktiv</strong> (Fire TV: Einstellungen › Mein Fire TV ›
|
||||||
Entwickleroptionen) — beim ersten Mal erscheint dort ein Dialog
|
Entwickleroptionen). Beim <strong>ersten Mal</strong> erscheint dort
|
||||||
„USB-Debugging zulassen?", den Du bestätigen musst.
|
„USB-Debugging zulassen?" — einfach bestätigen, während der Rollout läuft:
|
||||||
|
er wartet darauf und macht dann automatisch weiter.
|
||||||
</div>
|
</div>
|
||||||
<label style="display:block;margin-bottom:4px;color:#8888AA;">APK-URL</label>
|
<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;"
|
<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
|
Aus dem Gitea-Release. Der Satellit lädt die Datei selbst (einmal) und
|
||||||
installiert sie auf dem Gerät.
|
installiert sie auf dem Gerät.
|
||||||
</div>
|
</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;">
|
<div style="margin-top:14px;">
|
||||||
<button class="btn" onclick="runApkDeploy()">📦 Jetzt ausrollen</button>
|
<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>
|
<span id="apk-status" style="margin-left:10px;color:#8888AA;"></span>
|
||||||
</div>
|
</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>
|
<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();
|
loadRuntimeConfig();
|
||||||
loadOnboardingQR();
|
loadOnboardingQR();
|
||||||
loadOAuthServices();
|
loadOAuthServices();
|
||||||
|
loadJellyfinConfig();
|
||||||
refreshVoiceIdStatus();
|
refreshVoiceIdStatus();
|
||||||
} else if (tab === 'brain') {
|
} else if (tab === 'brain') {
|
||||||
loadBrainStatus();
|
loadBrainStatus();
|
||||||
@@ -4706,11 +4755,136 @@
|
|||||||
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
|
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
|
||||||
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
|
'<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="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>' +
|
||||||
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
|
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).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) ─────────
|
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
|
||||||
let apkTarget = null; // {satId, ip, name}
|
let apkTarget = null; // {satId, ip, name}
|
||||||
let apkRequestId = null;
|
let apkRequestId = null;
|
||||||
@@ -4721,6 +4895,20 @@
|
|||||||
document.getElementById('apk-status').textContent = '';
|
document.getElementById('apk-status').textContent = '';
|
||||||
const log = document.getElementById('apk-log');
|
const log = document.getElementById('apk-log');
|
||||||
log.style.display = 'none'; log.textContent = '';
|
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');
|
document.getElementById('apk-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
function closeApkDeploy() {
|
function closeApkDeploy() {
|
||||||
@@ -4732,9 +4920,63 @@
|
|||||||
const url = document.getElementById('apk-url').value.trim();
|
const url = document.getElementById('apk-url').value.trim();
|
||||||
if (!url) { document.getElementById('apk-status').textContent = 'APK-URL fehlt'; return; }
|
if (!url) { document.getElementById('apk-status').textContent = 'APK-URL fehlt'; return; }
|
||||||
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
|
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',
|
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) {
|
function onSatResult(p) {
|
||||||
if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
|
if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
|
||||||
@@ -4744,7 +4986,19 @@
|
|||||||
if (p.ok) {
|
if (p.ok) {
|
||||||
const r = p.result || {};
|
const r = p.result || {};
|
||||||
status.innerHTML = '<span style="color:#34C759;">✓ erfolgreich</span>';
|
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 {
|
} else {
|
||||||
status.innerHTML = '<span style="color:#FF6E6E;">✗ fehlgeschlagen</span>';
|
status.innerHTML = '<span style="color:#FF6E6E;">✗ fehlgeschlagen</span>';
|
||||||
log.textContent = p.error || 'Unbekannter Fehler';
|
log.textContent = p.error || 'Unbekannter Fehler';
|
||||||
@@ -4893,7 +5147,8 @@
|
|||||||
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
|
'<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>' : '') +
|
(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 +
|
devHtml +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|||||||
+137
-3
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
|
|||||||
return cur;
|
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) ────────────────
|
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
||||||
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
||||||
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
||||||
@@ -557,7 +638,7 @@ function hostList() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(hosts.values()).map(h => ({
|
return Array.from(hosts.values()).map(h => ({
|
||||||
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
|
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);
|
const wasKnown = hosts.has(p.hostId);
|
||||||
hosts.set(p.hostId, {
|
hosts.set(p.hostId, {
|
||||||
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
|
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();
|
if (!wasKnown) broadcastHosts();
|
||||||
else hosts.get(p.hostId).last_seen = Date.now();
|
else hosts.get(p.hostId).last_seen = Date.now();
|
||||||
@@ -2025,6 +2107,39 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
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") {
|
} else if (req.url === "/api/onboarding") {
|
||||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
@@ -2812,6 +2927,14 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "host_list") {
|
} else if (msg.action === "host_list") {
|
||||||
// Browser will die aktuelle Host-Agenten-Liste.
|
// Browser will die aktuelle Host-Agenten-Liste.
|
||||||
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
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") {
|
} else if (msg.action === "rooms_query") {
|
||||||
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
||||||
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
||||||
@@ -2819,13 +2942,24 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "sat_command") {
|
} else if (msg.action === "sat_command") {
|
||||||
// Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen).
|
// Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen).
|
||||||
// Die Antwort (sat_result) kommt ueber die rvsWs zurueck.
|
// 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({
|
sendToRVS_raw({
|
||||||
type: "sat_command",
|
type: "sat_command",
|
||||||
payload: {
|
payload: {
|
||||||
satellite: String(msg.satellite || ""),
|
satellite: String(msg.satellite || ""),
|
||||||
device: String(msg.device || ""),
|
device: String(msg.device || ""),
|
||||||
action: String(msg.cmd || ""),
|
action: String(msg.cmd || ""),
|
||||||
params: msg.params && typeof msg.params === "object" ? msg.params : {},
|
params,
|
||||||
requestId: String(msg.requestId || ""),
|
requestId: String(msg.requestId || ""),
|
||||||
},
|
},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per A
|
|||||||
und einen **📦**-Button.
|
und einen **📦**-Button.
|
||||||
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
|
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
|
||||||
erscheint auf dem TV „USB-Debugging zulassen?" → bestätigen (Haken „Immer erlauben").
|
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'`,
|
ARIA kann dasselbe: `satellite_command` mit `action='adb.install'`,
|
||||||
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
|
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId 'de.hackersoft.ariaagent'
|
applicationId 'de.hackersoft.ariaagent'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||||
versionCode 8
|
versionCode 102
|
||||||
versionName '0.0.0.8'
|
versionName '0.0.1.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -48,6 +49,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".AgentService"
|
android:name=".AgentService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ data class AgentConfig(
|
|||||||
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
|
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
|
||||||
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
|
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
|
||||||
var replyMode: String = "overlay",
|
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 {
|
companion object {
|
||||||
private const val PREFS = "aria_agent"
|
private const val PREFS = "aria_agent"
|
||||||
@@ -28,6 +32,7 @@ data class AgentConfig(
|
|||||||
name = p.getString("name", "") ?: "",
|
name = p.getString("name", "") ?: "",
|
||||||
controlEnabled = p.getBoolean("control", false),
|
controlEnabled = p.getBoolean("control", false),
|
||||||
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
|
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
|
||||||
|
captureEnabled = p.getBoolean("capture", false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,7 @@ data class AgentConfig(
|
|||||||
putString("name", name)
|
putString("name", name)
|
||||||
putBoolean("control", controlEnabled)
|
putBoolean("control", controlEnabled)
|
||||||
putString("replyMode", replyMode)
|
putString("replyMode", replyMode)
|
||||||
|
putBoolean("capture", captureEnabled)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ class AgentService : Service() {
|
|||||||
}
|
}
|
||||||
ScreenCapturer.start(applicationContext, code, data)
|
ScreenCapturer.start(applicationContext, code, data)
|
||||||
if (ScreenCapturer.active) {
|
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")
|
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||||
} else {
|
} else {
|
||||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||||
@@ -123,6 +128,19 @@ class AgentService : Service() {
|
|||||||
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||||
}
|
}
|
||||||
rvs?.start()
|
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
|
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),
|
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
|
||||||
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
|
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
|
||||||
@@ -209,6 +212,53 @@ class MainActivity : AppCompatActivity() {
|
|||||||
statusView.text = "Status: ${AgentService.status} $dot"
|
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. */
|
/** Sprech-Taste: startet/stoppt die Aufnahme im laufenden Dienst. */
|
||||||
private fun talkToAria() {
|
private fun talkToAria() {
|
||||||
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
|
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
|
||||||
|
|||||||
@@ -126,11 +126,24 @@ class RvsClient(
|
|||||||
p.put("name", config.displayName())
|
p.put("name", config.displayName())
|
||||||
val kind = if (isTv) "Fire TV / Android TV" else "Android"
|
val kind = if (isTv) "Fire TV / Android TV" else "Android"
|
||||||
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
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("caps", JSONArray(caps))
|
||||||
p.put("control", config.controlEnabled)
|
p.put("control", config.controlEnabled)
|
||||||
send(webSocket, "host_hello", p)
|
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) {
|
private fun startPing(webSocket: WebSocket) {
|
||||||
pingThread?.interrupt()
|
pingThread?.interrupt()
|
||||||
pingThread = Thread {
|
pingThread = Thread {
|
||||||
@@ -318,7 +331,7 @@ class RvsClient(
|
|||||||
if (mode == "speech" || mode == "both") {
|
if (mode == "speech" || mode == "both") {
|
||||||
val r = speakOut(p, text)
|
val r = speakOut(p, text)
|
||||||
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
|
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" })
|
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
|
||||||
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
|
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
|
||||||
@@ -329,19 +342,33 @@ class RvsClient(
|
|||||||
private fun doSpeak(p: JSONObject): JSONObject {
|
private fun doSpeak(p: JSONObject): JSONObject {
|
||||||
val text = p.optString("text")
|
val text = p.optString("text")
|
||||||
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
|
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)
|
val r = speakOut(p, text)
|
||||||
return r.fold(
|
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}") },
|
{ 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> {
|
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
|
||||||
val audio = p.optString("audio_base64")
|
val audio = p.optString("audio_base64")
|
||||||
return if (audio.isNotBlank())
|
val focus = p.optString("focus").ifBlank { "duck" }
|
||||||
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"))
|
if (audio.isNotBlank())
|
||||||
else Speaker.speak(appCtx, text)
|
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 {
|
private fun doVolume(p: JSONObject): JSONObject {
|
||||||
|
|||||||
@@ -24,28 +24,38 @@ object Speaker {
|
|||||||
private var ttsReady = false
|
private var ttsReady = false
|
||||||
private var player: MediaPlayer? = null
|
private var player: MediaPlayer? = null
|
||||||
|
|
||||||
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher. */
|
/** 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 {
|
* [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()
|
stop()
|
||||||
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
|
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
|
||||||
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
|
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
|
||||||
f.writeBytes(bytes)
|
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 {
|
player = MediaPlayer().apply {
|
||||||
setAudioAttributes(
|
setAudioAttributes(
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
setDataSource(f.absolutePath)
|
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()
|
prepare()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
|
/** 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) {
|
if (tts == null) {
|
||||||
tts = TextToSpeech(ctx.applicationContext) { status ->
|
tts = TextToSpeech(ctx.applicationContext) { status ->
|
||||||
ttsReady = status == TextToSpeech.SUCCESS
|
ttsReady = status == TextToSpeech.SUCCESS
|
||||||
@@ -55,8 +65,26 @@ object Speaker {
|
|||||||
var waited = 0
|
var waited = 0
|
||||||
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
|
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
|
||||||
}
|
}
|
||||||
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
|
if (!ttsReady) throw IllegalStateException(
|
||||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
"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
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +96,10 @@ object Speaker {
|
|||||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
|
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop(ctx: Context? = null) {
|
||||||
try { player?.stop(); player?.release() } catch (_: Exception) {}
|
try { player?.stop(); player?.release() } catch (_: Exception) {}
|
||||||
player = null
|
player = null
|
||||||
try { tts?.stop() } catch (_: Exception) {}
|
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). */
|
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
|
||||||
@Volatile private var lastReplyText: String = ""
|
@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
|
val isRecording: Boolean get() = recording
|
||||||
|
|
||||||
// ── Aufnehmen + streamen ────────────────────────────────────────
|
// ── Aufnehmen + streamen ────────────────────────────────────────
|
||||||
@@ -100,6 +104,9 @@ class VoiceSession(
|
|||||||
|
|
||||||
rec = r
|
rec = r
|
||||||
recording = true
|
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 {
|
send("stt_stream_start", JSONObject().apply {
|
||||||
put("requestId", sid)
|
put("requestId", sid)
|
||||||
@@ -154,6 +161,9 @@ class VoiceSession(
|
|||||||
try { aec?.release() } catch (_: Exception) {}
|
try { aec?.release() } catch (_: Exception) {}
|
||||||
try { ns?.release() } catch (_: Exception) {}
|
try { ns?.release() } catch (_: Exception) {}
|
||||||
rec = null; aec = null; ns = null
|
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 {
|
sessionId?.let {
|
||||||
send("stt_stream_end", JSONObject().apply {
|
send("stt_stream_end", JSONObject().apply {
|
||||||
put("requestId", it); put("reason", reason); put("targetInstance", "")
|
put("requestId", it); put("reason", reason); put("targetInstance", "")
|
||||||
@@ -164,6 +174,32 @@ class VoiceSession(
|
|||||||
.put("result", JSONObject().put("message", "Aufnahme beendet"))
|
.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. */
|
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
|
||||||
fun ask(text: String): JSONObject {
|
fun ask(text: String): JSONObject {
|
||||||
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
||||||
@@ -200,10 +236,15 @@ class VoiceSession(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
"audio_pcm" -> {
|
"audio_pcm" -> {
|
||||||
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
|
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
|
||||||
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
|
// (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")
|
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
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
@@ -245,6 +286,8 @@ class VoiceSession(
|
|||||||
try { track?.stop() } catch (_: Exception) {}
|
try { track?.stop() } catch (_: Exception) {}
|
||||||
try { track?.release() } catch (_: Exception) {}
|
try { track?.release() } catch (_: Exception) {}
|
||||||
track = null; playingMessageId = null
|
track = null; playingMessageId = null
|
||||||
|
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
|
||||||
|
AudioFocusHelper.abandon(appCtx)
|
||||||
onEvent("bereit")
|
onEvent("bereit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,6 +296,8 @@ class VoiceSession(
|
|||||||
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
|
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
|
||||||
if (track == null || playingMessageId != mid) {
|
if (track == null || playingMessageId != mid) {
|
||||||
try { track?.release() } catch (_: Exception) {}
|
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,
|
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
|
||||||
AudioFormat.ENCODING_PCM_16BIT)
|
AudioFormat.ENCODING_PCM_16BIT)
|
||||||
track = AudioTrack.Builder()
|
track = AudioTrack.Builder()
|
||||||
@@ -276,5 +321,6 @@ class VoiceSession(
|
|||||||
stopListening("shutdown")
|
stopListening("shutdown")
|
||||||
try { track?.stop(); track?.release() } catch (_: Exception) {}
|
try { track?.stop(); track?.release() } catch (_: Exception) {}
|
||||||
track = null
|
track = null
|
||||||
|
AudioFocusHelper.abandon(appCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
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).
|
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||||
AGENT_VERSION = "0.0.0.8"
|
AGENT_VERSION = "0.0.1.2"
|
||||||
|
|
||||||
HEARTBEAT_SEC = 25
|
HEARTBEAT_SEC = 25
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { EventEmitter } from "events";
|
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";
|
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||||
export class ClaudeSubprocess extends EventEmitter {
|
export class ClaudeSubprocess extends EventEmitter {
|
||||||
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
isKilled = false;
|
isKilled = false;
|
||||||
_stdinClosed = false;
|
_stdinClosed = false;
|
||||||
|
_systemPromptFile = null;
|
||||||
|
_systemPromptDir = null;
|
||||||
/**
|
/**
|
||||||
* Start the Claude CLI subprocess with the given prompt
|
* 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)
|
// Handle spawn errors (e.g., claude not found)
|
||||||
this.process.on("error", (err) => {
|
this.process.on("error", (err) => {
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
if (err.message.includes("ENOENT")) {
|
if (err.message.includes("ENOENT")) {
|
||||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
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) => {
|
this.process.on("close", (code) => {
|
||||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
// Process any remaining buffer
|
// Process any remaining buffer
|
||||||
if (this.buffer.trim()) {
|
if (this.buffer.trim()) {
|
||||||
this.processBuffer();
|
this.processBuffer();
|
||||||
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||||
"--model",
|
"--model",
|
||||||
options.model, // Model alias (opus/sonnet/haiku)
|
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) {
|
if (options.sessionId) {
|
||||||
args.push("--session-id", options.sessionId);
|
args.push("--session-id", options.sessionId);
|
||||||
}
|
}
|
||||||
return args;
|
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.
|
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
|
// 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.
|
// der RVS sie still an der Allow-List und die Historie bleibt leer.
|
||||||
"agent_history", "agent_history_query", "agent_history_list",
|
"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),
|
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||||
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||||
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ CONTROL_ENABLED=true
|
|||||||
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
# adb.connect ADB-Verbindung aufbauen (params: ip)
|
||||||
# adb.devices verbundene ADB-Geraete auflisten
|
# adb.devices verbundene ADB-Geraete auflisten
|
||||||
# adb.shell Shell-Kommando auf dem Android-Geraet (params: ip, cmd)
|
# 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
|
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
|
||||||
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
|
# 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) ──────────────────────────────
|
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
|
||||||
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
|
||||||
|
|||||||
@@ -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.
|
`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
|
**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
|
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
|
||||||
`satellite/`-Ordner liegen):
|
`satellite/`-Ordner liegen):
|
||||||
|
|||||||
+288
-12
@@ -135,7 +135,7 @@ CONTROL_ALLOWLIST = [
|
|||||||
os.environ.get("CONTROL_ALLOWLIST",
|
os.environ.get("CONTROL_ALLOWLIST",
|
||||||
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
|
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
|
||||||
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,"
|
"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()
|
if a.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -145,6 +145,10 @@ CONTROL_ALLOWLIST = [
|
|||||||
# Download brauchen.
|
# Download brauchen.
|
||||||
ADB_PORT = int(os.environ.get("ADB_PORT", "5555") or "5555")
|
ADB_PORT = int(os.environ.get("ADB_PORT", "5555") or "5555")
|
||||||
ADB_TIMEOUT_SEC = float(os.environ.get("ADB_TIMEOUT_SEC", "180") or "180")
|
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_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))
|
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 "
|
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
|
||||||
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
|
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
|
||||||
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
|
"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()
|
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)
|
return await loop.run_in_executor(None, _do_adb_install, params)
|
||||||
if action == "adb.shell":
|
if action == "adb.shell":
|
||||||
return await loop.run_in_executor(None, _do_adb_shell, params)
|
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."}
|
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||||
@@ -1121,6 +1172,28 @@ def _adb_state(target: str) -> str:
|
|||||||
return ""
|
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:
|
def _do_adb_devices(params: dict) -> dict:
|
||||||
"""Listet die dem Satelliten bekannten ADB-Geraete."""
|
"""Listet die dem Satelliten bekannten ADB-Geraete."""
|
||||||
rc, out, err = _adb_run(["devices", "-l"], timeout=20)
|
rc, out, err = _adb_run(["devices", "-l"], timeout=20)
|
||||||
@@ -1140,10 +1213,190 @@ def _do_adb_connect(params: dict) -> dict:
|
|||||||
if not target:
|
if not target:
|
||||||
return {"ok": False, "error": "ip/host erforderlich."}
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
ok, msg = _adb_connect(target)
|
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:
|
if not ok:
|
||||||
return {"ok": False, "error": msg}
|
return {"ok": False, "error": msg}
|
||||||
return {"ok": True, "result": {"target": target, "state": _adb_state(target),
|
installed = _adb_app_installed(target, package)
|
||||||
"message": msg}}
|
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:
|
def _do_adb_shell(params: dict) -> dict:
|
||||||
@@ -1241,12 +1494,23 @@ def _do_adb_install(params: dict) -> dict:
|
|||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
|
|
||||||
ok, msg = _adb_connect(target)
|
ok, msg = _adb_connect(target)
|
||||||
if not ok:
|
|
||||||
return {"ok": False, "error": msg}
|
|
||||||
state = _adb_state(target)
|
state = _adb_state(target)
|
||||||
if state and state != "device":
|
# Erstkontakt: Das Geraet zeigt jetzt "USB-Debugging zulassen?" auf dem
|
||||||
return {"ok": False, "error": f"Geraet im Zustand '{state}' — "
|
# Bildschirm. Statt abzubrechen warten wir auf die Bestaetigung — ein Klick
|
||||||
"bei 'unauthorized' den Dialog auf dem Bildschirm bestaetigen."}
|
# 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
|
args = ["-s", target, "install", "-r"] # -r = Update statt Neuinstallation
|
||||||
if params.get("downgrade"):
|
if params.get("downgrade"):
|
||||||
@@ -1256,8 +1520,19 @@ def _do_adb_install(params: dict) -> dict:
|
|||||||
txt = f"{out} {err2}".strip()
|
txt = f"{out} {err2}".strip()
|
||||||
size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1)
|
size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1)
|
||||||
if rc == 0 and "success" in txt.lower():
|
if rc == 0 and "success" in txt.lower():
|
||||||
return {"ok": True, "result": {"target": target, "apk_mb": size_mb,
|
res = {"target": target, "apk_mb": size_mb,
|
||||||
"message": f"APK installiert ({size_mb} 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()
|
low = txt.lower()
|
||||||
if "signatures do not match" in low or "update_incompatible" in low:
|
if "signatures do not match" in low or "update_incompatible" in low:
|
||||||
return {"ok": False, "error": "Signatur passt nicht zur installierten Version — "
|
return {"ok": False, "error": "Signatur passt nicht zur installierten Version — "
|
||||||
@@ -1640,7 +1915,8 @@ class Satellite:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
||||||
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
|
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"):
|
if NET.get("warning"):
|
||||||
logger.warning("⚠ %s", NET["warning"])
|
logger.warning("⚠ %s", NET["warning"])
|
||||||
try:
|
try:
|
||||||
|
|||||||
+15
-6
@@ -584,6 +584,12 @@ class SessionManager:
|
|||||||
if ws is None:
|
if ws is None:
|
||||||
return # disconnected — Endpointer pausiert bis Reconnect
|
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)
|
audio_ms = self._buffer_duration_ms(sess)
|
||||||
|
|
||||||
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
|
# 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.
|
# transcribe-Call mit Leerstring weil Model None.
|
||||||
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
|
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
|
||||||
needs_load = (runner.model is None) or (target_model != runner.model_size)
|
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:
|
if needs_load:
|
||||||
async def _load_then_start(p, target):
|
async def _load_only(target):
|
||||||
await _broadcast_status(ws, "loading", model=target)
|
await _broadcast_status(ws, "loading", model=target)
|
||||||
try:
|
try:
|
||||||
await runner.ensure_loaded(target)
|
await runner.ensure_loaded(target)
|
||||||
await _broadcast_status(ws, "ready", model=runner.model_size)
|
await _broadcast_status(ws, "ready", model=runner.model_size)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
return
|
asyncio.create_task(_load_only(target_model))
|
||||||
sessions.start_session(p)
|
|
||||||
asyncio.create_task(_load_then_start(payload, target_model))
|
|
||||||
else:
|
|
||||||
sessions.start_session(payload)
|
|
||||||
|
|
||||||
elif mtype == "stt_audio_chunk":
|
elif mtype == "stt_audio_chunk":
|
||||||
ok = sessions.feed_chunk(payload)
|
ok = sessions.feed_chunk(payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user