- `aria-data/config/AGENT.md` — ARIAs Persönlichkeit und Sicherheitsregeln

- `aria-data/config/USER.md` — Stefans Präferenzen
- `aria-data/config/TOOLING.md` — VM-Tooling Liste
- `aria-data/skills/README.md` — Skill-Bauanleitung

### Bekannte Probleme
- Android Release-Build: `EMFILE: too many open files` — Fix: `CI=true` in `build.sh`
- JDK 21 inkompatibel mit AGP 8.1 — Fix: Automatischer Fallback auf JDK 17
- `react-native-screens` > 3.27.0 inkompatibel mit RN 0.73.4 — Fix: Version gepinnt
This commit is contained in:
duffyduck 2026-03-11 23:13:28 +01:00
parent 71f9ae221c
commit c5d835ea09
11 changed files with 245 additions and 44 deletions

Binary file not shown.

Binary file not shown.

View File

@ -4,6 +4,25 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
---
## [0.0.0.4] — 2026-03-11
### Geändert
**Bridge → aria-core: OpenClaw Gateway Protokoll**
- Bridge nutzt jetzt das echte OpenClaw Gateway WebSocket-Protokoll (Port 18789 statt 8080)
- Vollständiger Handshake: `connect.challenge``connect` Request (mit Auth-Token) → `hello-ok`
- Nachrichten über `chat.send` Method mit `sessionKey` und `idempotencyKey`
- Antworten über `chat:final` Events (statt custom JSON)
- Streaming-Support vorbereitet (`chat:delta` Events werden empfangen)
- Fehlerbehandlung für `chat:error` Events — werden an die App weitergeleitet
**Docker-Compose Fixes**
- `OPENCLAW_GATEWAY_BIND=0.0.0.0` — Gateway bindet auf alle Interfaces (sonst nur 127.0.0.1, Bridge kann nicht zugreifen)
- `OPENCLAW_GATEWAY_TOKEN` statt `AUTH_TOKEN` — korrekter Env-Var-Name fuer OpenClaw Gateway Auth
- `ARIA_AUTH_TOKEN` an Bridge-Container durchgereicht — Bridge authentifiziert sich am Gateway
---
## [0.0.0.3] — 2026-03-09
### Geändert
@ -50,6 +69,7 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
**Docker & Infrastruktur**
- OpenClaw Image fix: `openclaw/openclaw:latest``ghcr.io/openclaw/openclaw:latest`
- Proxy fix: Binary heißt `claude-max-api`, braucht `@anthropic-ai/claude-code` als Peer-Dependency
- Proxy Binary-Name fix: `claude-max-api-proxy``claude-max-api` (npm-Paket heißt anders als die Binary)
- `libportaudio2` in Bridge Dockerfile hinzugefügt — `sounddevice` braucht PortAudio
- `aria-data/config/aria.env.example` hinzugefügt — Voice Bridge Konfigurationsvorlage
@ -80,6 +100,12 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
- `network_security_config.xml` hinzugefuegt — Android 9+ blockiert sonst `ws://` (Cleartext)
- Verbindungslog im Settings-Tab — zeigt jeden Verbindungsversuch, Fehler, Fallback (scrollbar, max 200px)
- Gespeicherte Config wird beim Start in die Einstellungsfelder geladen
- Fix: TLS-Fallback erzeugte Doppel-Verbindungen (onerror + onclose beide reconnected)
**RVS — Ghost-Client Fix**
- Heartbeat-Intervall 30s → 15s, Cleanup 60s → 30s — tote Clients werden schneller entfernt
- `heartbeat` als erlaubter Nachrichtentyp hinzugefuegt — App-Heartbeats halten Verbindung lebendig
- App-seitiger JSON-Heartbeat zaehlt als Lebenszeichen (zusaetzlich zu WebSocket Ping/Pong)
**Neues Script: `get-voices.sh`**
- Lädt Piper Stimmen (Ramona + Thorsten) von HuggingFace herunter

View File

