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:
@@ -10,6 +10,26 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3.0] — 2026-07-19 — Satelliten: ARIAs Augen & Hände in fremden Netzen 🛰️
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Neuer eigenständiger Container `satellite/`** — ein Außenposten, den du in einem beliebigen Netz (Büro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht.
|
||||
- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) → Live-Inventar.
|
||||
- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. „ARIA, spiel YouTube-Video X auf dem Büro-Stick" → Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports.
|
||||
- **Adressierung** über `SATELLITE_LOCATION` (z.B. „Büro") — mehrere Satelliten im selben Raum, jeder mit eigenem Namen.
|
||||
|
||||
**End-to-end verdrahtet:**
|
||||
- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README.
|
||||
- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`.
|
||||
- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster.
|
||||
- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command).
|
||||
|
||||
### Deploy
|
||||
Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
|
||||
|
||||
### Behoben
|
||||
|
||||
@@ -96,6 +96,7 @@ ARIA hat zwei Rollen:
|
||||
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
|
||||
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
|
||||
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` |
|
||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU,
|
||||
|
||||
@@ -1105,6 +1105,62 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_list",
|
||||
"description": (
|
||||
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
|
||||
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
|
||||
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
|
||||
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
|
||||
"Du welche Netze erreichbar sind."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_devices",
|
||||
"description": (
|
||||
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
|
||||
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
|
||||
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID (z.B. 'Buero')."},
|
||||
},
|
||||
"required": ["satellite"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "satellite_command",
|
||||
"description": (
|
||||
"Fuehrt eine Aktion auf einem Geraet im Netz eines Satelliten aus. "
|
||||
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
|
||||
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
|
||||
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
|
||||
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"satellite": {"type": "string", "description": "Satelliten-Name/Location/ID."},
|
||||
"device": {"type": "string", "description": "Geraet (Name/ID/IP) aus satellite_devices."},
|
||||
"action": {"type": "string", "description": "z.B. 'dial.launch', 'wol', 'http.get'."},
|
||||
"params": {"type": "object", "description": "Aktions-Parameter, z.B. {'app':'YouTube','v':'<id>'}."},
|
||||
},
|
||||
"required": ["satellite", "action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1938,6 +1994,70 @@ class Agent:
|
||||
return []
|
||||
return out[-limit:]
|
||||
|
||||
def _dispatch_satellite(self, name: str, arguments: dict) -> str:
|
||||
"""satellite_list / satellite_devices / satellite_command — geht via
|
||||
Bridge (/internal/satellite*) → RVS → Satellit. Muster wie flux_generate."""
|
||||
def _post(path: str, body: dict, timeout: float) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", "ignore"))
|
||||
try:
|
||||
if name == "satellite_list":
|
||||
result = _post("/internal/satellite-list", {}, 10)
|
||||
sats = result.get("satellites") or []
|
||||
if not sats:
|
||||
return ("Gerade ist kein Satellit online. (Ein Satellit ist ein "
|
||||
"Aussenposten-Container in einem fremden Netz, der sich mit "
|
||||
"RVS verbindet und Dir dort Geraete zeigt + steuerbar macht.)")
|
||||
lines = []
|
||||
for s in sats:
|
||||
status = "online" if s.get("online") else "offline"
|
||||
caps = ", ".join(s.get("caps") or []) or "nur beobachten"
|
||||
lines.append(f"- {s.get('location')} (id={s.get('id')}, {status}; kann: {caps})")
|
||||
return "Satelliten / erreichbare Netze:\n" + "\n".join(lines)
|
||||
|
||||
if name == "satellite_devices":
|
||||
sat = (arguments.get("satellite") or "").strip()
|
||||
if not sat:
|
||||
return "FEHLER: satellite ist Pflicht."
|
||||
result = _post("/internal/satellite", {"op": "discover", "satellite": sat}, 30)
|
||||
if result.get("error"):
|
||||
return f"FEHLER: {result['error']}"
|
||||
devices = result.get("devices") or []
|
||||
if not devices:
|
||||
return f"Im Netz von '{sat}' wurden keine Geraete gefunden (oder der Satellit ist offline)."
|
||||
lines = []
|
||||
for d in devices[:40]:
|
||||
tags = []
|
||||
if d.get("model"):
|
||||
tags.append(d["model"])
|
||||
if d.get("dialAppUrl"):
|
||||
tags.append("DIAL/App-Launch")
|
||||
if d.get("mac"):
|
||||
tags.append(d["mac"])
|
||||
extra = f" ({'; '.join(tags)})" if tags else ""
|
||||
lines.append(f"- {d.get('name')} [{d.get('type')}] {d.get('ip', '')}{extra} id={d.get('id')}")
|
||||
return f"Geraete im Netz '{result.get('location', sat)}':\n" + "\n".join(lines)
|
||||
|
||||
# satellite_command
|
||||
sat = (arguments.get("satellite") or "").strip()
|
||||
action = (arguments.get("action") or "").strip()
|
||||
if not sat or not action:
|
||||
return "FEHLER: satellite und action sind Pflicht."
|
||||
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
||||
result = _post("/internal/satellite", {
|
||||
"op": "command", "satellite": sat,
|
||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||
}, 30)
|
||||
if result.get("ok"):
|
||||
r = result.get("result")
|
||||
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
|
||||
|
||||
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
|
||||
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
|
||||
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
|
||||
@@ -2675,6 +2795,8 @@ class Agent:
|
||||
f"Editor + Desktop erscheinen in der App. Code-Dateien unter "
|
||||
f"/shared/projects/{active_id}/ schreiben, damit sie im Editor auftauchen.")
|
||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||
return self._dispatch_satellite(name, arguments)
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
@@ -437,6 +437,37 @@ SEED_RULES: List[dict] = [
|
||||
"wie gewohnt einfach machen, nicht moralisieren."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/satellites-remote-networks",
|
||||
"type": "rule",
|
||||
"title": "Satelliten: Augen + Haende in fremden Netzen (Buero etc.)",
|
||||
"category": "architektur",
|
||||
"content": (
|
||||
"Ein SATELLIT ist ein eigenstaendiger Container in einem FREMDEN Netz "
|
||||
"(z.B. Buero, Werkstatt), der sich mit RVS verbindet und Dir dort Geraete "
|
||||
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
|
||||
"NICHT steht.\n"
|
||||
"\n"
|
||||
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n"
|
||||
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
|
||||
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
|
||||
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
|
||||
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
|
||||
" 3. `satellite_command(...)` — Aktion ausfuehren.\n"
|
||||
"\n"
|
||||
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
|
||||
" satellite_command(satellite='Buero', device='Fire TV', "
|
||||
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
|
||||
"Die YouTube-Video-ID (v=) ziehst Du aus dem Link/Titel (ggf. web_search). "
|
||||
"Weitere Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks.\n"
|
||||
"\n"
|
||||
"Adressierung ueber Location/Name des Satelliten ('Buero'), NICHT ueber die "
|
||||
"Geraete — die identifizieren sich selbst. Steuerung geht nur, wenn der "
|
||||
"Satellit sie erlaubt (steht in satellite_list als caps). Ist keiner online: "
|
||||
"sag das ehrlich, statt zu raten."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/architecture/brain-tools-xml-tag",
|
||||
"type": "rule",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -72,6 +72,10 @@ const ALLOWED_TYPES = new Set([
|
||||
"code_file", "code_file_edit",
|
||||
"check_desktop", "desktop_status",
|
||||
"vnc_open", "vnc_close", "vnc_data", "vnc_input",
|
||||
// Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit
|
||||
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||
// fuehren Aktionen aus (sat_command → sat_result).
|
||||
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ─── ARIA Satellit — Konfiguration ──────────────────────────────────
|
||||
# Kopiere diese Datei nach .env und passe sie an.
|
||||
|
||||
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
|
||||
# diesen Satelliten erreicht). Werte aus der Haupt-.env / vom generate-token.sh.
|
||||
RVS_HOST=rvs.example.de
|
||||
RVS_PORT=443
|
||||
RVS_TLS=true
|
||||
RVS_TOKEN=
|
||||
|
||||
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||
# SATELLITE_LOCATION = menschlicher Name, so spricht ARIA das Netz an ("Buero").
|
||||
# Mehrere Satelliten koennen im selben RVS-Raum haengen — die Location
|
||||
# unterscheidet sie ("Buero", "Zuhause", "Werkstatt").
|
||||
SATELLITE_ID=buero
|
||||
SATELLITE_LOCATION=Büro
|
||||
|
||||
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
|
||||
# CONTROL_ENABLED=false → reiner Info-/Beobachtungs-Satellit (entdeckt & meldet
|
||||
# nur, steuert nichts). Sicherste Basis.
|
||||
# CONTROL_ENABLED=true → darf Geraete steuern (nur Aktionen aus der Allowlist).
|
||||
CONTROL_ENABLED=true
|
||||
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
|
||||
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
|
||||
# wol Wake-on-LAN (Geraet per MAC aufwecken)
|
||||
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks)
|
||||
# http.post generischer HTTP-POST
|
||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get
|
||||
|
||||
# ─── Discovery-Tuning (optional) ───────────────────────────────────
|
||||
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
|
||||
DISCOVER_TIMEOUT_SEC=6 # Dauer eines Sweeps (mDNS + SSDP)
|
||||
DEVICE_CACHE_TTL_SEC=120 # wie lange ein Inventar als "frisch" gilt
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARIA Satellit — schlanker Aussenposten-Container.
|
||||
# Laeuft mit network_mode: host (siehe docker-compose.yml), damit mDNS/SSDP-
|
||||
# Broadcasts + die Geraete-IPs im lokalen Netz erreichbar sind.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py .
|
||||
|
||||
CMD ["python", "-u", "satellite.py"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# ARIA Satellit 🛰️
|
||||
|
||||
Ein eigenständiger **Außenposten-Container** für ein fremdes Netz (Büro, Werkstatt,
|
||||
Ferienwohnung …). Er verbindet sich als RVS-Client in Stefans Raum und gibt ARIA
|
||||
**Augen und Hände in genau diesem Netz** — ohne dass der Haupt-Stack dort stehen muss.
|
||||
|
||||
- **Augen (Info):** entdeckt Geräte via **mDNS/Zeroconf** (Chromecast, AirPlay, Sonos,
|
||||
Drucker, NAS …), **SSDP/UPnP + DIAL** (Smart-TVs, Fire TV) und der **ARP-Tabelle**
|
||||
(rohe Hosts). Meldet ARIA ein Live-Inventar.
|
||||
- **Hände (Steuerung):** **DIAL-App-Launch** (z.B. YouTube-Video auf dem Fire TV),
|
||||
**Wake-on-LAN**, generisches **HTTP**. Nur wenn freigeschaltet (siehe Sicherheit).
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd satellite
|
||||
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # "sat_hello gesendet" + "[scan] N Geraete"
|
||||
```
|
||||
|
||||
`RVS_HOST/PORT/TLS/TOKEN` **identisch** zum Haupt-Stack (gleicher Raum, damit ARIA
|
||||
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||
anspricht („Büro").
|
||||
|
||||
> **`network_mode: host` ist Pflicht** (schon in der compose gesetzt): nur so sieht
|
||||
> der Container die mDNS/SSDP-Broadcasts und die Geräte-IPs des LAN.
|
||||
|
||||
## So nutzt ARIA es
|
||||
|
||||
ARIA hat drei Brain-Tools:
|
||||
- `satellite_list` — welche Netze/Satelliten sind online + was können sie.
|
||||
- `satellite_devices(satellite)` — Inventar eines Netzes.
|
||||
- `satellite_command(satellite, device, action, params)` — Aktion ausführen.
|
||||
|
||||
Beispiel „YouTube-Video auf dem Büro-Stick":
|
||||
```
|
||||
satellite_command(satellite="Büro", device="Fire TV",
|
||||
action="dial.launch", params={"app":"YouTube","v":"<videoId>"})
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar — deshalb:
|
||||
|
||||
- Reagiert **nur** auf den eigenen RVS-Raum (Token).
|
||||
- **`CONTROL_ENABLED=false`** = reiner Info-Satellit (steuert nichts). Standard-sicher.
|
||||
- Bei `true`: nur Aktionen aus **`CONTROL_ALLOWLIST`**, alles andere wird abgelehnt.
|
||||
- Jede ausgeführte Aktion wird **geloggt**.
|
||||
- Keine offenen Ports — reiner Client.
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
sonst `false` und nur beobachten.
|
||||
@@ -0,0 +1,18 @@
|
||||
# ARIA Satellit — eigenstaendiger Stack fuer ein fremdes Netz (Buero, Werkstatt …).
|
||||
#
|
||||
# Deploy:
|
||||
# cd satellite
|
||||
# cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# WICHTIG: network_mode: host — der Satellit MUSS im Host-Netz laufen, sonst
|
||||
# sieht er die mDNS/SSDP-Broadcasts + Geraete-IPs des LAN nicht (Docker-Bridge
|
||||
# wuerde das isolieren). Damit ist er zugleich als RVS-Client raus ins Internet
|
||||
# verbunden. Keine Ports zu veroeffentlichen — er ist reiner Client.
|
||||
services:
|
||||
satellite:
|
||||
build: .
|
||||
container_name: aria-satellite
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
websockets>=12.0
|
||||
zeroconf>=0.131.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
ARIA Satellit — Info-/Gateway-Aussenposten in einem fremden Netz.
|
||||
|
||||
Laeuft eigenstaendig (z.B. im Buero) und verbindet sich als RVS-Client in
|
||||
Stefans Raum (gleicher Token). Gibt ARIA damit Augen + Haende in DIESEM Netz:
|
||||
|
||||
Augen: entdeckt Geraete (mDNS/Zeroconf, SSDP/UPnP + DIAL, ARP-Tabelle) und
|
||||
meldet ein Inventar → sat_devices.
|
||||
Haende: steuert Geraete (DIAL-App-Launch z.B. YouTube auf Fire TV, Wake-on-
|
||||
LAN, generisches HTTP) → sat_command / sat_result. Nur wenn
|
||||
CONTROL_ENABLED=true, Aktion in der Allowlist, alles geloggt.
|
||||
|
||||
Adressierung: mehrere Satelliten haengen im selben RVS-Raum. Jeder hat eine
|
||||
SATELLITE_ID (technisch, eindeutig) + SATELLITE_LOCATION (menschlich, "Buero").
|
||||
ARIA spricht einen Satelliten ueber seine ID/Location an.
|
||||
|
||||
Message-Typen (RVS, Base64/JSON-Relay wie der Rest):
|
||||
raus: sat_hello {id, location, caps, ts}
|
||||
sat_devices {requestId, satellite, devices:[...]}
|
||||
sat_result {requestId, satellite, ok, result|error}
|
||||
rein: sat_discover {satellite?, requestId}
|
||||
sat_command {satellite?, requestId, device, action, params}
|
||||
|
||||
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token). Commands brauchen
|
||||
CONTROL_ENABLED + Allowlist. Discovery ist read-only. Keine offenen Ports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [satellite] %(levelname)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("satellite")
|
||||
|
||||
|
||||
# ─── Konfiguration ──────────────────────────────────────────────────
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
v = os.environ.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||
|
||||
|
||||
def _default_id() -> str:
|
||||
host = socket.gethostname() or "satellite"
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
|
||||
return slug or "satellite"
|
||||
|
||||
|
||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
CONTROL_ALLOWLIST = [
|
||||
a.strip() for a in
|
||||
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",")
|
||||
if a.strip()
|
||||
]
|
||||
|
||||
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
|
||||
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
|
||||
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||
MDNS_TYPES = [
|
||||
"_googlecast._tcp.local.", # Chromecast / Google TV / Nest
|
||||
"_airplay._tcp.local.", # Apple TV / AirPlay
|
||||
"_raop._tcp.local.", # AirPlay-Audio
|
||||
"_spotify-connect._tcp.local.", # Spotify-Geraete
|
||||
"_sonos._tcp.local.", # Sonos
|
||||
"_hap._tcp.local.", # HomeKit
|
||||
"_printer._tcp.local.", # Drucker
|
||||
"_ipp._tcp.local.", # Drucker (IPP)
|
||||
"_smb._tcp.local.", # NAS / Fileshares
|
||||
"_workstation._tcp.local.", # generische Hosts
|
||||
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
|
||||
]
|
||||
|
||||
CAPABILITIES = ["discover"]
|
||||
if CONTROL_ENABLED:
|
||||
CAPABILITIES += CONTROL_ALLOWLIST
|
||||
|
||||
|
||||
# ─── Discovery ──────────────────────────────────────────────────────
|
||||
|
||||
def _discover_mdns(timeout: float) -> list[dict]:
|
||||
"""Blockierend (im Executor): mDNS/Zeroconf-Sweep ueber MDNS_TYPES."""
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
from zeroconf import Zeroconf, ServiceBrowser
|
||||
except Exception as exc:
|
||||
logger.warning("zeroconf nicht verfuegbar: %s", exc)
|
||||
return []
|
||||
|
||||
class _Listener:
|
||||
def add_service(self, zc, type_, name):
|
||||
try:
|
||||
info = zc.get_service_info(type_, name, timeout=2000)
|
||||
except Exception:
|
||||
info = None
|
||||
if not info:
|
||||
return
|
||||
ips = []
|
||||
try:
|
||||
for addr in info.parsed_addresses():
|
||||
ips.append(addr)
|
||||
except Exception:
|
||||
pass
|
||||
props = {}
|
||||
try:
|
||||
for k, v in (info.properties or {}).items():
|
||||
try:
|
||||
props[k.decode("utf-8", "ignore")] = (
|
||||
v.decode("utf-8", "ignore") if isinstance(v, (bytes, bytearray)) else v)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
friendly = name.split("." + type_.split(".", 1)[0])[0].strip(".")
|
||||
fn = props.get("fn") or props.get("friendlyName") or friendly
|
||||
dev_id = _slug(f"{fn}-{ips[0] if ips else name}")
|
||||
out[dev_id] = {
|
||||
"id": dev_id,
|
||||
"name": fn,
|
||||
"type": _mdns_kind(type_),
|
||||
"ip": ips[0] if ips else "",
|
||||
"port": info.port,
|
||||
"via": "mdns",
|
||||
"service": type_,
|
||||
"model": props.get("md") or props.get("model") or "",
|
||||
}
|
||||
|
||||
def update_service(self, *a):
|
||||
pass
|
||||
|
||||
def remove_service(self, *a):
|
||||
pass
|
||||
|
||||
zc = None
|
||||
try:
|
||||
zc = Zeroconf()
|
||||
listener = _Listener()
|
||||
for t in MDNS_TYPES:
|
||||
try:
|
||||
ServiceBrowser(zc, t, listener)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("mDNS-Sweep-Fehler: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
if zc:
|
||||
zc.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _mdns_kind(service_type: str) -> str:
|
||||
m = {
|
||||
"_googlecast": "cast", "_airplay": "airplay", "_raop": "airplay-audio",
|
||||
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
|
||||
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
|
||||
"_workstation": "host", "_http": "web",
|
||||
}
|
||||
for k, v in m.items():
|
||||
if service_type.startswith(k):
|
||||
return v
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _discover_ssdp(timeout: float) -> list[dict]:
|
||||
"""Blockierend: SSDP M-SEARCH (UPnP + DIAL). Liefert v.a. Smart-TVs / Fire
|
||||
TV mit ihrer DIAL Application-URL (fuer App-Launch wie YouTube)."""
|
||||
out: dict[str, dict] = {}
|
||||
targets = [
|
||||
"urn:dial-multiscreen-org:service:dial:1",
|
||||
"ssdp:all",
|
||||
]
|
||||
for st in targets:
|
||||
msg = (
|
||||
"M-SEARCH * HTTP/1.1\r\n"
|
||||
"HOST: 239.255.255.250:1900\r\n"
|
||||
'MAN: "ssdp:discover"\r\n'
|
||||
"MX: 2\r\n"
|
||||
f"ST: {st}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(msg, ("239.255.255.250", 1900))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, addr = sock.recvfrom(65507)
|
||||
except socket.timeout:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
headers = _parse_http_headers(data.decode("utf-8", "ignore"))
|
||||
location = headers.get("location", "")
|
||||
dial_app = headers.get("application-url", "")
|
||||
ip = addr[0]
|
||||
dev = _fetch_upnp_description(location) if location else {}
|
||||
name = dev.get("name") or headers.get("server", "") or ip
|
||||
dev_id = _slug(f"{name}-{ip}")
|
||||
entry = out.get(dev_id, {
|
||||
"id": dev_id, "name": name, "type": "media-renderer",
|
||||
"ip": ip, "via": "ssdp",
|
||||
})
|
||||
if dev.get("name"):
|
||||
entry["name"] = dev["name"]
|
||||
if dev.get("model"):
|
||||
entry["model"] = dev["model"]
|
||||
if dev.get("manufacturer"):
|
||||
entry["manufacturer"] = dev["manufacturer"]
|
||||
if dial_app or dev.get("dialAppUrl"):
|
||||
entry["dialAppUrl"] = dial_app or dev.get("dialAppUrl")
|
||||
entry["type"] = "dial"
|
||||
out[dev_id] = entry
|
||||
except Exception as exc:
|
||||
logger.debug("SSDP (%s) Fehler: %s", st, exc)
|
||||
finally:
|
||||
try:
|
||||
if sock:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return list(out.values())
|
||||
|
||||
|
||||
def _fetch_upnp_description(location: str) -> dict:
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(location, timeout=3)
|
||||
dial_app = r.headers.get("Application-URL", "")
|
||||
xml = r.text
|
||||
name = _xml_tag(xml, "friendlyName")
|
||||
model = _xml_tag(xml, "modelName")
|
||||
manuf = _xml_tag(xml, "manufacturer")
|
||||
return {"name": name, "model": model, "manufacturer": manuf, "dialAppUrl": dial_app}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _discover_arp() -> list[dict]:
|
||||
"""Rohe Host-Liste aus der ARP-Tabelle (kein aktiver Scan)."""
|
||||
out = []
|
||||
try:
|
||||
with open("/proc/net/arp", "r", encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()[1:]
|
||||
for ln in lines:
|
||||
parts = ln.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
ip, _hw, _flags, mac = parts[0], parts[1], parts[2], parts[3]
|
||||
if mac == "00:00:00:00:00:00":
|
||||
continue
|
||||
out.append({
|
||||
"id": _slug(f"host-{ip}"), "name": ip, "type": "host",
|
||||
"ip": ip, "mac": mac, "via": "arp",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _merge_devices(*lists) -> list[dict]:
|
||||
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||
by_ip: dict[str, dict] = {}
|
||||
loose: list[dict] = []
|
||||
order = {"mdns": 3, "ssdp": 2, "arp": 1}
|
||||
for lst in lists:
|
||||
for d in lst:
|
||||
ip = d.get("ip") or ""
|
||||
if not ip:
|
||||
loose.append(d)
|
||||
continue
|
||||
cur = by_ip.get(ip)
|
||||
if not cur:
|
||||
by_ip[ip] = d
|
||||
else:
|
||||
# bessere Quelle / mehr Felder → mergen
|
||||
merged = {**d, **{k: v for k, v in cur.items() if v}}
|
||||
if order.get(d.get("via"), 0) >= order.get(cur.get("via"), 0):
|
||||
merged.update({k: v for k, v in d.items() if v})
|
||||
# DIAL-URL / mac aus beiden behalten
|
||||
for key in ("dialAppUrl", "mac", "model", "manufacturer"):
|
||||
merged[key] = d.get(key) or cur.get(key) or merged.get(key)
|
||||
by_ip[ip] = {k: v for k, v in merged.items() if v not in (None, "")}
|
||||
return list(by_ip.values()) + loose
|
||||
|
||||
|
||||
# ─── Control ────────────────────────────────────────────────────────
|
||||
|
||||
async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
||||
"""Fuehrt eine Steuer-Aktion aus. Guards: CONTROL_ENABLED + Allowlist."""
|
||||
if not CONTROL_ENABLED:
|
||||
return {"ok": False, "error": "Steuerung ist an diesem Satelliten deaktiviert (CONTROL_ENABLED=false)."}
|
||||
if action not in CONTROL_ALLOWLIST:
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht erlaubt (Allowlist: {', '.join(CONTROL_ALLOWLIST)})."}
|
||||
logger.info("[control] %s params=%s", action, {k: str(v)[:60] for k, v in (params or {}).items()})
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
if action == "dial.launch":
|
||||
return await loop.run_in_executor(None, _do_dial_launch, params, devices)
|
||||
if action == "wol":
|
||||
return await loop.run_in_executor(None, _do_wol, params)
|
||||
if action in ("http.get", "http.post"):
|
||||
return await loop.run_in_executor(None, _do_http, action, params)
|
||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||
|
||||
|
||||
def _find_device(devices: list[dict], ref: str) -> Optional[dict]:
|
||||
ref = (ref or "").strip().lower()
|
||||
if not ref:
|
||||
return None
|
||||
for d in devices:
|
||||
if d.get("id", "").lower() == ref or d.get("ip", "") == ref:
|
||||
return d
|
||||
for d in devices:
|
||||
if ref in (d.get("name", "").lower()):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _do_dial_launch(params: dict, devices: list[dict]) -> dict:
|
||||
"""DIAL-App-Launch, z.B. YouTube-Video auf Fire TV / Smart-TV.
|
||||
params: {device, app='YouTube', v=<videoId> (oder beliebige app-params)}"""
|
||||
import requests
|
||||
ref = params.get("device") or ""
|
||||
dev = _find_device(devices, ref)
|
||||
app_url = (dev or {}).get("dialAppUrl") if dev else params.get("dialAppUrl")
|
||||
if not app_url:
|
||||
return {"ok": False, "error": f"Kein DIAL-Geraet fuer '{ref}' gefunden (oder keine Application-URL)."}
|
||||
app = params.get("app") or "YouTube"
|
||||
# app-Parameter (alles ausser device/app) als form-urlencoded Body.
|
||||
body = {k: v for k, v in (params or {}).items() if k not in ("device", "app", "dialAppUrl")}
|
||||
url = app_url.rstrip("/") + "/" + app
|
||||
r = requests.post(url, data=body, timeout=5)
|
||||
ok = r.status_code in (200, 201)
|
||||
return {"ok": ok, "result": f"DIAL {app} → {(dev or {}).get('name', ref)} (HTTP {r.status_code})"
|
||||
if ok else None,
|
||||
"error": None if ok else f"DIAL-Launch HTTP {r.status_code}: {r.text[:120]}"}
|
||||
|
||||
|
||||
def _do_wol(params: dict) -> dict:
|
||||
mac = (params.get("mac") or "").strip()
|
||||
if not re.match(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", mac):
|
||||
return {"ok": False, "error": f"Ungueltige MAC: {mac!r}"}
|
||||
clean = re.sub(r"[:-]", "", mac)
|
||||
packet = b"\xff" * 6 + bytes.fromhex(clean) * 16
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.sendto(packet, ("255.255.255.255", 9))
|
||||
s.close()
|
||||
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||
|
||||
|
||||
def _do_http(action: str, params: dict) -> dict:
|
||||
import requests
|
||||
url = params.get("url") or ""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||
method = "GET" if action == "http.get" else "POST"
|
||||
r = requests.request(method, url, data=params.get("body"),
|
||||
headers=params.get("headers"), timeout=6)
|
||||
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
s = (s or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "dev"
|
||||
|
||||
|
||||
def _parse_http_headers(text: str) -> dict:
|
||||
headers = {}
|
||||
for line in text.split("\r\n")[1:]:
|
||||
if ":" in line:
|
||||
k, _, v = line.partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def _xml_tag(xml: str, tag: str) -> str:
|
||||
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
# ─── Satellit (RVS-Client) ──────────────────────────────────────────
|
||||
|
||||
class Satellite:
|
||||
def __init__(self) -> None:
|
||||
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._devices: list[dict] = []
|
||||
self._devices_ts: float = 0.0
|
||||
self._scanning = False
|
||||
|
||||
async def _scan(self, force: bool = False) -> list[dict]:
|
||||
fresh = (time.time() - self._devices_ts) < DEVICE_CACHE_TTL_SEC
|
||||
if self._devices and fresh and not force:
|
||||
return self._devices
|
||||
if self._scanning:
|
||||
# Laufenden Scan abwarten (grob)
|
||||
for _ in range(30):
|
||||
await asyncio.sleep(0.2)
|
||||
if not self._scanning:
|
||||
break
|
||||
return self._devices
|
||||
self._scanning = True
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
self._devices = _merge_devices(mdns, ssdp, arp)
|
||||
self._devices_ts = time.time()
|
||||
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
|
||||
len(self._devices), len(mdns), len(ssdp), len(arp))
|
||||
finally:
|
||||
self._scanning = False
|
||||
return self._devices
|
||||
|
||||
async def _send(self, message: dict) -> None:
|
||||
if self.ws is None:
|
||||
return
|
||||
try:
|
||||
await self.ws.send(json.dumps(message))
|
||||
except Exception as exc:
|
||||
logger.warning("send fehlgeschlagen: %s", exc)
|
||||
|
||||
async def _hello(self, log: bool = False) -> None:
|
||||
await self._send({
|
||||
"type": "sat_hello",
|
||||
"payload": {
|
||||
"id": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION,
|
||||
"caps": CAPABILITIES,
|
||||
"control": CONTROL_ENABLED,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if log:
|
||||
logger.info("sat_hello gesendet: id=%s location=%s caps=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CAPABILITIES)
|
||||
|
||||
def _for_me(self, payload: dict) -> bool:
|
||||
target = (payload.get("satellite") or "").strip().lower()
|
||||
if not target or target in ("all", "*"):
|
||||
return True
|
||||
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
|
||||
|
||||
async def _handle(self, raw: str) -> None:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
mtype = msg.get("type", "")
|
||||
payload = msg.get("payload", {}) or {}
|
||||
|
||||
if mtype == "sat_discover":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
devices = await self._scan(force=bool(payload.get("force")))
|
||||
await self._send({
|
||||
"type": "sat_devices",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
|
||||
"location": SATELLITE_LOCATION, "devices": devices},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
elif mtype == "sat_command":
|
||||
if not self._for_me(payload):
|
||||
return
|
||||
req_id = payload.get("requestId", "")
|
||||
action = (payload.get("action") or "").strip()
|
||||
params = payload.get("params") or {}
|
||||
if payload.get("device") and "device" not in params:
|
||||
params["device"] = payload.get("device")
|
||||
devices = await self._scan()
|
||||
result = await _control(action, params, devices)
|
||||
await self._send({
|
||||
"type": "sat_result",
|
||||
"payload": {"requestId": req_id, "satellite": SATELLITE_ID, **result},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
async def _periodic_scan(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._scan(force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("periodischer Scan-Fehler: %s", exc)
|
||||
await asyncio.sleep(SCAN_INTERVAL_SEC)
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
# Re-announce bei jedem Heartbeat: falls die Bridge NACH uns (neu)
|
||||
# verbindet, lernt sie uns so innerhalb von HEARTBEAT_SEC — RVS replayt
|
||||
# nichts. Haelt zugleich last_seen in der Bridge-Registry frisch.
|
||||
while True:
|
||||
await asyncio.sleep(HEARTBEAT_SEC)
|
||||
await self._send({"type": "heartbeat", "timestamp": int(time.time() * 1000)})
|
||||
await self._hello()
|
||||
|
||||
async def run(self) -> None:
|
||||
if not RVS_HOST or not RVS_TOKEN:
|
||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||
return
|
||||
asyncio.create_task(self._periodic_scan())
|
||||
backoff = 1
|
||||
while True:
|
||||
proto = "wss" if RVS_TLS else "ws"
|
||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||
try:
|
||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
||||
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
||||
ping_interval=20, ping_timeout=20) as ws:
|
||||
self.ws = ws
|
||||
backoff = 1
|
||||
await self._hello(log=True)
|
||||
hb = asyncio.create_task(self._heartbeat())
|
||||
try:
|
||||
async for raw in ws:
|
||||
await self._handle(raw)
|
||||
finally:
|
||||
hb.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||
finally:
|
||||
self.ws = None
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
|
||||
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
|
||||
try:
|
||||
asyncio.run(Satellite().run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user