Compare commits

...
35 Commits
Author SHA1 Message Date
duffyduck 2aef0347ae release: bump version to 0.2.2.9 2026-07-21 01:21:23 +02:00
duffyduckandClaude Opus 4.8 a49c022308 fix(vnc): Tastatur-Button folgt echter Tastatur-Sichtbarkeit
Androids Zurueck-Taste versteckt die Tastatur ohne den TextInput zu
blurren → ⌨-Button blieb an, erst der uebernaechste Tap oeffnete wieder.
Jetzt setzt keyboardDidShow/Hide den Button-Zustand; Oeffnen erzwingt
blur→focus, damit ein noch fokussiertes Feld die Tastatur neu aufklappt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:19:37 +02:00
duffyduckandClaude Opus 4.8 2d3ba024a4 feat(vnc): Fn-Steuertasten-Leiste + Enter-Fix fuer VM-Bedienung
Die Software-Tastatur liefert weder Enter (Haken) noch Esc/Pfeile/F-Tasten/
Strg/Alt. Neu:
- Haken der Tastatur → onSubmitEditing sendet jetzt Enter (returnKeyType=send).
- Fn-Leiste (Toggle in der ctlBar): Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/
  Entf, Enter, F1–F12, Strg+Alt+Entf — direkt an rfb.sendKey.
- Sticky-Modifier Strg/Alt/Shift (one-shot): kombinieren mit der naechsten
  Taste ODER dem naechsten getippten Zeichen → Strg+C, Strg+Alt+Entf, Alt+F …
- noVNC: window.ariaVncKey.combo(mods,ks) haelt Modifier, tappt, laesst los.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:17:56 +02:00
duffyduck 1c7157327c release: bump version to 0.2.2.8 2026-07-21 01:10:26 +02:00
duffyduckandClaude Opus 4.8 6c27097c96 fix(app): gruener Desktop-Punkt nur bei laufender VM im aktiven Projekt
Badge haing bisher am globalen desktop_status (irgendwo ein Desktop
erreichbar) → wurde generell angezeigt. Jetzt aus der projektbezogenen
VM-Liste (listProjectVms → running), pid-Guard, 6s-Refresh fuer Start/Stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:08:37 +02:00
duffyduckandClaude Opus 4.8 a91325a04f fix(vnc): Tastatur ueber echtes RN-TextInput statt WebView-Hidden-Input
Der versteckte WebView-Input oeffnete die Android-Software-Tastatur
unzuverlaessig. Jetzt haelt VncTile ein echtes RN-<TextInput>
(keyboardType=visible-password), der ⌨-Button fokussiert es. Getippte
Zeichen gehen per injectJavaScript an window.ariaVncKey.char/keysym →
rfb.sendKey. Prefix-Diff fuer Druckbares, onKeyPress fuer Backspace/Enter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:06:27 +02:00
duffyduck ef826d1ed1 release: bump version to 0.2.2.7 2026-07-21 01:01:54 +02:00
duffyduckandClaude Opus 4.8 933836f0a6 fix(app): kein 404 bei Dateien/Desktop im Hauptchat (leere project_id)
Im Hauptchat (projectId='') riefen FilesTile/DesktopTile /projects//files bzw.
/vms auf → Brain 404. Beide guarden jetzt leere projectId: kein Fetch, klare
Meldung "Kein aktives Projekt — wechsle in ein Projekt".

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:59:50 +02:00
duffyduck e04d8f360b release: bump version to 0.2.2.6 2026-07-21 00:54:02 +02:00
duffyduckandClaude Opus 4.8 4685632294 feat(vnc): echtes Vollbild beim Verbinden (randlos ueber das ganze Display)
Der ⤢-Knopf war nur der Fit/1:1-Umschalter (Zoom) INNERHALB des eingerahmten
Streifens (Header+Dock drumrum) — sah deshalb wie Zoom aus, fuellte nie das
Display. Jetzt oeffnet "Verbinden" die VM in einem Fullscreen-Modal: randlos
ueber alles (Header + Dock weg), scaleViewport passt den VM-Screen sauber ein,
Landscape erlaubt. Buttons schweben drueber (‹ VMs links, ⌨/Strg+Alt+Entf/⤢
rechts, unter der Statusleiste). Android-Back verlaesst Vollbild.

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:51:59 +02:00
duffyduck 769025c41b release: bump version to 0.2.2.5 2026-07-21 00:47:46 +02:00
duffyduckandClaude Opus 4.8 2005e9b85e fix(vm): Screenshot ohne /root-Rechte + VNC-Tastatur robuster
Screenshot: aria-vm lief als User 'aria', SHOT_DIR war aber /root/... →
"mkdir: cannot create directory /root". Jetzt schreibt aria-vm das PNG ins
VM-Verzeichnis (aria-schreibbar) und der Brain holt es per SSH (base64,
_ssh_host) — unabhaengig von Volume-Rechten. End-to-end validiert (gueltiges
PNG). Wird weiter ins Projekt (screenshots/) kopiert.

VNC-Tastatur: verstecktes Input-Feld war off-screen (opacity:0, left:-1000px) →
Android oeffnete die Tastatur oft nicht / lieferte keine Events. Jetzt on-screen
(bottom, 1px, opacity:0, pointer-events:none) + beforeinput als primaerer
Handler (Android-robust) mit input-Fallback. Strg+Alt+Entf ging schon.

py/bash/tsc clean. aria-vm bereits live. Deploy: brain rebuild + APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:45:41 +02:00
duffyduckandClaude Opus 4.8 25abd220ad fix(vnc): Bridge nutzt aria-net-Gateway statt host.docker.internal (VNC refused)
Kern des "Verbinde..."-Hangs: host.docker.internal loeste zu 172.17.0.1 (docker0)
auf, QEMUs VNC ist aber ans aria-net-Gateway 192.168.64.1 gebunden (dorthin
bindet der Brain) → Connection refused. Die Bridge ermittelt den VNC-Host jetzt
selbst per _docker_gateway() (/proc/net/route) = dasselbe Gateway wie der Brain.
ARIA_VNC_HOST-Env hat weiter Vorrang.

Live belegt: Bridge->192.168.64.1:5901 liefert RFB-Handshake, ->host.docker.
internal:5901 (172.17.0.1) = refused.

py_compile clean. Deploy: bridge rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:35:26 +02:00
duffyduck 3c6bf0783e release: bump version to 0.2.2.4 2026-07-21 00:25:19 +02:00
duffyduckandClaude Opus 4.8 2832ab3dc9 fix(vm): global eindeutige VNC-Displays (kein Doppel-Binding) + Gateway per-boot
- project_vms.add_vm: VNC-Display wird GLOBAL eindeutig vergeben (ueber alle
  Projekte). vnc_display<=0 oder belegtes Display → naechstes freies. Updates
  behalten ihr Display. Verhindert Port-Konflikt (5901) wenn mehrere VMs laufen.
  Getestet: vm1..3 → 1,2,3; Wunsch 2 (belegt) → 4; Update behaelt Display.
- vm_register-Tool + Seed-Regel: vnc_display weglassen, wird auto-vergeben.
- Gateway-IP: der Brain liest sie aus /proc/net/route bei JEDEM Boot frisch
  (Docker-IP-Aenderung nach Netz-Neuaufbau wird abgefangen); boot-Antwort
  enthaelt vnc_bind zur Transparenz.

py_compile clean. Brain-only, Deploy: brain rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:23:23 +02:00
duffyduckandClaude Opus 4.8 d38d62ba21 fix(vnc): Bridge erreicht QEMU-VNC (Kern-Bug) + Screenshots ins Projekt
Live-VNC rendert nie, weil die Bridge die VM nie erreichte: QEMU band 127.0.0.1
(Host-Loopback) und host.docker.internal resolved in der Bridge gar nicht.
- aria-vm: --vnc-bind <ip> (Default 127.0.0.1 / env ARIA_VM_VNC_BIND).
- Brain: _docker_gateway() (aus /proc/net/route) → boot bindet VNC an die
  Docker-Gateway-IP (container-erreichbar, NICHT im LAN/Internet).
- docker-compose: bridge bekommt extra_hosts host.docker.internal:host-gateway.
- Screenshot-Endpoint kopiert das PNG zusaetzlich nach
  /shared/projects/<pid>/screenshots/ → erscheint im Dateien-Panel.