@ -1,11 +0,0 @@
[Interface]
Address = 10.252.1.21/32
PrivateKey = 2JmAeJQ1wL+nfaAVp32RiEsPFcaoXVtZh/p7pqHGCl4=
MTU = 1450
[Peer]
PublicKey = IHBroF1ChESXWQQ+2RC4DmrNoHQl54Hc/xhH+iYLTBA=
PresharedKey = A1i59KCEjvwtx9J03pkcqDdGP7Jhr4PcbA5Um32iMoY=
AllowedIPs = 192.168.0.0/24
Endpoint = stb-er.selfhost.eu:51820
PersistentKeepalive = 15

View File

@ -215,7 +215,8 @@ services:
- proxy
environment:
- CANVAS_HOST=127.0.0.1
- AUTH_TOKEN=${ARIA_AUTH_TOKEN}
- OPENCLAW_GATEWAY_BIND=0.0.0.0 # Bridge muss von Docker-Netz zugreifen
- OPENCLAW_GATEWAY_TOKEN=${ARIA_AUTH_TOKEN}
- OPENAI_API_KEY=not-needed
- OPENAI_BASE_URL=http://proxy:3456/v1
- DEFAULT_MODEL=openai/claude-sonnet-4-6
@ -249,6 +250,7 @@ services:
- /dev/snd
environment:
- PULSE_SERVER=unix:/run/user/1000/pulse/native
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
@ -406,7 +408,19 @@ cp .env.example .env
# → RVS_HOST + RVS_PORT eintragen (z.B. rvs.hackersoft.de / 443)
```
### 3. Konfiguration & Stimmen
### 3. Claude CLI verbinden (Proxy-Auth)
Der Proxy-Container leitet API-Calls über deine Claude Max Subscription.
Dafür muss die Claude CLI einmalig auf der VM eingeloggt sein:
```bash
npm install -g @anthropic-ai/claude-code
claude login
# → Zeigt einen Link + Code — im Browser öffnen und bestätigen
# → Credentials landen in ~/.config/claude/ (wird read-only in den Container gemounted)
```
### 4. Konfiguration & Stimmen
```bash
# Voice Bridge Konfiguration anlegen
@ -417,7 +431,7 @@ cp aria-data/config/aria.env.example aria-data/config/aria.env
./get-voices.sh
```
### 4. Token generieren & starten
### 5. Token generieren & starten
```bash
# Token erzeugen — schreibt RVS_TOKEN automatisch in .env, zeigt QR-Code
@ -427,11 +441,11 @@ cp aria-data/config/aria.env.example aria-data/config/aria.env
docker compose up -d
```
### 5. App verbinden
### 6. App verbinden
App öffnen → QR-Code scannen → "ARIA, hörst du mich?" 🎙️
> Alles was über diese fünf Schritte hinausgeht macht ARIA selbst.
> Alles was über diese sechs Schritte hinausgeht macht ARIA selbst.
---

View File

@ -224,10 +224,17 @@ class RVSConnection {
// TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln
if (this.config?.useTLS && !this.usingTLSFallback) {
this.usingTLSFallback = true;
// shouldReconnect kurz deaktivieren damit onclose keinen
// parallelen Reconnect ausloest — wir machen das selbst
this.shouldReconnect = false;
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
this.clearTimers();
this.ws?.close();
if (this.ws) {
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
try { this.ws.close(); } catch (_) {}
}
this.ws = null;
this.shouldReconnect = true;
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.establishConnection();
return;

View File

@ -1,4 +1,10 @@
OPENCLAW_URL=http://aria-core:18789
# Bridge → aria-core (OpenClaw Gateway)
# Standard: ws://aria-core:18789 (internes Docker-Netz)
ARIA_CORE_WS=ws://aria-core:18789
# Piper TTS Stimmen
PIPER_RAMONA=/voices/de_DE-ramona-low.onnx
PIPER_THORSTEN=/voices/de_DE-thorsten-high.onnx
WAKE_WORD=aria
# Wake-Word
WAKE_WORD=aria

View File

@ -25,6 +25,7 @@ import signal
import ssl
import sys
import tempfile
import uuid
import wave
from pathlib import Path
from typing import Optional
@ -51,7 +52,8 @@ logger = logging.getLogger("aria-bridge")
CONFIG_PATH = Path("/config/aria.env")
VOICES_DIR = Path("/voices")
CORE_WS_URL = os.getenv("ARIA_CORE_WS", "ws://aria:8080")
CORE_WS_URL = os.getenv("ARIA_CORE_WS", "ws://aria-core:18789")
CORE_AUTH_TOKEN = os.getenv("ARIA_AUTH_TOKEN", "") # OpenClaw Gateway Token
RVS_HOST = os.getenv("RVS_HOST", "") # z.B. rvs.hackersoft.de
RVS_PORT = os.getenv("RVS_PORT", "443") # Port des RVS
RVS_TLS = os.getenv("RVS_TLS", "true") # true = wss://, false = ws://
@ -405,6 +407,9 @@ class ARIABridge:
def __init__(self) -> None:
self.config = load_config()
self.ws_url = self.config.get("ARIA_CORE_WS", CORE_WS_URL)
self.core_auth_token = self.config.get("ARIA_AUTH_TOKEN", CORE_AUTH_TOKEN)
self._req_id_counter = 0
self._session_key = "aria-bridge" # Feste Session fuer die Bridge
# RVS-Verbindungsinfo aus Config oder Env
rvs_host = self.config.get("RVS_HOST", RVS_HOST)
rvs_port = self.config.get("RVS_PORT", RVS_PORT)
@ -473,19 +478,99 @@ class ARIABridge:
logger.warning(" Setze RVS_HOST, RVS_PORT, RVS_TOKEN in /config/aria.env")
logger.info("Modus: %s %s", self.current_mode.config.emoji, self.current_mode.config.name)
# ── aria-core Verbindung ─────────────────────────────────
# ── aria-core Verbindung (OpenClaw Gateway Protokoll) ───
def _next_req_id(self) -> str:
"""Erzeugt eine eindeutige Request-ID fuer das OpenClaw-Protokoll."""
self._req_id_counter += 1
return f"bridge-{self._req_id_counter}"
async def _openclaw_handshake(self, ws: websockets.WebSocketClientProtocol) -> bool:
"""Fuehrt den OpenClaw Gateway Handshake durch.
1. Wartet auf connect.challenge Event vom Gateway
2. Sendet connect Request mit Auth-Token
3. Wartet auf hello-ok Response
Returns:
True wenn Handshake erfolgreich, sonst False.
"""
try:
# Schritt 1: Auf Challenge warten (max 10s)
raw = await asyncio.wait_for(ws.recv(), timeout=10.0)
challenge = json.loads(raw)
if challenge.get("type") != "event" or challenge.get("event") != "connect.challenge":
logger.error("[core] Unerwartete erste Nachricht: %s", raw[:200])
return False
nonce = challenge.get("payload", {}).get("nonce", "")
logger.info("[core] Challenge empfangen (nonce: %s...)", nonce[:8] if nonce else "?")
# Schritt 2: Connect Request senden
connect_req = {
"type": "req",
"id": self._next_req_id(),
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 3,
"client": {
"id": "aria-bridge",
"version": "0.0.3",
"platform": "linux",
"mode": "operator",
},
"role": "operator",
"scopes": ["operator.read", "operator.write"],
"caps": ["voice"],
"commands": [],
"permissions": {},
"auth": {"token": self.core_auth_token} if self.core_auth_token else {},
"locale": "de-DE",
"userAgent": "aria-bridge/0.0.3",
},
}
await ws.send(json.dumps(connect_req))
logger.info("[core] Connect-Request gesendet")
# Schritt 3: Auf hello-ok warten (max 10s)
raw = await asyncio.wait_for(ws.recv(), timeout=10.0)
response = json.loads(raw)
if response.get("type") == "res" and response.get("ok"):
logger.info("[core] Handshake erfolgreich — hello-ok empfangen")
return True
else:
error = response.get("error", response)
logger.error("[core] Handshake fehlgeschlagen: %s", json.dumps(error)[:200])
return False
except asyncio.TimeoutError:
logger.error("[core] Handshake-Timeout (10s)")
return False
except Exception:
logger.exception("[core] Handshake-Fehler")
return False
async def connect_to_core(self) -> None:
"""Persistente WebSocket-Verbindung zu aria-core mit Auto-Reconnect."""
"""Persistente WebSocket-Verbindung zu aria-core (OpenClaw Gateway)."""
retry_delay = 2
while self.running:
try:
logger.info("[core] Verbinde: %s", self.ws_url)
async with websockets.connect(self.ws_url) as ws:
# OpenClaw Handshake durchfuehren
if not await self._openclaw_handshake(ws):
logger.error("[core] Handshake fehlgeschlagen — Reconnect")
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30)
continue
self.ws_core = ws
retry_delay = 2
logger.info("[core] Verbunden")
logger.info("[core] Verbunden und authentifiziert")
async for message in ws:
await self._handle_core_message(message)
@ -493,7 +578,7 @@ class ARIABridge:
except websockets.ConnectionClosed:
logger.warning("[core] Verbindung verloren")
except ConnectionRefusedError:
logger.warning("[core] Nicht erreichbar")
logger.warning("[core] Nicht erreichbar (%s)", self.ws_url)
except Exception:
logger.exception("[core] WebSocket-Fehler")
finally:
@ -505,10 +590,11 @@ class ARIABridge:
retry_delay = min(retry_delay * 2, 30)
async def _handle_core_message(self, raw_message: str) -> None:
"""Verarbeitet Nachrichten von aria-core.
"""Verarbeitet Nachrichten von aria-core (OpenClaw Gateway Protokoll).
- Leitet Antworten an die App weiter (via RVS)
- Sprachausgabe ueber TTS (wenn Modus erlaubt)
Unterstuetzte Frame-Typen:
- event: chat:delta (Streaming-Tokens), chat:final (fertige Antwort)
- res: Antworten auf Requests (chat.send Acknowledgment)
"""
try:
message = json.loads(raw_message)
@ -516,13 +602,71 @@ class ARIABridge:
logger.error("[core] Ungueltige JSON: %s", raw_message[:100])
return
text = message.get("text", "")
metadata = message.get("metadata", {})
frame_type = message.get("type", "")
# ── Response auf unsere Requests (z.B. chat.send Ack) ──
if frame_type == "res":
req_id = message.get("id", "")
if message.get("ok"):
logger.debug("[core] Request %s bestätigt", req_id)
else:
error = message.get("error", "Unbekannt")
logger.error("[core] Request %s fehlgeschlagen: %s", req_id, error)
return
# ── Events vom Gateway ──
if frame_type != "event":
logger.debug("[core] Unbekannter Frame-Typ: %s", frame_type)
return
event_name = message.get("event", "")
payload = message.get("payload", {})
if event_name == "chat:delta":
# Streaming-Delta — fuer spaeter (Live-Typing in der App)
delta = payload.get("delta", payload.get("text", ""))
if delta:
logger.debug("[core] Delta: '%s'", delta[:40])
return
if event_name == "chat:final":
# Fertige Antwort von aria-core
text = payload.get("text", payload.get("message", ""))
if not text:
logger.warning("[core] chat:final ohne Text: %s", json.dumps(payload)[:200])
return
logger.info("[core] Antwort: '%s'", text[:80])
await self._process_core_response(text, payload)
return
if event_name == "chat:error":
error = payload.get("error", payload.get("message", "Unbekannt"))
logger.error("[core] Chat-Fehler: %s", error)
# Fehler auch an die App melden
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": f"[Fehler] {error}",
"sender": "aria",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
# Andere Events loggen (presence, tick, etc.)
logger.debug("[core] Event: %s", event_name)
async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core.
- Leitet Antwort an die App weiter (via RVS)
- Sprachausgabe ueber TTS (wenn Modus erlaubt)
"""
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
logger.info("[core] Nachricht: '%s'", text[:80])
# Modus-Wechsel pruefen
new_mode = detect_mode_switch(text)
if new_mode is not None:
@ -532,7 +676,6 @@ class ARIABridge:
self.current_mode.config.emoji,
self.current_mode.config.name,
)
# Modus-Aenderung auch an die App senden
await self._send_to_rvs({
"type": "mode",
"payload": {"mode": self.current_mode.name},
@ -576,21 +719,26 @@ class ARIABridge:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
async def send_to_core(self, text: str, source: str = "bridge") -> None:
"""Sendet Text an aria-core."""
"""Sendet Text an aria-core (OpenClaw chat.send Protokoll)."""
if self.ws_core is None:
logger.error("[core] Nicht verbunden — Nachricht verworfen: '%s'", text[:60])
return
req_id = self._next_req_id()
message = json.dumps({
"type": "voice_input" if source == "bridge" else "chat_input",
"text": text,
"mode": self.current_mode.name,
"source": source,
"type": "req",
"id": req_id,
"method": "chat.send",
"params": {
"sessionKey": self._session_key,
"text": text,
"idempotencyKey": str(uuid.uuid4()),
},
})
try:
await self.ws_core.send(message)
logger.info("[core] Gesendet (%s): '%s'", source, text[:80])
logger.info("[core] chat.send (%s, id=%s): '%s'", source, req_id, text[:80])
except Exception:
logger.exception("[core] Sendefehler")

View File

@ -20,7 +20,8 @@ services:
- proxy
environment:
- CANVAS_HOST=127.0.0.1
- AUTH_TOKEN=${ARIA_AUTH_TOKEN}
- OPENCLAW_GATEWAY_BIND=0.0.0.0 # Bridge muss von Docker-Netz zugreifen (kein Port-Mapping nach aussen)
- OPENCLAW_GATEWAY_TOKEN=${ARIA_AUTH_TOKEN}
- OPENAI_API_KEY=not-needed
- OPENAI_BASE_URL=http://proxy:3456/v1
- DEFAULT_MODEL=openai/claude-sonnet-4-6
@ -54,6 +55,7 @@ services:
- /dev/snd
environment:
- PULSE_SERVER=unix:/run/user/1000/pulse/native
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}

View File

@ -8,7 +8,7 @@ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
// Erlaubte Nachrichtentypen — alles andere wird verworfen
const ALLOWED_TYPES = new Set([
"chat", "audio", "file", "location", "mode", "log", "event",
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
]);
// Token-Raum: token -> { clients: Set<ws> }
@ -129,13 +129,14 @@ function registerClient(ws, token) {
});
}
// ── Heartbeat — hält Verbindungen am Leben ──────────────────────────
// ── Heartbeat — hält Verbindungen am Leben, räumt tote auf ──────────
const HEARTBEAT_INTERVAL = 30_000;
const HEARTBEAT_INTERVAL = 15_000;
const heartbeat = setInterval(() => {
for (const client of wss.clients) {
if (client.isAlive === false) {
log(`Toter Client entfernt (kein Pong)`);
client.terminate();
continue;
}
@ -147,10 +148,18 @@ const heartbeat = setInterval(() => {
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
// App-seitiger Heartbeat (JSON) zaehlt auch als lebendig
const origOnMessage = ws._events?.message;
ws.on("message", (raw) => {
try {
const msg = JSON.parse(raw);
if (msg.type === "heartbeat") ws.isAlive = true;
} catch {}
});
});
// Aufräumen alle 60 Sekunden
const cleanup = setInterval(cleanupRooms, 60_000);
// Aufräumen alle 30 Sekunden (statt 60)
const cleanup = setInterval(cleanupRooms, 30_000);
wss.on("close", () => {
clearInterval(heartbeat);