feat: Satelliten — ARIAs Augen & Haende in fremden Netzen (Info-/Gateway-Aussenposten)

Neuer eigenstaendiger Container satellite/ (RVS-Client, network_mode host), den
man in einem beliebigen Netz (Buero etc.) deployt. Gibt ARIA Zugriff auf dieses
Netz ohne Haupt-Stack davor.

- satellite/satellite.py: RVS-Client + Discovery (mDNS/Zeroconf, SSDP/UPnP+DIAL,
  ARP) + Steuerung (dial.launch fuer YouTube-auf-FireTV, wol, http) mit Guards
  (CONTROL_ENABLED + Allowlist + Logging, token-gated, keine offenen Ports).
  Periodisches Re-Announce (sat_hello im Heartbeat) fuer spaet joinende Bridge.
- satellite/: Dockerfile, requirements, docker-compose (host-net), .env.example
  (SATELLITE_LOCATION als Adresse), README.
- rvs: sat_hello/discover/devices/command/result whitelisted.
- bridge: Satelliten-Registry (sat_hello) + Future-Relay (_satellite_request) +
  /internal/satellite + /internal/satellite-list (Muster wie flux).
- brain: Tools satellite_list/devices/command + _dispatch_satellite + Seed-Regel.

Alle py_compile + node -c gruen. Kein APK-Rebuild noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:22:30 +02:00
co-authored by Claude Opus 4.8
parent 28361ea97a
commit 071e38f464
12 changed files with 975 additions and 0 deletions
+100
View File
@@ -739,6 +739,11 @@ class ARIABridge:
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
self._vnc_sessions: dict[str, dict] = {}
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
self._satellites: dict[str, dict] = {}
self._pending_sat: dict[str, asyncio.Future] = {}
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
self._pending_flux: dict[str, asyncio.Future] = {}
@@ -3411,6 +3416,32 @@ class ARIABridge:
logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc)
return
elif msg_type == "sat_hello":
sid = (payload.get("id") or "").strip()
if sid:
self._satellites[sid] = {
"id": sid,
"location": payload.get("location") or sid,
"caps": payload.get("caps") or [],
"control": bool(payload.get("control")),
"last_seen": time.time(),
}
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
sid, self._satellites[sid]["location"],
self._satellites[sid]["caps"], self._satellites[sid]["control"])
return
elif msg_type in ("sat_devices", "sat_result"):
req_id = payload.get("requestId", "")
future = self._pending_sat.get(req_id)
if future is not None and not future.done():
future.set_result(payload)
# last_seen aktualisieren
sid = (payload.get("satellite") or "").strip()
if sid and sid in self._satellites:
self._satellites[sid]["last_seen"] = time.time()
return
elif msg_type == "config_request":
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
# Config — passiert wenn sie sich connected, weil sie sonst die
@@ -4282,6 +4313,27 @@ class ARIABridge:
"timestamp": int(time.time() * 1000),
}))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
op = (data.get("op") or "discover").strip()
result = await self._satellite_request(
op=op,
satellite=str(data.get("satellite") or ""),
device=str(data.get("device") or ""),
action=str(data.get("action") or ""),
params=data.get("params") if isinstance(data.get("params"), dict) else {},
timeout=float(data.get("timeout") or 20.0),
)
await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox),
@@ -4501,6 +4553,54 @@ class ARIABridge:
pass
logger.info("[vnc] Tunnel geschlossen: session=%s", session)
def _satellite_list(self) -> list[dict]:
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
now = time.time()
out = []
for s in self._satellites.values():
out.append({
"id": s["id"], "location": s.get("location") or s["id"],
"caps": s.get("caps") or [], "control": bool(s.get("control")),
"online": (now - s.get("last_seen", 0)) < 300,
})
return out
async def _satellite_request(self, op: str, satellite: str = "",
device: str = "", action: str = "",
params: Optional[dict] = None,
timeout: float = 20.0) -> dict:
"""Schickt sat_discover / sat_command an einen Satelliten (via RVS) und
wartet auf sat_devices / sat_result. op = 'discover' | 'command'.
Muster identisch zu _flux_generate (requestId → Future)."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_sat[request_id] = future
try:
if op == "discover":
msg = {"type": "sat_discover",
"payload": {"requestId": request_id, "satellite": satellite,
"force": bool((params or {}).get("force"))}}
else:
msg = {"type": "sat_command",
"payload": {"requestId": request_id, "satellite": satellite,
"device": device, "action": action,
"params": params or {}}}
msg["timestamp"] = int(time.time() * 1000)
ok = await self._send_to_rvs(msg)
if not ok:
return {"ok": False, "error": "Satellit-Request konnte nicht gesendet werden"}
result = await asyncio.wait_for(future, timeout=timeout)
return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"}
except asyncio.TimeoutError:
return {"ok": False, "error": f"Satellit '{satellite or 'all'}' antwortet nicht (Timeout)."}
except Exception as exc:
return {"ok": False, "error": str(exc)}
finally:
self._pending_sat.pop(request_id, None)
async def _delete_chat_message(self, ts: int) -> dict:
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
broadcastet chat_message_deleted via RVS.