End-to-end auf dem Host validiert: Bridge liest den RFB-Handshake
(b'RFB 003.008\n') von der Gateway-gebundenen VNC. aria-vm bereits live.
py/bash clean. Deploy: brain rebuild + bridge NEU (extra_hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:20:21 +02:00
duffyduckandClaude Opus 4.8 5890c17ec0 feat(app): Dateien-Panel im Dock (zwischen Chat und Code)
Neues 📁-Panel listet ALLE Projektdateien (/shared/projects/<id>/) — auch
erzeugte Bilder, nicht nur Code; dieselben, die in der Projektliste als 📄
gezaehlt werden. Bild antippen → Vollbild-Vorschau; Textdatei → Text-Vorschau.
- Brain: /projects/<id>/file?binary=1 → Base64 + MIME (fuer Bilder, max 8 MB).
- App: brainApi.readProjectFileBinary; layout 'files'-Tile; Dock-Reihenfolge
  Chat · Dateien · Code · Desktop; FilesTile mit Bild-/Text-Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:09:34 +02:00
duffyduckandClaude Opus 4.8 239f1094f9 feat(vm): Screenshot pro VM im Desktop-Panel (VM sehen ohne Live-VNC)
Solange das Live-VNC-Bild noch hakt, ist ein Standbild der pragmatische Weg die
VM zu sehen — genau wie ARIA es beim basic_os-Test gemacht hat.
- Brain: POST /projects/<pid>/vms/<name>/screenshot → aria-vm screenshot (PNG in
  /shared/uploads) → als Base64 zurueck. End-to-end auf dem Host validiert
  (Brain-Container sieht die Datei unter /shared/uploads).
- App: 📷-Knopf pro laufender VM im DesktopTile → zeigt den Screenshot im Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:55:47 +02:00
duffyduckandClaude Opus 4.8 055db7c059 feat(vm): Medien pro VM (disk/floppy/iso) + ARIA-Urteilswissen
vm_register + Registry + boot fassen jetzt das ECHTE Boot-Medium (disk/floppy/
iso) — Eintrag, Startbefehl und App-Start spiegeln die reale Config. Leer =
aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso selbst.
- project_vms.add_vm: floppy/disk-Felder.
- main.py: VmAddBody + _vm_boot_args (baut --disk/--floppy/--iso), boot-Endpoint
  + boot_cmd nutzen sie.
- agent.py: vm_register-Tool bekommt disk/floppy/iso.
- Seed-Regel: URTEIL — VM nur wenn sinnvoll; Medium aus der Situation (Festplatte
  fuer DOS-Spiele, Diskette fuer OS-Dev, ISO fuer Installer); Architektur zum Task
  (ARM=aarch64, emuliert/langsam ok); Aenderungs-Zyklus stop→aendern→boot.

py_compile clean. aria-vm schon live auf dem Host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:51:27 +02:00
duffyduckandClaude Opus 4.8 2a8cbc6c15 fix(vm): aria-vm boot flexibel (Diskette/ISO ohne Disk) + ARIA-Grundwissen
Start aus der App scheiterte: aria-vm boot verlangte zwingend disk.qcow2 —
basic_os bootet aber von floppy.img/ISO ohne Festplatte (OS-Bau). Jetzt:
- aria-vm boot: Disk optional; --floppy (-fda), --iso, --disk; Auto-Erkennung
  von disk.qcow2/floppy.img/cdrom.iso im VM-Ordner; Boot-Reihenfolge automatisch
  (ISO→d, nur Diskette→a, sonst c); braucht nur >=1 Boot-Medium.
- aria-vm create: groesse='none' → VM ohne Disk.
- Seed-Regel (Grundwissen) neu: volle aria-vm-Optionen inkl. disk-los, PFLICHT
  vm_register nach Bau (sonst leere Desktop-Liste), Dateien in /shared/projects,
  VNC komplett durch RVS. So weiss ARIA selbst was zu tun ist.

Live getestet auf dem Host: boot basicos (floppy-Autodetect) → laeuft :5901.
aria-vm bereits auf den Host gepusht. bash -n / py_compile OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:48:22 +02:00
duffyduck 7e14107361 release: bump version to 0.2.2.3 2026-07-20 23:34:05 +02:00
duffyduckandClaude Opus 4.8 e64043dca5 feat: Datei-Symbol am Projekt (auto) + VM-Startparameter im Desktop-Panel
- Brain: /projects/status + /list liefern has_files + file_count pro Projekt
  (Scan /shared/projects/<id>/). Ersetzt das manuelle Code-Flag als primaeren
  Indikator. VM-Liste liefert boot_cmd (lesbarer aria-vm-Startbefehl).
- App: 📄-Symbol (+ Anzahl) an Projekten mit Dateien im ProjectsBrowser.
  DesktopTile zeigt pro VM den Start-Befehl als Wert dahinter.
- Diagnostic: 📄-Symbol (+ Anzahl, Tooltip) an Projekten mit Dateien.

Hinweis (kein Code): der VNC-Stream laeuft komplett durch RVS — der Port ist nur
der interne QEMU-Display-Port, den die Bridge lokal auf dem Host nutzt; die App
oeffnet nie einen Port (firewall-unabhaengig).

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:31:31 +02:00
duffyduckandClaude Opus 4.8 0efb8d0848 feat: VM-Liste pro Projekt (Desktop-Panel) — Start/Stop/Verbinden
- Brain: project_vms.py (Registry /shared/config/project_vms.json pro Projekt) +
  Endpoints GET/POST/DELETE /projects/<id>/vms + boot/stop (via SSH aria-wohnung
  aria-vm auf dem Host; Status aus `aria-vm list`).
- ARIA-Tools vm_register/vm_list (aufs Request-Projekt) + Seed-Regel: nach dem
  VM-Bau registrieren, damit sie in Stefans Desktop-Panel auftaucht.
- App: brainApi VM-Methoden + ProjectVm-Typ. Neues DesktopTile — pro Projekt die
  VM-Liste (leer bis registriert), Start/Stop/Verbinden; Verbinden oeffnet noVNC
  (VncTile mit dem VNC-Port der VM). Deck rendert DesktopTile statt VncTile.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:25:55 +02:00
duffyduckandClaude Opus 4.8 5fa5d79ad8 feat: Editor laedt vorhandene Dateien + manueller Code-Toggle (App + Diagnostic)
Editor zeigte "keine Datei", obwohl ARIA schon Dateien geschrieben hatte — er las
NUR den Live-code_file-Stream, nie den Bestand. Jetzt:
- Brain: GET /projects/<id>/files + /file (liest /shared/projects/<id>/, pfad-sicher,
  512KB-Cap). kind in ProjectUpdateBody (PATCH akzeptiert 'code'|'chat').
- App: brainApi.listProjectFiles/readProjectFile/setProjectKind. CodeEditorTile
  holt beim Oeffnen die vorhandene Dateiliste + laedt Inhalt (Live-Version hat
  Vorrang). ProjectsBrowser-Edit: Code-Projekt-Toggle (spiegelt sofort in
  projectFocus → Cockpit-Panels).
- Diagnostic: </> Code-Toggle je Projektzeile + Code-Badge.

py/node/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:20:39 +02:00
duffyduckandClaude Opus 4.8 9dd7a2cb14 fix(satellite): install.ps1 — richtiges Cmdlet New-ScheduledTaskSettingsSet
New-ScheduledTaskSettings existiert nicht (CommandNotFound). Korrekt ist
New-ScheduledTaskSettingsSet. venv/Requirements liefen bereits; nur die
Task-Registrierung brach ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:07:07 +02:00
duffyduckandClaude Opus 4.8 2811379684 feat(satellite): Dienst-Installer install.sh (Linux/macOS) + install.ps1 (Windows)
Richten den Satelliten als Autostart-Dienst ein (venv + requirements + Restart):
- install.sh: Linux → systemd-Unit (aria-satellite), macOS → launchd-Agent.
  `bash install.sh` / `bash install.sh uninstall`.
- install.ps1: Windows → Scheduled Task (S4U, AtStartup+AtLogOn, Restart, hidden),
  loggt nach satellite.log. Admin-Check. `-Uninstall` entfernt.
- WorkingDirectory = satellite/ (findet .env). README-Abschnitt ergaenzt.

install.sh bash -n OK; install.ps1 manuell geprueft (kein pwsh lokal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:02:30 +02:00
duffyduckandClaude Opus 4.8 902316566e fix(satellite): .env-Parser schneidet Inline-Kommentare ab
Nativer Start crashte an `SCAN_INTERVAL_SEC=300      # Hintergrund-Rescan-Intervall`
(int('300      # ...') → ValueError), weil der .env-Parser den Wert ungekuerzt
nahm. Jetzt: ungequotete Werte werden am ersten " #" (Whitespace+#) abgeschnitten,
gequotete Werte bleiben unangetastet (auch mit # im Inhalt). Getestet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:57:05 +02:00
duffyduckandClaude Opus 4.8 1f94b4eab5 fix(satellite): laedt .env selbst beim nativen Start
Nativ gestartet (python satellite.py) las das Script nichts aus der .env — nur
os.environ. Die Variablen mit Default (SATELLITE_ID, CONTROL_ENABLED …) wirkten
"ok", aber RVS_HOST/RVS_TOKEN (ohne Default) blieben leer → Verbindungsfehler.
Jetzt laedt _load_dotenv() eine .env neben dem Script (oder im CWD), bevor die
Config gelesen wird. Bestehende echte Umgebungsvariablen gewinnen (Docker via
env_file bleibt unberuehrt). Kein python-dotenv noetig. README Weg B vereinfacht.

Getestet: Parser liest Keys inkl. Quotes + export-Praefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:55:04 +02:00
duffyduckandClaude Opus 4.8 57e13800e0 fix(satellite): Selbstdiagnose fuers Netz — warnt bei Docker-/NAT-Netz
Symptom: Satellit fand nur Docker-Container (192.168.65.x / 172.18.x) statt der
echten LAN-Geraete. Ursache ist kein Bug, sondern das Deployment-Netz: auf Docker
Desktop (Mac/Windows) ist network_mode:host das Docker-VM-NAT, nicht das echte LAN
— mDNS/SSDP erreichen die realen Geraete nicht.

- satellite.py: _net_context() ermittelt primary_ip + alle IPs und WARNT, wenn der
  Satellit in einem Docker-/NAT-Netz laeuft (192.168.65.x oder 172.16-31.x). Netz-
  Info wird in sat_hello + sat_devices mitgeschickt und beim Start geloggt.
- Diagnostic: zeigt Netz (primary_ip) pro Satellit + eine rote ⚠-Box mit der
  Warnung, wenn er im falschen Netz sitzt.
- README/compose: klar dokumentiert, dass der Satellit im ECHTEN Ziel-LAN laufen
  muss (Linux Docker Engine ODER nativ python satellite.py); Docker-Desktop-Falle.

py/node clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:46:34 +02:00
duffyduckandClaude Opus 4.8 1dc8c0936f feat(diagnostic): Satelliten-Tab — verbundene Satelliten + erkannte Geraete
Das Diagnostic haengt selbst am RVS-Raum und kennt die sat_*-Broadcasts jetzt:
- server.js: Satelliten-Registry (sat_hello), leitet sat_devices an den Browser,
  gibt die Liste beim init mit; Browser-Aktionen sat_list + sat_discover.
- index.html: neuer Haupt-Tab "Satelliten" — Karten pro Satellit (online/offline,
  Standort, Capabilities, read-only/steuerbar) mit "Geraete scannen" → Live-Liste
  der erkannten Geraete (Typ/IP/Modell/DIAL/MAC).

Kein Brain/Bridge-Rebuild fuer diesen Teil noetig (nur diagnostic). node -c OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:11:14 +02:00
duffyduckandClaude Opus 4.8 071e38f464 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>
2026-07-19 23:22:30 +02:00
duffyduck 28361ea97a release: bump version to 0.2.2.2 2026-07-17 22:57:49 +02:00
duffyduckandClaude Opus 4.8 a20e57e33a fix(brain): project_summary liest volle Projekt-Historie aus chat_backup.jsonl
"Hol dir die Infos aus Projekt X" lieferte fast immer leer: project_summary las
nur das rollende Conversation-Window (~50 Turns ueber alle Projekte), aeltere
Projekt-Chats sind da rausdistilliert. Jetzt liest _read_project_history die
echte volle Historie aus /shared/config/chat_backup.jsonl (im Brain gemountet),
letzte ~20 Turns des Zielprojekts, Standort-Hints gefiltert, Fallback aufs
Window. Tool-Beschreibung geschaerft. Live gegen echte Daten getestet
(basic_os 20 / vdi 20 / mac_os_update_fehler 6 Turns).

Kein APK-Rebuild noetig (nur Brain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:55:54 +02:00
duffyduckandClaude Opus 4.8 dc775ee34f feat: geraetelokaler Voice-Projektwechsel ("geh in Projekt X")
Der Voice-Projektwechsel ist zurueck, aber nur fuer das ausloesende Geraet:
- App merkt sich die IDs eigener Anfragen (Text-clientMsgId via dispatchWithAck +
  Voice-audioRequestId an allen 4 Aufnahme-Stellen) in myRequestIdsRef.
- Bridge haengt an project_changed die ausloesende clientMsgId an — in beiden
  Voice-Pfaden: send_to_core (ARIAs project_enter/exit) und _process_endpoint_text
  (Voice-Router back_to_main / project_prefix).
- App folgt einem project_changed-Wechsel nur, wenn die clientMsgId eine eigene
  ist → andere App-Instanzen + Diagnostic bleiben unberuehrt.

Nur Bridge + App betroffen. py-compile + tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:47:38 +02:00
duffyduckandClaude Opus 4.8 c2122c43c2 fix: Projekt-Fokus pro Geraet unabhaengig (Diagnostic/App/Multi-Instanz)
Zwei Kopplungs-Bugs behoben:
- set_project_kind nutzte den globalen active_project statt des Request-Projekts
  → markierte das falsche Projekt (belegt: global aktiv war mac_os_update_fehler,
  das kind=code bekam, obwohl die App in basic_os war). _dispatch_tool bekommt
  jetzt die project_id des Requests durchgereicht; set_project_kind wirkt darauf.
- Die App erzwang bei project_changed-Broadcasts (ARIA-Tool/Diagnostic/andere
  App-Instanz) einen Fokuswechsel → alle Geraete wurden mitgezogen. Fokus ist
  jetzt rein geraetelokal; Broadcasts aktualisieren nur Namen/Typ. Wechseln nur
  noch lokal ueber den Drawer.

py-compile + tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:37:51 +02:00
33 changed files with 2949 additions and 145 deletions
+59
View File
@@ -10,6 +10,65 @@ 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).
- **Diagnostic: neuer Tab „Satelliten"** — zeigt live welche Satelliten verbunden sind (online/offline, Standort, Capabilities, read-only vs. steuerbar) und pro Satellit ein „Geräte scannen" (löst `sat_discover` aus → erkannte Geräte mit Typ/IP/Modell/DIAL/MAC).
### 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
- **`project_summary` fand fast nie Inhalte.** Es las nur ARIAs rollendes Kontextfenster (~50 Turns über ALLE Projekte) — ältere/andere Projekt-Chats sind da längst rausdistilliert. „Hol dir die Infos aus Projekt X" lieferte deshalb leere Ergebnisse, und ARIA wusste nicht, wie sie an die Historie kommt. Jetzt liest das Tool die **echte, volle Historie aus `chat_backup.jsonl`** (im Brain gemountet) — die letzten ~20 Turns des Zielprojekts, unabhängig davon wie lange man da nicht war. Standort-Hints in User-Turns werden rausgefiltert. Fallback aufs Fenster, falls das Backup fehlt. Tool-Beschreibung geschärft, damit ARIA es direkt aufruft.
- Nebenbei bereinigt: `mac_os_update_fehler` war durch den alten `set_project_kind`-Bug (0.2.2.1) fälschlich `kind=code` — auf `chat` zurückgesetzt.
### Deploy
`git pull && docker compose up -d --build brain` (kein APK-Rebuild nötig).
---
## [0.2.2.2] — 2026-07-17 — Voice-Projektwechsel zurück — aber gerätelokal
### Hinzugefügt
- **„ARIA, geh in Projekt X" per Sprache wechselt wieder das Projekt** — aber **nur auf dem Gerät, das den Befehl gab**. Andere App-Instanzen und das Diagnostic bleiben in ihrem Projekt.
- Umsetzung: Jedes Gerät merkt sich die IDs seiner eigenen Anfragen (Text-`clientMsgId` + Voice-`audioRequestId`). Der Brain/Voice-Router hängt an jedes `project_changed`-Event die auslösende `clientMsgId` an; die App folgt dem Wechsel nur, wenn die ID eine **eigene** ist. Deckt beide Voice-Pfade ab (ARIAs `project_enter`/`exit` via `send_to_core` **und** den Bridge-Voice-Router „für Projekt X: …" / „zurück zum Hauptchat").
- Manuelles Umschalten bleibt wie gehabt gerätelokal über den Drawer.
### Deploy
`git pull && docker compose up -d --build bridge` + APK 0.2.2.2 (kein Brain-Rebuild nötig).
---
## [0.2.2.1] — 2026-07-17 — Projekt-Fokus wirklich pro Gerät unabhängig
### Behoben
- **`set_project_kind` markierte das falsche Projekt.** Es nutzte den **globalen** `active_project` (geräteübergreifend) statt des Projekts, in dem der aktuelle Request läuft. Folge: Obwohl auf der App „Basic os" aktiv war, bekam das global-aktive „Mac OS Update Fehler" `kind=code`. Jetzt wirkt das Tool auf die pro Request mitgesendete `project_id` (in `_dispatch_tool` durchgereicht) — jede App-/Diagnostic-Instanz markiert ihr eigenes Projekt.
- **App wurde von fremden `project_changed`-Broadcasts ins andere Projekt gezogen.** Ein Projektwechsel/anlegen durch ARIA, das Diagnostic oder eine andere App-Instanz erzwang auf **allen** Geräten einen Fokuswechsel. Der Projekt-Fokus ist jetzt **rein gerätelokal**: Broadcasts aktualisieren nur Namen + Typ, wechseln aber nicht mehr das Projekt. Umschalten geht ausschließlich lokal über den Projekt-Drawer. So arbeitet jedes Gerät unabhängig in seinem eigenen Projekt (auch mehrere App-Instanzen).
- Hinweis: „ARIA, geh in Projekt X" per Sprache wechselt das App-Projekt dadurch **nicht mehr** automatisch — bewusst, damit Geräte unabhängig bleiben. Wechseln über den Drawer.
### Deploy
`git pull && docker compose up -d --build brain` + APK 0.2.2.1 neu bauen.
---
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
### Geändert
+1
View File
@@ -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,
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20201
versionName "0.2.2.1"
versionCode 20209
versionName "0.2.2.9"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.2.1",
"version": "0.2.2.9",
"private": true,
"scripts": {
"android": "react-native run-android",
+30 -3
View File
@@ -28,6 +28,7 @@ import {
import brainApi, { Project } from '../services/brainApi';
import rvs from '../services/rvs';
import projectFocus from '../services/projectFocus';
interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
@@ -75,6 +76,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
@@ -148,18 +150,25 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
setEditing(p);
setEditName(p.name);
setEditDesc(p.description || '');
setEditKind(p.kind === 'code' ? 'code' : 'chat');
}, []);
const saveEdit = useCallback(() => {
if (!editing) return;
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
const curKind = editing.kind === 'code' ? 'code' : 'chat';
if (editKind !== curKind) patch.kind = editKind;
if (Object.keys(patch).length === 0) { setEditing(null); return; }
brainApi.updateProject(editing.id, patch)
.then(() => { setEditing(null); load(); })
.then(() => {
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
setEditing(null); load();
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [editing, editName, editDesc, load]);
}, [editing, editName, editDesc, editKind, load]);
const endProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" beenden?`,
@@ -216,6 +225,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{item.has_files && (
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
)}
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
@@ -375,6 +387,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
style={[s.input, { height: 70 }]}
multiline
/>
<TouchableOpacity
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
>
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
</Text>
<View style={{
width: 46, height: 26, borderRadius: 13, padding: 3,
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
}}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
</View>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
+33 -4
View File
@@ -407,6 +407,22 @@ const ChatScreen: React.FC = () => {
const nextClientMsgId = (): string =>
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
// IDs (clientMsgId + audioRequestId), die DIESES Geraet ausgeloest hat.
// Fuer den geraete-lokalen Voice-Projektwechsel: ein project_changed vom Brain
// traegt die ausloesende ID; nur wenn sie zu einer unserer IDs gehoert, folgt
// dieses Geraet dem Wechsel. So wechselt „ARIA, geh in Projekt X" nur das
// Geraet, das den Befehl gab — nicht andere App-/Diagnostic-Instanzen.
const myRequestIdsRef = useRef<Set<string>>(new Set());
const rememberMyRequest = (id: string): void => {
if (!id) return;
const s = myRequestIdsRef.current;
s.add(id);
if (s.size > 80) {
const oldest = s.values().next().value;
if (oldest !== undefined) s.delete(oldest);
}
};
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
// 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst
@@ -462,6 +478,7 @@ const ChatScreen: React.FC = () => {
return;
}
pendingPayloads.current.set(cmid, { type, payload });
rememberMyRequest(cmid);
const online = connectionStateRef.current === 'connected';
if (!online) {
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
@@ -992,10 +1009,18 @@ const ChatScreen: React.FC = () => {
if (p.id && (p.kind === 'code' || p.kind === 'chat')) {
projectFocus.setKind(p.id, p.kind);
}
if (action === 'entered' || action === 'created') {
if (p.id) setFocusedProjectId(p.id);
} else if (action === 'exited') {
setFocusedProjectId('');
// Projekt-Fokus ist GERAETE-LOKAL. Ein Broadcast wechselt dieses Geraet
// NUR, wenn er durch EINEN EIGENEN Befehl ausgeloest wurde — erkennbar an
// der mitgelieferten clientMsgId (unsere gesendete Text-cmid oder Voice-
// audioRequestId). So folgt „ARIA, geh in Projekt X" nur dem Geraet, das
// den Befehl gab; Diagnostic / andere App-Instanzen bleiben unberuehrt.
const trigCmid = typeof p.clientMsgId === 'string' ? p.clientMsgId : '';
if (trigCmid && myRequestIdsRef.current.has(trigCmid)) {
if (action === 'entered' || action === 'created') {
if (p.id) setFocusedProjectId(p.id);
} else if (action === 'exited') {
setFocusedProjectId('');
}
}
return;
}
@@ -1620,6 +1645,7 @@ const ChatScreen: React.FC = () => {
// dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
// aktualisiert (siehe chat-Handler oben).
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
@@ -1723,6 +1749,7 @@ const ChatScreen: React.FC = () => {
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
await new Promise(r => setTimeout(r, 150));
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const windowMs = await loadConvWindowMs();
@@ -1799,6 +1826,7 @@ const ChatScreen: React.FC = () => {
// useCallback damit der useEffect oben die Funktion stabil capturen kann.
const startPassiveStreamingRecording = useCallback(async () => {
const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const location = await getCurrentLocation();
const passiveMs = await loadPassiveListenMs();
const { ok } = await audioService.startStreamingRecording({
@@ -2202,6 +2230,7 @@ const ChatScreen: React.FC = () => {
// ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
rememberMyRequest(audioRequestId);
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
+62 -2
View File
@@ -168,6 +168,9 @@ export interface Project {
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
desktop_url?: string;
// Automatisch: hat das Projekt Dateien in /shared/projects/<id>/? → Datei-Symbol.
has_files?: boolean;
file_count?: number;
}
export interface ProjectStatus {
@@ -176,6 +179,19 @@ export interface ProjectStatus {
projects: Project[];
}
/** QEMU-VM eines Projekts (Registry + Live-Status). */
export interface ProjectVm {
name: string;
arch: string;
iso?: string;
vnc_display: number;
mem: number;
running?: boolean;
vnc_port?: number;
boot_cmd?: string;
created_at?: number;
}
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
* Key "__main__" = Hauptchat, sonst project_id. */
export interface QueueContextStatus {
@@ -600,14 +616,58 @@ export const brainApi = {
});
},
/** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { kind },
});
},
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
},
/** Inhalt einer Projekt-Datei laden (Text). */
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
},
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
},
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
},
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
},
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
},
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
},
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
},
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
+4 -2
View File
@@ -15,8 +15,9 @@ import { TileId } from './layout';
import { useWorkspaceLayout } from './useWorkspaceLayout';
import WorkspaceDock from './WorkspaceDock';
import ChatTile from './tiles/ChatTile';
import FilesTile from './tiles/FilesTile';
import CodeEditorTile from './tiles/CodeEditorTile';
import VncTile from './tiles/VncTile';
import DesktopTile from './tiles/DesktopTile';
interface Props {
projectId: string;
@@ -57,8 +58,9 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
const render = (id: TileId) => {
switch (id) {
case 'chat': return <ChatTile />;
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
default: return null;
}
};
+17 -10
View File
@@ -5,19 +5,19 @@
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
*
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
* Desktop gruen, wenn eine VM verbunden ist.
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
*/
import React, { useEffect, useMemo, useState } from 'react';
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
import codeFile from '../services/codeFile';
import desktop from '../services/desktop';
import brainApi from '../services/brainApi';
import viewMode, { ViewModeValue } from '../services/viewMode';
import ChatScreen from '../screens/ChatScreen';
import { TileId } from './layout';
import WorkspaceDeck from './WorkspaceDeck';
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
const WorkspaceScreen: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
@@ -29,7 +29,6 @@ const WorkspaceScreen: React.FC = () => {
useEffect(() => projectFocus.subscribe(setFocus), []);
const pid = focus.focusedProjectId;
const kind = projectFocus.getProjectKind(pid);
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
useEffect(() => {
@@ -39,13 +38,21 @@ const WorkspaceScreen: React.FC = () => {
});
}, [pid]);
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
useEffect(() => {
setHasDesktop(desktop.getStatus().available);
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
if (kind === 'code') desktop.requestCheck(pid);
return unsub;
}, [pid, kind]);
if (!pid) { setHasDesktop(false); return; }
let alive = true;
const check = () => {
brainApi.listProjectVms(pid)
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
.catch(() => { if (alive) setHasDesktop(false); });
};
check();
const t = setInterval(check, 6000);
return () => { alive = false; clearInterval(t); };
}, [pid]);
const badges = useMemo(() => ({
editor: hasCode ? '#0096FF' : undefined,
+26 -17
View File
@@ -77,14 +77,13 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
var msg=document.getElementById('msg');
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
var kbd=document.createElement('input');
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
document.body.appendChild(kbd);
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
// cp<0x100 → Keysym == Codepoint (Latin-1)
// sonst → X11-Unicode-Keysym 0x01000000+cp
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
var RFB = mod.default;
@@ -99,19 +98,29 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
});
window.__rfb = rfb;
// Tasten aus dem versteckten Feld an die VM schicken.
// Down+Up einer Taste an die VM schicken.
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
kbd.addEventListener('keydown', function(e){
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
});
kbd.addEventListener('input', function(){
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
});
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
// per injectJavaScript.
// char(cp) druckbares Zeichen (Codepoint)
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
window.ariaVncKey = {
char: function(cp){ tap(cpToKeysym(cp)); },
keysym: function(ks){ tap(ks); },
combo: function(mods, ks){
try{
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
}catch(_){}
}
};
// Steuerungs-API fuer die App (per injectJavaScript).
window.ariaVncCtl = {
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
};
+2 -1
View File
@@ -2,12 +2,13 @@
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
*/
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
export interface TileDef { id: TileId; title: string; icon: string }
export const TILE_META: Record<TileId, TileDef> = {
chat: { id: 'chat', title: 'Chat', icon: '💬' },
files: { id: 'files', title: 'Dateien', icon: '📁' },
editor: { id: 'editor', title: 'Code', icon: '📝' },
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
+65 -25
View File
@@ -1,57 +1,97 @@
/**
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
*
* Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel)
* und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit
* zurueck an die Bridge. Datei-Tabs oben zum Umschalten.
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
* ARIA schon Dateien geschrieben hat.
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import codeFile, { CodeFileState } from '../../services/codeFile';
import codeFile from '../../services/codeFile';
import brainApi from '../../services/brainApi';
import { EDITOR_HTML } from '../assets/editorHtml';
interface Props {
projectId: string;
}
function guessLang(path: string): string {
const ext = (path.split('.').pop() || '').toLowerCase();
const map: Record<string, string> = {
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
};
return map[ext] || 'text';
}
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
const webRef = useRef<WebView>(null);
const [files, setFiles] = useState<CodeFileState[]>(() => codeFile.getFiles(projectId));
const [currentPath, setCurrentPath] = useState<string | null>(files[0]?.path ?? null);
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
const [serverPaths, setServerPaths] = useState<string[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [loadErr, setLoadErr] = useState<string>('');
const readyRef = useRef(false);
const currentPathRef = useRef<string | null>(currentPath);
currentPathRef.current = currentPath;
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
const files = useMemo(() => {
const set = new Set<string>(serverPaths);
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [serverPaths, projectId]);
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
webRef.current?.injectJavaScript(js);
}, []);
const loadFileIntoEditor = useCallback((path: string | null) => {
const loadFileIntoEditor = useCallback(async (path: string | null) => {
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
const f = codeFile.getFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 });
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
const live = codeFile.getFile(projectId, path);
if (live) {
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
return;
}
try {
const res = await brainApi.readProjectFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
} catch (e: any) {
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
}
}, [projectId, sendToWeb]);
// Projektwechsel: Dateiliste + Auswahl neu.
// Projektwechsel: vorhandene Dateien vom Brain laden.
useEffect(() => {
const list = codeFile.getFiles(projectId);
setFiles(list);
setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null));
let cancelled = false;
setLoadErr('');
brainApi.listProjectFiles(projectId)
.then(res => {
if (cancelled) return;
const paths = (res.files || []).map(f => f.path);
setServerPaths(paths);
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
})
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
return () => { cancelled = true; };
}, [projectId]);
// Eingehende Updates aus dem Spiegel.
// Live-Updates aus dem Spiegel.
useEffect(() => {
return codeFile.subscribe((u) => {
if ((u.projectId || '') !== (projectId || '')) return;
setFiles(codeFile.getFiles(projectId));
// Noch keine Datei gewaehlt → diese oeffnen.
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
if (u.path !== currentPathRef.current) return;
if (!readyRef.current) return;
if (u.path !== currentPathRef.current || !readyRef.current) return;
if (u.patch) {
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
} else {
@@ -60,7 +100,7 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
});
}, [projectId, sendToWeb]);
// Datei-Auswahl gewechselt → in den Editor laden (falls WebView bereit).
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
useEffect(() => {
if (readyRef.current) loadFileIntoEditor(currentPath);
}, [currentPath, loadFileIntoEditor]);
@@ -82,14 +122,14 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
<View style={styles.container}>
<View style={styles.tabsRow}>
{files.length === 0 ? (
<Text style={styles.noFiles}>Noch keine Datei</Text>
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{files.map((f) => {
const active = f.path === currentPath;
const name = f.path.split('/').pop() || f.path;
{files.map((path) => {
const active = path === currentPath;
const name = path.split('/').pop() || path;
return (
<TouchableOpacity key={f.path} onPress={() => setCurrentPath(f.path)} style={[styles.tab, active && styles.tabActive]}>
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
</TouchableOpacity>
);
+192
View File
@@ -0,0 +1,192 @@
/**
* DesktopTile — das Desktop-Panel eines Code-Projekts.
*
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi, { ProjectVm } from '../../services/brainApi';
import VncTile from './VncTile';
interface Props {
projectId: string;
focused: boolean;
}
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
const [vms, setVms] = useState<ProjectVm[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
const [connected, setConnected] = useState<ProjectVm | null>(null);
const [shotBusy, setShotBusy] = useState('');
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
const load = useCallback(() => {
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectVms(projectId)
.then(r => setVms(r.vms || []))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => {
if (focused && !connected) load();
}, [focused, projectId, connected, load]);
const boot = useCallback((vm: ProjectVm) => {
setBusy(vm.name);
brainApi.bootProjectVm(projectId, vm.name)
.then(() => load())
.catch(e => setErr(String(e?.message || e)))
.finally(() => setBusy(''));
}, [projectId, load]);
const stop = useCallback((vm: ProjectVm) => {
setBusy(vm.name);
brainApi.stopProjectVm(projectId, vm.name)
.then(() => load())
.catch(e => setErr(String(e?.message || e)))
.finally(() => setBusy(''));
}, [projectId, load]);
const screenshot = useCallback((vm: ProjectVm) => {
setShotBusy(vm.name); setErr('');
brainApi.screenshotProjectVm(projectId, vm.name)
.then(r => setShot({ name: vm.name, b64: r.base64 }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setShotBusy(''));
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}></Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 12 }}>
{loading && vms.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : !projectId ? (
<Text style={styles.empty}>Kein aktives Projekt wechsle in ein Projekt für dessen VMs.</Text>
) : vms.length === 0 ? (
<Text style={styles.empty}>
Noch keine VM in diesem Projekt.{'\n'}
Sag ARIA z.B. bau eine QEMU-VM zum Testen" — sie registriert sie hier,
dann kannst du sie starten und verbinden.
</Text>
) : (
vms.map(vm => {
const isBusy = busy === vm.name;
return (
<View key={vm.name} style={styles.vmRow}>
<View style={{ flex: 1 }}>
<Text style={styles.vmName}>
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
<Text style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
</Text>
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
</View>
<View style={styles.vmBtns}>
{isBusy ? (
<ActivityIndicator color="#0096FF" />
) : vm.running ? (
<>
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
{shotBusy === vm.name
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
</TouchableOpacity>
)}
</View>
</View>
);
})
)}
</ScrollView>
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
{shot && (
<Image
source={{ uri: `data:image/png;base64,${shot.b64}` }}
style={styles.shotImg}
resizeMode="contain"
/>
)}
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
</TouchableOpacity>
</Modal>
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
{connected && (
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
<View style={styles.fs}>
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
<Text style={styles.fsBackText}> VMs</Text>
</TouchableOpacity>
</View>
</Modal>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 64, marginBottom: 16 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
vmBtnText: { fontSize: 12, fontWeight: '700' },
fs: { flex: 1, backgroundColor: '#000000' },
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
});
export default DesktopTile;
+149
View File
@@ -0,0 +1,149 @@
/**
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
*
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi from '../../services/brainApi';
interface Props {
projectId: string;
focused: boolean;
}
interface FileEntry { path: string; size: number }
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
function iconFor(path: string): string {
const e = ext(path);
if (isImage(path)) return '🖼️';
if (['md', 'txt', 'readme'].includes(e)) return '📄';
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
return '📄';
}
function humanSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
const [previewBusy, setPreviewBusy] = useState('');
const load = useCallback(() => {
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectFiles(projectId)
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
const open = useCallback((f: FileEntry) => {
setPreviewBusy(f.path); setErr('');
if (isImage(f.path)) {
brainApi.readProjectFileBinary(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
} else {
brainApi.readProjectFile(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
}
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>📁</Text>
<Text style={styles.text}>Dateien</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}></Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 8 }}>
{loading && files.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : files.length === 0 ? (
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
) : (
files.map(f => (
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
{previewBusy === f.path
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
</TouchableOpacity>
))
)}
</ScrollView>
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
<View style={styles.pvOverlay}>
<View style={styles.pvBar}>
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}></Text></TouchableOpacity>
</View>
{preview?.kind === 'image' ? (
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
) : (
<ScrollView style={styles.pvTextWrap} horizontal>
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
</ScrollView>
)}
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 56, marginBottom: 10 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
rowIcon: { fontSize: 18 },
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
rowSize: { color: '#555570', fontSize: 11 },
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
pvImg: { flex: 1, width: '100%' },
pvTextWrap: { flex: 1, padding: 12 },
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
});
export default FilesTile;
+167 -12
View File
@@ -2,13 +2,16 @@
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
*
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
* dem Handy voll bedienbar:
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1F12 und
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import desktop from '../../services/desktop';
import { NOVNC_HTML } from '../assets/novncHtml';
@@ -16,11 +19,33 @@ import { NOVNC_HTML } from '../assets/novncHtml';
interface Props {
projectId: string;
focused: boolean;
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
}
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
const NAV_KEYS: { label: string; ks: number }[] = [
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
];
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
const webRef = useRef<WebView>(null);
const kbdRef = useRef<TextInput>(null);
const bufRef = useRef(''); // Spiegel des TextInput-Textes
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
const [kbdOn, setKbdOn] = useState(false);
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
const unsubDataRef = useRef<null | (() => void)>(null);
const teardown = useCallback(() => {
@@ -33,10 +58,87 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
return () => teardown();
}, [focused, teardown]);
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
useEffect(() => {
if (!focused) return;
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
return () => { show.remove(); hide.remove(); };
}, [focused]);
const ctl = useCallback((fn: string) => {
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
}, []);
const sendKeysym = useCallback((ks: number) => {
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
}, []);
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
}, []);
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
const activeMods = useCallback(() => {
const m = modRef.current; const a: number[] = [];
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
return a;
}, []);
const clearMods = useCallback(() => {
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
modRef.current = { ctrl: false, alt: false, shift: false };
setMods(modRef.current);
}
}, []);
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
setMods(modRef.current);
}, []);
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
const pressKey = useCallback((ks: number) => {
const m = activeMods();
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
}, [activeMods, sendCombo, clearMods, sendKeysym]);
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
const pressChar = useCallback((cp: number) => {
const m = activeMods();
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
}, [activeMods, sendCombo, clearMods]);
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
const toggleKbd = useCallback(() => {
if (kbdOn) { Keyboard.dismiss(); }
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
}, [kbdOn]);
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
const text = e.nativeEvent.text || '';
const prev = bufRef.current;
let i = 0;
const min = Math.min(prev.length, text.length);
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
bufRef.current = text;
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
}, [pressChar]);
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const k = e.nativeEvent.key;
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
else if (k === 'Enter') pressKey(KEYSYM.Enter);
}, [pressKey]);
const onMessage = useCallback((e: WebViewMessageEvent) => {
let m: any;
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
@@ -46,7 +148,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
webRef.current?.injectJavaScript(js);
});
desktop.openVnc(projectId);
desktop.openVnc(projectId, port);
} else if (m.event === 'vnc_send') {
desktop.sendInput(m.b64);
} else if (m.event === 'vnc_close') {
@@ -55,7 +157,7 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
if (m.state === 'connected') setStatus('connected');
else if (m.state === 'disconnected') setStatus('disconnected');
}
}, [projectId]);
}, [projectId, port]);
if (!focused) {
return (
@@ -83,14 +185,34 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
keyboardDisplayRequiresUserAction={false}
/>
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
<TextInput
ref={kbdRef}
style={styles.hiddenInput}
onChange={onKbdChange}
onKeyPress={onKbdKeyPress}
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
keyboardType="visible-password"
returnKeyType="send"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
blurOnSubmit={false}
caretHidden
contextMenuHidden
multiline={false}
/>
{/* Steuerungs-Leiste — nur wenn verbunden */}
{connected && (
<View style={styles.ctlBar}>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
<Text style={styles.ctlText}>Fn</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
@@ -98,6 +220,26 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
</View>
)}
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
{connected && keyBar && (
<View style={styles.keyBar} pointerEvents="box-none">
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
{NAV_KEYS.map(k => (
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
))}
</ScrollView>
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
{F_KEYS.map(k => (
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
))}
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
</ScrollView>
</View>
)}
{!connected && (
<View style={styles.overlay} pointerEvents="none">
<Text style={styles.overlayText}>
@@ -118,7 +260,7 @@ const styles = StyleSheet.create({
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
ctlBar: {
position: 'absolute',
top: 8,
top: 34,
right: 8,
flexDirection: 'row',
gap: 6,
@@ -134,8 +276,21 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
key: {
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
alignItems: 'center', justifyContent: 'center',
},
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
});
+264 -23
View File
@@ -37,6 +37,7 @@ import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
import project_vms as project_vms_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
@@ -1045,12 +1046,15 @@ META_TOOLS = [
"function": {
"name": "project_summary",
"description": (
"Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt "
"passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per "
"Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es "
"IMMER wenn Stefan sagt 'hol dir die Infos aus Projekt X', 'schau mal in "
"den Hauptchat/in Projekt Y rein', 'was war zuletzt bei ...', 'hol mich "
"ab' — sonst halluzinierst Du Inhalte die nicht da sind."
"Liest die ECHTE gespeicherte Chat-Historie eines ANDEREN Projekts "
"(letzte ~20 Turns aus dem persistierten Verlauf — nicht nur Deinem "
"aktuellen Kontextfenster) und fasst sie zusammen. Funktioniert fuer "
"JEDES Projekt (per Name, Fuzzy-Match) UND fuer den Hauptchat "
"(name='Hauptchat'). Rufe es DIREKT auf, sobald Stefan sagt 'hol dir "
"die Infos aus Projekt X', 'schau mal in Projekt Y / den Hauptchat "
"rein', 'was war zuletzt bei ...', 'hol mich ab' — antworte NICHT aus "
"dem Gedaechtnis, sonst halluzinierst Du. Der Verlauf des Projekts steht "
"sicher zur Verfuegung, auch wenn Ihr da lange nicht wart."
),
"parameters": {
"type": "object",
@@ -1102,6 +1106,99 @@ 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"],
},
},
},
{
"type": "function",
"function": {
"name": "vm_register",
"description": (
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
"eine VM gebaut/gebootet hast — mit den Medien, die zum Task passen: "
"disk (Festplatte, z.B. DOS-Spiele), floppy (Diskette, OS-Dev), iso "
"(Installer). Leer lassen = aria-vm erkennt disk.qcow2/floppy.img/"
"cdrom.iso im VM-Ordner selbst. vnc_display → Port 5900+display "
"(mehrere VMs = verschiedene Displays)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips (Architektur zum Task waehlen)."},
"disk": {"type": "string", "description": "optional: qcow2-Pfad (Festplatte)."},
"floppy": {"type": "string", "description": "optional: Disketten-Image-Pfad (-fda)."},
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad."},
"vnc_display": {"type": "integer", "description": "meist WEGLASSEN — das VNC-Display wird global eindeutig auto-vergeben (kein Port-Konflikt bei mehreren VMs). Nur setzen wenn Du ein bestimmtes willst."},
"mem": {"type": "integer", "description": "RAM in MB (Default 1024)."},
},
"required": ["name", "arch"],
},
},
},
{
"type": "function",
"function": {
"name": "vm_list",
"description": "Listet die registrierten QEMU-VMs des aktuellen Projekts (mit Lauf-Status).",
"parameters": {"type": "object", "properties": {}},
},
},
]
@@ -1515,7 +1612,7 @@ class Agent:
targs = {}
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs)
tresult = self._dispatch_tool(tname, targs, active_project_id)
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
@@ -1807,7 +1904,7 @@ class Agent:
))
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
tool_result = self._dispatch_tool(tc["name"], tc["arguments"], active_project_id)
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or ""
@@ -1895,10 +1992,116 @@ class Agent:
# ── Tool-Dispatcher ───────────────────────────────────────
def _dispatch_tool(self, name: str, arguments: dict) -> str:
def _read_project_history(self, project_id: str, limit: int = 20) -> list:
"""Liest die VOLLE Chat-Historie eines Projekts aus dem geteilten
chat_backup.jsonl (/shared/config, auch im Brain gemountet).
Noetig, weil das rollende Conversation-Window nur die letzten ~50 Turns
ueber ALLE Projekte haelt aeltere/andere Projekt-Chats sind da laengst
rausdistilliert. Gibt die letzten `limit` (role, text)-Paare zurueck,
gefiltert auf project_id ('' = Hauptthread). Fuehrende Hint-Bloecke
([Standort: ...]) in User-Turns werden entfernt."""
path = "/shared/config/chat_backup.jsonl"
out: list = []
try:
if not os.path.exists(path):
return []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
o = json.loads(line)
except Exception:
continue
role = o.get("role")
text = o.get("text")
if not role or text is None:
continue
# nur echte Gespraechs-Turns (keine skill/memory/system-Eintraege)
if role not in ("user", "assistant", "aria"):
continue
if (o.get("project_id") or "") != (project_id or ""):
continue
if role == "user":
text = _strip_leading_hint_blocks(text)
out.append((role, text))
except Exception as exc:
logger.warning("project_summary: chat_backup lesen fehlgeschlagen: %s", exc)
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
weitermachen kann."""
weitermachen kann. project_id = Projekt DIESES Requests (fuer Tools die
aufs aktuelle Projekt wirken, z.B. set_project_kind) NICHT der globale
active_project (geraeteuebergreifend)."""
try:
if name == "skill_create":
# ARIA-Skills sind immer Python — execution ist nicht mehr im Schema
@@ -2566,23 +2769,29 @@ class Agent:
# aus einem Projekt heraus in den Hauptthread reinschauen kann.
if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main",
"mainchat", "haupt-chat", "hauptchat-thread"}:
turns = [t for t in self.conversation.turns if not t.project_id]
target_pid = ""
label, desc = "Hauptchat", "der Hauptthread (kein Projekt)"
else:
p = projects_mod.find_project(pname)
if not p:
return f"Kein Chat/Projekt '{pname}' gefunden (fuer den Hauptthread: name='Hauptchat')."
turns = [t for t in self.conversation.turns if t.project_id == p["id"]]
target_pid = p["id"]
label, desc = p["name"], p.get("description", "(keine Beschreibung)")
# VOLLE Historie aus chat_backup.jsonl (nicht nur das rollende
# Window — sonst fehlen aeltere/andere Projekt-Chats komplett).
turns = self._read_project_history(target_pid, limit=20)
if not turns:
return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
f"Turns. {desc}")
tail = turns[-12:]
# Fallback aufs In-Memory-Window, falls chat_backup fehlt/leer.
turns = [(t.role, t.content) for t in self.conversation.turns
if (t.project_id or "") == target_pid]
if not turns:
return (f"'{label}' hat noch keine Chat-Historie. {desc}")
tail = turns[-20:]
summary_lines = []
for t in tail:
prefix = "Stefan" if t.role == "user" else "Du"
summary_lines.append(f"{prefix}: {t.content[:280]}")
preamble = f"'{label}'{desc}.\nLetzte {len(tail)} Turns:\n"
for role, text in tail:
prefix = "Stefan" if role == "user" else "Du"
summary_lines.append(f"{prefix}: {(text or '')[:280]}")
preamble = f"'{label}'{desc}.\nLetzte {len(tail)} Turns aus der Historie:\n"
return preamble + "\n".join(summary_lines)
if name == "project_end":
pname = (arguments.get("name") or "").strip()
@@ -2602,13 +2811,18 @@ class Agent:
kind = (arguments.get("kind") or "").strip().lower()
if kind not in ("code", "chat"):
return "FEHLER: kind muss 'code' oder 'chat' sein."
active_id = projects_mod.get_active()
# WICHTIG: das Projekt des AKTUELLEN Requests markieren, NICHT den
# globalen active_project — der ist geraeteuebergreifend und wuerde
# (bei mehreren App-/Diagnostic-Instanzen) das falsche Projekt
# treffen. project_id kommt pro Request vom Client.
active_id = (project_id or "").strip()
if not active_id:
return ("Kein aktives Projekt — Du bist im Hauptthread. Erst ein Projekt "
"anlegen/betreten (project_create/project_enter), dann set_project_kind.")
return ("Kein aktives Projekt — dieser Chat laeuft im Hauptthread. "
"set_project_kind wirkt nur, wenn der Request in einem Projekt "
"laeuft (der Client sendet die project_id mit).")
updated = projects_mod.update_project(active_id, {"kind": kind})
if not updated:
return f"FEHLER: aktives Projekt '{active_id}' nicht gefunden."
return f"FEHLER: Projekt '{active_id}' nicht gefunden."
self._pending_events.append({
"type": "project_changed",
"project": updated,
@@ -2619,6 +2833,33 @@ 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)
if name == "vm_register":
pid = (project_id or "").strip()
if not pid:
return "Kein aktives Projekt — VMs werden pro Projekt gefuehrt. Erst ein Projekt betreten."
try:
vm = project_vms_mod.add_vm(
pid, (arguments.get("name") or "").strip(),
(arguments.get("arch") or "i386").strip(),
(arguments.get("iso") or "").strip(),
(arguments.get("floppy") or "").strip(),
(arguments.get("disk") or "").strip(),
int(arguments.get("vnc_display") or 0),
int(arguments.get("mem") or 1024),
)
except (ValueError, TypeError) as exc:
return f"FEHLER: {exc}"
return (f"OK — VM '{vm['name']}' ({vm['arch']}, Display :{vm['vnc_display']}) "
f"in Projekt '{pid}' registriert. Erscheint in Stefans Desktop-Panel.")
if name == "vm_list":
pid = (project_id or "").strip()
vms = project_vms_mod.list_vms(pid)
if not vms:
return f"Projekt '{pid or 'Hauptchat'}' hat noch keine registrierten VMs."
return "VMs:\n" + "\n".join(
f"- {v['name']} ({v.get('arch')}, Display :{v.get('vnc_display', 1)})" for v in vms)
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+282 -2
View File
@@ -39,6 +39,7 @@ import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
import project_vms as project_vms_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -769,15 +770,49 @@ def projects_queue_status():
# ── Projekte ────────────────────────────────────────────────────────
def _project_file_count(pid: str) -> int:
"""Anzahl Dateien in /shared/projects/<pid>/ (rekursiv, gecappt). 0 = leer."""
base = os.path.join("/shared/projects", pid or "")
if not os.path.isdir(base):
return 0
cnt = 0
try:
for _dp, dns, fns in os.walk(base):
dns[:] = [d for d in dns if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")]
cnt += len(fns)
if cnt > 999:
return 999
except Exception:
return 0
return cnt
def _enrich_projects(projects: list) -> list:
"""Ergaenzt has_files + file_count pro Projekt (fuer das Datei-Symbol in der
Liste). Das ersetzt das manuelle Code-Flag als primaeren Code-Indikator."""
for p in projects or []:
if not isinstance(p, dict):
continue
c = _project_file_count(p.get("id") or "")
p["file_count"] = c
p["has_files"] = c > 0
return projects
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
return projects_mod.status()
st = projects_mod.status()
_enrich_projects(st.get("projects", []))
if st.get("active"):
_enrich_projects([st["active"]])
return st
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
return {"projects": _enrich_projects(
projects_mod.list_projects(include_archived=include_archived))}
class ProjectCreateBody(BaseModel):
@@ -827,17 +862,262 @@ class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
_PROJECT_FILES_ROOT = "/shared/projects"
_PROJECT_FILE_MAX = 512 * 1024
def _project_dir(project_id: str) -> str:
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
root = os.path.realpath(_PROJECT_FILES_ROOT)
if base != root and not base.startswith(root + os.sep):
raise HTTPException(status_code=400, detail="ungueltige project_id")
return base
@app.get("/projects/{project_id}/files")
def project_files(project_id: str):
base = _project_dir(project_id)
out = []
if os.path.isdir(base):
for dirpath, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in
(".git", "node_modules", "__pycache__", ".venv", "venv")]
for f in files:
full = os.path.join(dirpath, f)
rel = os.path.relpath(full, base).replace("\\", "/")
try:
sz = os.path.getsize(full)
except OSError:
sz = 0
out.append({"path": rel, "size": sz})
out.sort(key=lambda x: x["path"])
return {"projectId": project_id, "files": out}
@app.get("/projects/{project_id}/file")
def project_file(project_id: str, path: str, binary: bool = False):
base = _project_dir(project_id)
target = os.path.realpath(os.path.join(base, path))
if target != base and not target.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
if binary:
import base64
import mimetypes
if os.path.getsize(target) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
with open(target, "rb") as f:
data = f.read()
mime, _ = mimetypes.guess_type(target)
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
"base64": base64.b64encode(data).decode("ascii")}
if os.path.getsize(target) > _PROJECT_FILE_MAX:
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"projectId": project_id, "path": path, "content": content}
# ── QEMU-VMs pro Projekt ────────────────────────────────────────────
# Registry (project_vms) + echter Start/Stop via `aria-vm` auf dem Host (SSH
# aria-wohnung). Das Desktop-Panel der App zeigt pro Projekt die Liste.
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
def _docker_gateway() -> str:
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
try:
import socket as _sock
import struct as _struct
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
except Exception:
pass
return ""
def _ssh_host(*cmd: str, timeout: int = 25):
import subprocess
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
_ARIA_VM_HOST, *[str(c) for c in cmd]]
try:
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc:
return 1, "", str(exc)
def _ssh_aria_vm(*args: str, timeout: int = 25):
return _ssh_host("aria-vm", *args, timeout=timeout)
def _vm_running_names() -> set:
rc, out, _err = _ssh_aria_vm("list", timeout=15)
names = set()
if rc == 0:
for line in out.splitlines():
parts = line.split()
if parts and "laeuft" in line:
names.add(parts[0])
return names
class VmAddBody(BaseModel):
name: str
arch: str = "i386"
iso: str = ""
floppy: str = ""
disk: str = ""
vnc_display: int = 1
mem: int = 1024
create_disk: bool = False
size: str = "10G"
def _vm_boot_args(v: dict) -> list:
args = ["boot", v.get("name", "?"),
"--vnc-display", str(v.get("vnc_display", 1)),
"--mem", str(v.get("mem", 1024))]
if v.get("disk"):
args += ["--disk", v["disk"]]
if v.get("floppy"):
args += ["--floppy", v["floppy"]]
if v.get("iso"):
args += ["--iso", v["iso"]]
return args
def _vm_boot_cmd(v: dict) -> str:
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
return "aria-vm " + " ".join(_vm_boot_args(v))
@app.get("/projects/{project_id}/vms")
def project_vms_list(project_id: str):
vms = [dict(v) for v in project_vms_mod.list_vms(project_id)]
running = _vm_running_names()
for v in vms:
v["running"] = v.get("name") in running
v["vnc_port"] = 5900 + int(v.get("vnc_display", 1))
v["boot_cmd"] = _vm_boot_cmd(v)
return {"projectId": project_id, "vms": vms}
@app.post("/projects/{project_id}/vms")
def project_vm_add(project_id: str, body: VmAddBody):
try:
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
body.floppy, body.disk, body.vnc_display, body.mem)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if body.create_disk:
rc, out, err = _ssh_aria_vm("create", body.name, body.arch, body.size)
vm["create_result"] = out.strip() or err.strip()
vm["create_ok"] = (rc == 0)
return vm
@app.delete("/projects/{project_id}/vms/{name}")
def project_vm_remove(project_id: str, name: str, purge: bool = False):
ok = project_vms_mod.remove_vm(project_id, name)
if not ok:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht in Projekt {project_id}")
if purge:
_ssh_aria_vm("rm", name)
return {"ok": True, "name": name}
@app.post("/projects/{project_id}/vms/{name}/boot")
def project_vm_boot(project_id: str, name: str):
vm = project_vms_mod.get_vm(project_id, name)
if not vm:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
boot_args = _vm_boot_args(vm)
gw = _docker_gateway()
if gw:
boot_args += ["--vnc-bind", gw]
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/stop")
def project_vm_stop(project_id: str, name: str):
rc, out, err = _ssh_aria_vm("stop", name, timeout=25)
return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/screenshot")
def project_vm_screenshot(project_id: str, name: str):
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend nicht
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
die Datei danach per SSH (base64) funktioniert unabhaengig von Volume-
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
import base64
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
if rc != 0:
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
path = ""
for line in out.splitlines():
if line.startswith("screenshot="):
path = line.split("=", 1)[1].strip()
if not path:
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
if rc2 != 0 or not b64.strip():
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
b64 = b64.strip()
try:
data = base64.b64decode(b64)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
fname = os.path.basename(path)
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
proj_rel = ""
try:
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
os.makedirs(shots_dir, exist_ok=True)
with open(os.path.join(shots_dir, fname), "wb") as f:
f.write(data)
proj_rel = "screenshots/" + fname
except Exception:
proj_rel = ""
return {"ok": True, "name": name, "filename": fname,
"projectPath": proj_rel, "base64": b64}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
+118
View File
@@ -0,0 +1,118 @@
"""
project_vms Registry der QEMU-VMs PRO PROJEKT.
Persistenz: /shared/config/project_vms.json { project_id: [ {vm}, ... ] }.
Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte
Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py);
diese Datei haelt nur die Zuordnung VM Projekt + die Startparameter, damit die
Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer).
"""
from __future__ import annotations
import json
import os
import re
import time
from pathlib import Path
from typing import Optional
VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json"))
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$")
VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64",
"mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"}
def _load() -> dict:
if not VMS_FILE.exists():
return {}
try:
data = json.loads(VMS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save(data: dict) -> None:
VMS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = VMS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(VMS_FILE)
def list_vms(project_id: str) -> list[dict]:
return _load().get(project_id or "", [])
def get_vm(project_id: str, name: str) -> Optional[dict]:
for v in list_vms(project_id):
if v.get("name") == name:
return v
return None
def _used_displays(data: dict, exclude: object = None) -> set:
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
VM-Dict-Instanz, die ignoriert wird fuer Updates)."""
used = set()
for lst in data.values():
for v in lst:
if v is exclude:
continue
try:
used.add(int(v.get("vnc_display", 1)))
except (TypeError, ValueError):
pass
return used
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
floppy: str = "", disk: str = "",
vnc_display: int = 0, mem: int = 1024) -> dict:
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
ein bereits belegtes Display automatisch das naechste freie."""
if not NAME_RE.match(name or ""):
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
if arch not in VALID_ARCH:
raise ValueError(f"Unbekannte Architektur: {arch!r}")
data = _load()
lst = data.setdefault(project_id or "", [])
now = int(time.time())
existing = next((v for v in lst if v.get("name") == name), None)
used = _used_displays(data, exclude=existing)
req = int(vnc_display or 0)
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
req = int(existing.get("vnc_display", 0) or 0)
if req <= 0 or req in used: # frei/eindeutig machen
req = 1
while req in used:
req += 1
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
"vnc_display": req, "mem": int(mem), "updated_at": now}
if existing:
existing.update(fields)
_save(data)
return existing
vm = {"name": name, "created_at": now, **fields}
lst.append(vm)
_save(data)
return vm
def remove_vm(project_id: str, name: str) -> bool:
data = _load()
lst = data.get(project_id or "")
if not lst:
return False
new = [v for v in lst if v.get("name") != name]
if len(new) == len(lst):
return False
data[project_id or ""] = new
_save(data)
return True
+83 -25
View File
@@ -406,35 +406,93 @@ SEED_RULES: List[dict] = [
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
"category": "architektur",
"content": (
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
"(Du schreibst Code, baust ein System, testest eine VM):\n"
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
"\n"
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
"die Dateipraesenz ist der eigentliche Indikator.)\n"
"\n"
"QEMU (VMs fuer JEDE Architektur x86, ARM, MIPS, PPC, RISC-V, SPARC) "
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
"fuer Win 3.11, aarch64, mips ...).\n"
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
"5901). Nicht selbst nach aussen oeffnen!\n"
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
"Stefan mit [FILE:] schicken).\n"
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
" bootet von Diskette/ISO).\n"
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
" --iso <pfad> von CD/ISO booten\n"
" --floppy <pfad> von Diskette booten (-fda, klassisch OS-Dev)\n"
" --disk <pfad> explizite qcow2\n"
" --vnc-display <N> VNC-Display (Default 1 → Port 5901; mehrere VMs = "
"verschiedene N)\n"
" --mem <MB> RAM (Default 1024)\n"
" Medien im VM-Ordner (disk.qcow2/floppy.img/cdrom.iso) werden auto-"
"erkannt. Es MUSS mindestens ein Boot-Medium da sein.\n"
" - `aria-vm screenshot <name>` → PNG (an Stefan per [FILE:] schickbar).\n"
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
"\n"
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
"wie gewohnt einfach machen, nicht moralisieren."
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
"Port-Konflikt, wenn mehrere VMs laufen). ERST DANN erscheint die VM in "
"Stefans Desktop-Panel (Cockpit), "
"wo er sie Starten/Stoppen/Verbinden kann. Ohne vm_register bleibt seine "
"Liste leer, obwohl die VM laeuft. Der Startbefehl steht als Wert dahinter.\n"
"\n"
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
"was gebraucht wird:\n"
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
"Groesse, dann installieren; disk).\n"
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
" - Fertiges System installieren/testen → ISO (--iso Installer).\n"
" - Architektur zum Task waehlen: 'teste mal was auf ARM' → arch=aarch64 "
"(-M virt, laeuft emuliert = langsam, das ist ok). x86-Zeug → i386/x86_64.\n"
"AENDERUNGS-ZYKLUS: willst Du an einer laufenden VM etwas aendern (Datei "
"tauschen, Diskette neu bauen), dann `aria-vm stop <name>` → Aenderung → "
"`aria-vm boot <name>` wieder starten. Stefan kann das auch selbst ueber "
"Start/Stop im Panel.\n"
"\n"
"Der VNC-Stream laeuft AUTOMATISCH komplett durch den RVS-Server (die Bridge "
"tunnelt die RFB-Bytes; der VNC-Port ist nur intern auf dem Host). Firewall-"
"unabhaengig — Du oeffnest NIE einen Port nach aussen. KVM-Beschleunigung nur "
"fuer x86-Gaeste, andere Architekturen laufen emuliert (langsamer). Medien-"
"Downloads/Baubares fuer Stefans Privatgebrauch: 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."
),
},
{
+133 -3
View File
@@ -43,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
def _docker_gateway() -> str:
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet im
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
zeigt und daher die VM nicht trifft."""
try:
import socket as _sock
import struct as _struct
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
except Exception:
pass
return ""
# ── Logging ──────────────────────────────────────────────────
logging.basicConfig(
@@ -738,7 +757,15 @@ class ARIABridge:
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
# 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")
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
or _docker_gateway() or "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] = {}
@@ -2007,10 +2034,15 @@ class ARIABridge:
elif etype == "project_changed":
# ARIA hat ein Projekt erstellt / betreten / verlassen / beendet.
# App + Diagnostic refreshen ihren Projekt-Banner anhand des Events.
# clientMsgId der ausloesenden Anfrage mitgeben (Text-cmid oder
# Voice-audioRequestId) → das ausloesende Geraet kann einen
# geraete-lokalen Projektwechsel machen ("geh in Projekt X"),
# ohne andere App-/Diagnostic-Instanzen mitzuziehen.
await self._send_to_rvs({
"type": "project_changed",
"payload": {
"action": event.get("action") or "",
"clientMsgId": client_msg_id or "",
**(event.get("project") or {}),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
@@ -3406,6 +3438,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
@@ -3618,8 +3676,11 @@ class ARIABridge:
)
if meta_action in ("back_to_main", "project_prefix"):
# UI-Focus-Update broadcasten
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
# UI-Focus-Update broadcasten — clientMsgId (= audio_request_id der
# Aufnahme) mitgeben, damit NUR das Geraet folgt, das den Sprach-
# befehl gab (geraete-lokaler Fokus, keine anderen Instanzen ziehen).
payload = {"action": "entered" if meta_action == "project_prefix" else "exited",
"clientMsgId": audio_request_id or ""}
if meta_action == "project_prefix" and project_id:
# Namen aus dem Cache holen — best effort
try:
@@ -4274,6 +4335,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),
@@ -4493,6 +4575,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.
+115
View File
@@ -229,6 +229,7 @@
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
<button class="main-nav-btn" onclick="switchMainTab('satellites')">Satelliten</button>
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
</div>
@@ -1271,6 +1272,27 @@
</div>
</div><!-- /tab-triggers -->
<!-- ══════ TAB: Satelliten ══════ -->
<div id="tab-satellites" class="main-tab">
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">Satelliten 🛰️</h2>
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
</div>
<div class="card" style="margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;">
Aussenposten-Container in fremden Netzen (Buero, Werkstatt …), die sich mit
RVS verbinden. Hier siehst Du, welche Satelliten online sind und welche
Geraete sie im jeweiligen Netz erkannt haben. „Geraete scannen" fragt den
Satelliten live ab.
</p>
</div>
<div class="card">
<div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
</div>
</div>
</div><!-- /tab-satellites -->
<!-- Trigger-Create/Edit Modal -->
<div class="modal-overlay" id="trigger-modal">
<div class="modal-box" style="max-width:600px;">
@@ -1862,6 +1884,14 @@
return;
}
if (msg.type === 'sat_update') { satellites = msg.satellites || []; renderSatellites(); return; }
if (msg.type === 'sat_devices') {
if (msg.satellite) { satDevices[msg.satellite] = { devices: msg.devices || [], location: msg.location, ts: Date.now() }; }
satScanning = null;
renderSatellites();
return;
}
if (msg.type === 'voice_id_delete_response') {
const p = msg.payload || msg;
if (p.removed) {
@@ -3162,14 +3192,17 @@
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
${p.has_files ? `<span title="${p.file_count || 0} Dateien in /shared/projects/${escapeHtmlAttr(p.id)}/" style="font-size:11px;margin-left:4px;">📄${p.file_count ? ' ' + p.file_count : ''}</span>` : ''}
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${p.kind === 'code' ? '<span style="color:#0096FF;font-size:10px;font-weight:700;margin-left:6px;background:rgba(0,150,255,0.15);padding:2px 6px;border-radius:3px;">&lt;/&gt; Code</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn secondary" onclick="setProjectKind('${p.id}', '${p.kind === 'code' ? 'chat' : 'code'}')" style="padding:3px 8px;font-size:10px;color:${p.kind === 'code' ? '#0096FF' : '#8888AA'};" title="${p.kind === 'code' ? 'Ist Code-Projekt — klick fuer normalen Chat' : 'Als Code-Projekt markieren (Editor + Desktop im Cockpit)'}">&lt;/&gt;</button>
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
@@ -3216,6 +3249,18 @@
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
}
async function setProjectKind(id, kind) {
try {
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
loadProjects();
} catch (e) { alert('Code-Markierung fehlgeschlagen: ' + e.message); }
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {
@@ -4045,9 +4090,79 @@
loadSkills();
} else if (tab === 'triggers') {
loadTriggers();
} else if (tab === 'satellites') {
requestSatellites();
}
}
// ── Satelliten-Ansicht ─────────────────────────────────
let satellites = []; // [{id, location, caps, control, online}]
let satDevices = {}; // id → {devices, location, ts}
let satScanning = null; // id des gerade scannenden Satelliten
function requestSatellites() {
send({ action: 'sat_list' });
}
function scanSatellite(id) {
satScanning = id;
renderSatellites();
send({ action: 'sat_discover', satellite: id });
}
function renderSatellites() {
const box = document.getElementById('sat-list');
if (!box) return;
if (!satellites.length) {
box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' +
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
return;
}
box.innerHTML = satellites.map(s => {
const online = s.online !== false;
const dot = online ? '#34C759' : '#FF3B30';
const caps = (s.caps || []).join(', ') || 'nur beobachten';
const ctrl = s.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#8888AA;">read-only</span>';
const dev = satDevices[s.id];
const scanning = satScanning === s.id;
let devHtml;
if (scanning) {
devHtml = '<div style="color:#FFD60A;margin-top:6px;">Scanne Netz …</div>';
} else if (dev) {
if (!dev.devices.length) {
devHtml = '<div style="color:#8888AA;margin-top:6px;">Keine Geraete gefunden.</div>';
} else {
devHtml = '<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px;">' +
dev.devices.map(d => {
const tags = [];
if (d.model) tags.push(escapeHtml(d.model));
if (d.dialAppUrl) tags.push('DIAL');
if (d.mac) tags.push(escapeHtml(d.mac));
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;">' +
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>';
}).join('') + '</div>';
}
} else {
devHtml = '<div style="color:#8888AA;margin-top:6px;">Noch nicht gescannt.</div>';
}
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">' + escapeHtml(s.location || s.id) + '</strong> ' +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
'<button class="btn secondary" onclick="scanSatellite(\'' + escapeHtml(s.id) + '\')" style="padding:3px 8px;font-size:11px;"' +
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
devHtml +
'</div>';
}).join('');
}
// ── Triggers-Verwaltung ────────────────────────────────
let triggersCache = [];
+42
View File
@@ -475,6 +475,21 @@ function broadcastState() {
broadcast({ type: "state", state });
}
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
function satelliteList() {
const now = Date.now();
return Array.from(satellites.values()).map(s => ({
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
online: (now - (s.last_seen || 0)) < 300000,
}));
}
function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
// ── OpenClaw Gateway Verbindung ─────────────────────────
async function connectGateway() {
@@ -903,6 +918,23 @@ function connectRVS(forcePlain) {
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
broadcast({ type: "project_changed", payload: msg.payload || {} });
} else if (msg.type === "sat_hello") {
// Ein Satellit (Aussenposten in einem fremden Netz) meldet sich.
const p = msg.payload || {};
if (p.id) {
satellites.set(p.id, {
id: p.id, location: p.location || p.id,
caps: p.caps || [], control: !!p.control, net: p.net || null,
last_seen: Date.now(),
});
broadcastSatellites();
}
} else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
broadcast({ type: "sat_devices", satellite: p.satellite || "",
location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "agent_activity") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -2407,6 +2439,8 @@ wss.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) }));
// Letzten Disk-Status mitgeben damit der Client sofort weiss wie's um Platz steht
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
ws.on("message", (raw) => {
try {
@@ -2425,6 +2459,14 @@ wss.on("connection", (ws) => {
connectGateway();
} else if (msg.action === "reconnect_rvs") {
connectRVS();
} else if (msg.action === "sat_list") {
// Browser will die aktuelle Satelliten-Liste.
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
} else if (msg.action === "sat_discover") {
// Browser triggert einen Geraete-Scan auf einem Satelliten.
sendToRVS_raw({ type: "sat_discover",
payload: { satellite: msg.satellite || "", force: true },
timestamp: Date.now() });
} else if (msg.action === "test_proxy") {
testProxy(msg.text);
} else if (msg.action === "check_proxy_auth") {
+2
View File
@@ -120,6 +120,8 @@ services:
- brain
networks:
- aria-net
extra_hosts:
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
ports:
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
volumes:
+47 -13
View File
@@ -59,38 +59,62 @@ cmd_create() {
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
local d; d="$(vm_dir "${name}")"
[[ -e "${d}/disk.qcow2" ]] && die "VM '${name}' existiert schon"
[[ -f "${d}/arch" ]] && die "VM '${name}' existiert schon"
mkdir -p "${d}"
echo "${arch}" > "${d}/arch"
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
echo "VM '${name}' angelegt (${arch}, ${size})."
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
# z.B. OS-Entwicklung von Diskette). Sonst eine qcow2-Disk anlegen.
if [[ "${size}" == "none" || "${size}" == "0" ]]; then
echo "VM '${name}' angelegt (${arch}, ohne Disk — bootet von ISO/Diskette)."
else
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
echo "VM '${name}' angelegt (${arch}, ${size})."
fi
}
cmd_boot() {
local name="${1:?name}"; shift || true
local d; d="$(vm_dir "${name}")"
[[ -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
[[ -f "${d}/arch" || -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
vm_running "${name}" && die "VM '${name}' laeuft bereits"
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
local bin; bin="$(qemu_bin_for "${arch}")"
local iso="" bootdev="c" display=1 mem=1024 machine=""
local iso="" floppy="" disk="" bootdev="" display=1 mem=1024 machine=""
# VNC bindet an 127.0.0.1 (Loopback) — von aussen nur ueber den RVS-Tunnel der
# Bridge erreichbar. Die Bridge (Container) kann Loopback aber NICHT erreichen;
# der Brain gibt deshalb per --vnc-bind die Docker-Gateway-IP mit (container-
# intern, NICHT im LAN/Internet). Default bleibt Loopback.
local vncbind="${ARIA_VM_VNC_BIND:-127.0.0.1}"
while [[ $# -gt 0 ]]; do
case "$1" in
--iso) iso="${2:?}"; bootdev="d"; shift 2 ;;
--iso) iso="${2:?}"; shift 2 ;;
--floppy) floppy="${2:?}"; shift 2 ;; # -fda (Disketten-Boot, OS-Dev)
--disk) disk="${2:?}"; shift 2 ;; # explizite qcow2 statt Auto
--boot) bootdev="${2:?}"; shift 2 ;; # Boot-Reihenfolge (a/c/d)
--disk-boot) bootdev="c"; shift ;;
--vnc-display) display="${2:?}"; shift 2 ;;
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
--mem) mem="${2:?}"; shift 2 ;;
--machine) machine="${2:?}"; shift 2 ;;
*) die "unbekannte Option: $1" ;;
esac
done
# Auto-Erkennung der Medien im VM-Ordner, falls nicht explizit angegeben.
[[ -z "${disk}" && -f "${d}/disk.qcow2" ]] && disk="${d}/disk.qcow2"
[[ -z "${floppy}" && -f "${d}/floppy.img" ]] && floppy="${d}/floppy.img"
[[ -z "${iso}" && -f "${d}/cdrom.iso" ]] && iso="${d}/cdrom.iso"
[[ -n "${disk}${floppy}${iso}" ]] || \
die "Keine Boot-Medien fuer '${name}' (disk.qcow2 / --iso / --floppy). Erst 'create <name> <arch> <groesse>' oder ein Medium angeben."
local args=(-name "${name}" -m "${mem}"
-drive "file=${d}/disk.qcow2,format=qcow2"
-vnc "127.0.0.1:${display}"
-vnc "${vncbind}:${display}"
-monitor "unix:${d}/monitor.sock,server,nowait"
-pidfile "${d}/pid" -daemonize)
[[ -n "${disk}" ]] && args+=(-drive "file=${disk},format=qcow2")
[[ -n "${floppy}" ]] && args+=(-fda "${floppy}")
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
# KVM nur fuer x86 auf x86-Host.
case "${arch}" in
@@ -104,11 +128,17 @@ cmd_boot() {
esac
fi
[[ -n "${machine}" ]] && args+=(-M "${machine}")
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
# Boot-Reihenfolge: explizit, sonst automatisch (ISO→d, nur Diskette→a, sonst c).
if [[ -z "${bootdev}" ]]; then
if [[ -n "${iso}" ]]; then bootdev="d"
elif [[ -n "${floppy}" && -z "${disk}" ]]; then bootdev="a"
else bootdev="c"; fi
fi
args+=(-boot "${bootdev}")
"${bin}" "${args[@]}"
echo "VM '${name}' gestartet (${arch}) — VNC 127.0.0.1:${display} (Port $((5900+display)))."
echo "VM '${name}' gestartet (${arch}) — VNC ${vncbind}:${display} (Port $((5900+display)))."
echo "vnc_display=${display} vnc_port=$((5900+display))"
}
@@ -117,16 +147,20 @@ cmd_screenshot() {
local d; d="$(vm_dir "${name}")"
vm_running "${name}" || die "VM '${name}' laeuft nicht"
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
mkdir -p "${SHOT_DIR}"
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
mkdir -p "${out_dir}"
local ts; ts="$(date +%s)"
local ppm="${d}/shot-${ts}.ppm"
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
sleep 0.3
local out="${SHOT_DIR}/${name}-${ts}.png"
local out="${out_dir}/${name}-${ts}.png"
if command -v convert >/dev/null; then
convert "${ppm}" "${out}" && rm -f "${ppm}"
else
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
fi
echo "screenshot=${out}"
}
+4
View File
@@ -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> }
+34
View File
@@ -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
+13
View File
@@ -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"]
+102
View File
@@ -0,0 +1,102 @@
# 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).
## ⚠️ Wichtig: der Satellit MUSS im echten Ziel-LAN laufen
Discovery (mDNS/SSDP-Multicast + ARP) funktioniert **nur**, wenn der Prozess
tatsächlich im selben LAN wie die Geräte hängt — z.B. `192.168.177.0/24`, wo der
Fire TV steht.
**Docker Desktop (Mac/Windows) geht NICHT.** Dort ist `network_mode: host` das Netz
der Docker-Linux-VM (NAT, `192.168.65.x` / `172.x`), **nicht** dein echtes LAN.
Der Satellit sieht dann nur Docker-Container statt der echten Geräte. (Der Satellit
erkennt das selbst und meldet eine ⚠-Warnung im Diagnostic + Log.)
Richtig deployen — zwei Wege:
**A) Linux-Box im Ziel-LAN mit Docker Engine** (empfohlen, z.B. Raspberry Pi / NUC im Büro):
```bash
cd satellite
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION
docker compose up -d --build
docker compose logs -f # "Netz: primary_ip=192.168.177.x" + "[scan] N Geraete"
```
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
`satellite/`-Ordner liegen):
```bash
cd satellite
cp .env.example .env # RVS-Zugang + SATELLITE_LOCATION eintragen
pip install -r requirements.txt
python satellite.py # liest .env automatisch
```
(Echte Umgebungsvariablen haben Vorrang — `export RVS_TOKEN=...` überschreibt die
`.env`, falls du das lieber magst.)
`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").
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
(`192.168.177.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
falschen (Docker-)Netz.
## Als Dienst installieren (Autostart, ohne Docker)
Legt automatisch ein `.venv` an, installiert die Requirements und richtet einen
Dienst ein, der bei Boot startet und bei Absturz neu hochkommt. `.env` vorher
anlegen.
**Linux (systemd) / macOS (launchd):**
```bash
cd satellite
bash install.sh # installieren + starten
bash install.sh uninstall # entfernen
```
- Linux-Logs: `journalctl -u aria-satellite -f`
- macOS-Logs: `tail -f satellite.log` (evtl. „Lokales Netzwerk"-Zugriff erlauben)
**Windows (Scheduled Task) — PowerShell als Administrator:**
```powershell
cd satellite
powershell -ExecutionPolicy Bypass -File install.ps1 # installieren + starten
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # entfernen
```
- Log: `Get-Content -Wait satellite.log`
## 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.
+23
View File
@@ -0,0 +1,23 @@
# 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.
#
# ⚠ NUR auf LINUX Docker Engine, und der Host muss physisch im Ziel-LAN haengen!
# Docker Desktop (Mac/Windows) gibt hier NUR das Docker-VM-Netz (192.168.65.x /
# 172.x), NICHT dein echtes LAN → Discovery findet dann nur Docker-Container.
# Fuer Mac/Windows: satellite.py nativ starten (siehe README, Weg B).
services:
satellite:
build: .
container_name: aria-satellite
network_mode: host
env_file: .env
restart: unless-stopped
+87
View File
@@ -0,0 +1,87 @@
<#
ARIA Satellit Dienst-Installer fuer Windows (Scheduled Task, Autostart + Restart).
powershell -ExecutionPolicy Bypass -File install.ps1 installiert + startet
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall entfernt den Task
Legt ein venv (.venv) an, installiert requirements.txt und richtet einen
geplanten Task ein, der satellite.py aus DIESEM Ordner startet (findet die .env)
und bei Absturz/Neustart wieder hochkommt. Laeuft als aktueller Nutzer (S4U),
auch ohne Anmeldung. Als Administrator ausfuehren.
WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) auf einer
Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
#>
param([switch]$Uninstall)
$ErrorActionPreference = "Stop"
$Dir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Task = "ARIA-Satellite"
# ─── Uninstall ──────────────────────────────────────────────────────
if ($Uninstall) {
if (Get-ScheduledTask -TaskName $Task -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $Task -Confirm:$false
Write-Host "Task '$Task' entfernt."
} else {
Write-Host "Kein Task '$Task' gefunden."
}
exit 0
}
# ─── Admin-Check ────────────────────────────────────────────────────
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Warning "Bitte als Administrator ausfuehren (Rechtsklick PowerShell -> Als Administrator)."
exit 1
}
# ─── Python finden ──────────────────────────────────────────────────
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $py) { $py = (Get-Command python3 -ErrorAction SilentlyContinue).Source }
if (-not $py) { Write-Error "Python 3 nicht gefunden. Bitte von python.org installieren (mit 'Add to PATH')."; exit 1 }
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
if (-not (Test-Path "$Dir\.venv")) {
Write-Host "[install] Lege venv an ..."
& $py -m venv "$Dir\.venv"
}
$venvPy = "$Dir\.venv\Scripts\python.exe"
Write-Host "[install] Installiere Abhaengigkeiten ..."
& $venvPy -m pip install --upgrade pip | Out-Null
& $venvPy -m pip install -r "$Dir\requirements.txt"
if (-not (Test-Path "$Dir\.env")) {
Write-Warning "Keine .env gefunden - bitte anlegen: copy .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
}
# ─── Scheduled Task ─────────────────────────────────────────────────
Write-Host "[install] Richte geplanten Task '$Task' ein ..."
# Ueber cmd starten, damit stdout/stderr in satellite.log landen (der Task laeuft
# in Session 0 / S4U = kein sichtbares Fenster, python.exe ist hier ok).
$logfile = Join-Path $Dir "satellite.log"
$cmdArg = '/c ""{0}" satellite.py >> "{1}" 2>&1"' -f $venvPy, $logfile
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument $cmdArg -WorkingDirectory $Dir
$trigger = @(
New-ScheduledTaskTrigger -AtStartup
New-ScheduledTaskTrigger -AtLogOn
)
# Laeuft als aktueller Nutzer, auch ohne Anmeldung (S4U), mit hoechsten Rechten.
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-LogonType S4U -RunLevel Highest
# Bei Absturz neu starten, unbegrenzte Laufzeit, startet nach falls Zeitpunkt verpasst, versteckt.
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -Hidden `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
-MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $Task -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $Task
Write-Host "OK Dienst laeuft."
Write-Host " Status: Get-ScheduledTask -TaskName $Task"
Write-Host " Stoppen: Stop-ScheduledTask -TaskName $Task"
Write-Host " Log: Get-Content -Wait '$logfile' (pruefen: primary_ip + '[scan] N Geraete')"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
#
# ARIA Satellit — Dienst-Installer fuer Linux (systemd) und macOS (launchd).
#
# bash install.sh installiert + startet den Dienst
# bash install.sh uninstall entfernt den Dienst
#
# Legt ein venv an (.venv), installiert requirements.txt und richtet einen
# Autostart-Dienst ein, der satellite.py aus DIESEM Ordner startet (damit die
# .env gefunden wird) und bei Absturz neu startet.
#
# WICHTIG: Der Satellit muss im ECHTEN Ziel-LAN laufen (siehe README) — auf einer
# Maschine, die physisch im Netz der Geraete haengt. Docker Desktop taugt nicht.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SVC="aria-satellite"
PLIST_LABEL="de.aria.satellite"
OS="$(uname -s)"
ACTION="${1:-install}"
# ─── Uninstall ──────────────────────────────────────────────────────
if [ "$ACTION" = "uninstall" ]; then
if [ "$OS" = "Linux" ]; then
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO systemctl disable --now "$SVC" 2>/dev/null || true
$SUDO rm -f "/etc/systemd/system/$SVC.service"
$SUDO systemctl daemon-reload
echo "systemd-Dienst '$SVC' entfernt."
elif [ "$OS" = "Darwin" ]; then
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "launchd-Agent '$PLIST_LABEL' entfernt."
fi
exit 0
fi
# ─── venv + Abhaengigkeiten ─────────────────────────────────────────
PY="$(command -v python3 || command -v python || true)"
[ -n "$PY" ] || { echo "Python 3 nicht gefunden. Bitte installieren."; exit 1; }
if [ ! -d "$DIR/.venv" ]; then
echo "[install] Lege venv an …"
if ! "$PY" -m venv "$DIR/.venv" 2>/dev/null; then
echo "venv-Erstellung fehlgeschlagen. Auf Debian/Ubuntu: sudo apt install python3-venv"
exit 1
fi
fi
VENV_PY="$DIR/.venv/bin/python"
echo "[install] Installiere Abhaengigkeiten …"
"$VENV_PY" -m pip install --upgrade pip >/dev/null
"$VENV_PY" -m pip install -r "$DIR/requirements.txt"
if [ ! -f "$DIR/.env" ]; then
echo "⚠ Keine .env gefunden — bitte anlegen: cp .env.example .env (RVS-Zugang + SATELLITE_LOCATION)"
fi
# ─── Dienst einrichten ──────────────────────────────────────────────
if [ "$OS" = "Linux" ]; then
RUN_USER="${SUDO_USER:-$(id -un)}"
UNIT="/etc/systemd/system/$SVC.service"
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
echo "[install] Schreibe systemd-Unit $UNIT (User=$RUN_USER) …"
$SUDO tee "$UNIT" >/dev/null <<EOF
[Unit]
Description=ARIA Satellit (Netz-Aussenposten)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$RUN_USER
WorkingDirectory=$DIR
ExecStart=$VENV_PY $DIR/satellite.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
$SUDO systemctl daemon-reload
$SUDO systemctl enable --now "$SVC"
echo "✅ Dienst laeuft."
echo " Status: $SUDO systemctl status $SVC"
echo " Logs: $SUDO journalctl -u $SVC -f"
elif [ "$OS" = "Darwin" ]; then
PLIST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
mkdir -p "$HOME/Library/LaunchAgents"
echo "[install] Schreibe launchd-Agent $PLIST"
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$PLIST_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$VENV_PY</string>
<string>$DIR/satellite.py</string>
</array>
<key>WorkingDirectory</key><string>$DIR</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>$DIR/satellite.log</string>
<key>StandardErrorPath</key><string>$DIR/satellite.log</string>
</dict>
</plist>
EOF
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST"
echo "✅ Agent geladen."
echo " Logs: tail -f $DIR/satellite.log"
echo " Hinweis: macOS fragt evtl. nach 'Lokales Netzwerk'-Zugriff — erlauben, sonst findet der Satellit keine Geraete."
else
echo "Unbekanntes OS: $OS (dieses Skript kann Linux/macOS; fuer Windows: install.ps1)"
exit 1
fi
+3
View File
@@ -0,0 +1,3 @@
websockets>=12.0
zeroconf>=0.131.0
requests>=2.31.0
+668
View File
@@ -0,0 +1,668 @@
"""
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")
def _load_dotenv() -> None:
"""Laedt eine .env neben dem Script (oder im CWD) in os.environ — fuer den
NATIVEN Start (`python satellite.py`). In Docker sind die Variablen via
env_file schon gesetzt; bereits gesetzte Werte gewinnen (werden NICHT
ueberschrieben). Kein python-dotenv noetig."""
here = os.path.dirname(os.path.abspath(__file__))
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
if not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key[len("export "):].strip()
val = val.strip()
if val[:1] in ("'", '"'):
# Gequotet: Inhalt bis zum schliessenden Quote, Rest (Kommentar) egal.
q = val[0]
end = val.find(q, 1)
val = val[1:end] if end != -1 else val[1:]
else:
# Ungequotet: Inline-Kommentar (Whitespace + #) abschneiden.
m = re.search(r"\s+#", val)
if m:
val = val[:m.start()]
val = val.strip()
if key and key not in os.environ:
os.environ[key] = val
except Exception as exc:
logging.getLogger("satellite").warning(".env laden fehlgeschlagen (%s): %s", path, exc)
break # erste gefundene .env gewinnt
_load_dotenv()
# ─── 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
# ─── Netz-Kontext / Selbstdiagnose ──────────────────────────────────
def _in_docker_bridge(ip: str) -> bool:
# Docker-Default-Bridge-Range 172.16.0.0/12
try:
a, b = ip.split(".")[:2]
return a == "172" and 16 <= int(b) <= 31
except Exception:
return False
def _net_context() -> dict:
"""Ermittelt in welchem Netz der Satellit LAeUFT — und warnt, wenn das ein
Docker-/NAT-Netz ist (dann erreicht Discovery das echte LAN nicht)."""
ips: list[str] = []
primary = ""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(1)
s.connect(("8.8.8.8", 80))
primary = s.getsockname()[0]
s.close()
except Exception:
pass
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip and not ip.startswith("127.") and ip not in ips:
ips.append(ip)
except Exception:
pass
if primary and primary not in ips:
ips.insert(0, primary)
p = primary or (ips[0] if ips else "")
warning = ""
if p.startswith("192.168.65.") or _in_docker_bridge(p):
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
return {"primary_ip": p, "ips": ips, "warning": warning}
NET = _net_context()
# ─── 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,
"net": NET,
},
"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,
"net": NET},
"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)
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
if NET.get("warning"):
logger.warning("%s", NET["warning"])
try:
asyncio.run(Satellite().run())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()