> # ⚙ ARCHITEKTUR-UPDATE (2026-06-05) — der Plan wird durch den Node-MJPEG-Schalter EINFACHER > > Diese Roadmap wurde für den **go2rtc**-Aufbau geschrieben. Der ist seit 2026-06-05 ersetzt: > **Node besitzt alle Kameras selbst** (`src/cameraSwitch.js`, eine `CameraSwitch` pro Gerät), > go2rtc ist weg. Das ändert mehrere Grundannahmen — überwiegend zugunsten weniger Aufwand: > > | Roadmap-Annahme (alt) | Neue Realität | Folge für den Plan | > |-----------------------|---------------|--------------------| > | Zwei Kamera-Klassen (`stream:true` via go2rtc, `stream:false` via eigenem FFmpeg) | **Eine** Klasse: jede Kamera ist eine `CameraSwitch` mit On-Demand | **Phase 2 (separater one-shot-Pfad) entfällt** — `getFrame()`/`grabHires()` gelten für alle | > | go2rtc-Config parallel pflegen (Redeploy je Kamera) | nur `cameras.json` → erzeugt `CameraSwitch`-Instanzen | Phase 1 wird einfacher, **keine Doppelpflege** | > | „~25 % CPU pro Live-Stream, dauerhaft" | **On-Demand: 0 % idle**, ~35 %/Kamera **nur während aktiv beobachtet** | „2–3 live" kostet nur was, wenn wirklich jemand zuschaut | > | HD-Grab = „Phase-2-Dance" (Consumer release → cam_hires) | `grabHires()` (Live stoppen → 1280 → zurück), **fertig** | Phase-2-Dance-Beschreibungen sind überholt | > | ffmpeg im Node-Container „noch zu ergänzen" | **bereits drin** (+ Geräte durchgereicht) | Phase-2-Voraussetzung schon erfüllt | > | `stream: false` = „kein Live möglich" | jede Kamera *kann* live (On-Demand); `stream:false` = **nur Viewer zeigt keine Live-Box** | reine Anzeige-Entscheidung, kein anderer Grab-Pfad | > > **Netto:** Phasen 1, 3, 4 bleiben sinnvoll (cameras.json/Metadaten, „Snapshot alle", WebService). > **Phase 2 ist großteils erledigt/obsolet.** USB-Bandbreite (unten) gilt unverändert. > Details der Architektur: `05_screenShot_roadmap.md` (Abschnitt „Node-MJPEG-Schalter"). > Die folgenden Abschnitte sind als Konzept erhalten; go2rtc-spezifische Stellen sind > inline mit ⚠ markiert. --- # AppRobotWebcam – Multiple Kameras > Status: **Konzept** (teils überholt, s. Banner oben). Aktuelle Implementierung: Node-MJPEG- > Schalter, alle Kameras On-Demand, HD-Grab via `grabHires()` (`05_screenShot_roadmap.md`). > Diese Datei beschreibt den Ausbau auf bis zu 10 Kameras. --- ## Ziel | Anforderung | Detail | |---|---| | Bis zu **10 USB-Kameras** anschliessen | Nur ein Teil streamt live; alle liefern Snapshots | | **2–3 Live-Streams** im Viewer | CPU-Budget: ~25 % pro MJPEG-Stream (gemessen) | | **Alle** Kameras bei „Snapshot alle" | Streaming- und Nur-Snapshot-Kameras | | **Identifizierbare Bilder** | Jede Kamera hat Namen, Position, Rolle – im Dateinamen sichtbar | | **Download im Browser** | wie bisher | | **Später: WebService-Weiterleitung** | Konzept ausarbeiten, noch nicht implementieren | --- ## Grundproblem: ~~Zwei Kamera-Klassen~~ → EINE Klasse (aktualisiert 2026-06-05) Eine USB-Kamera kann gleichzeitig nur **einmal** geöffnet werden — das bleibt der harte Constraint. ⚠ **Die frühere Zwei-Klassen-Aufteilung ist mit dem Node-Schalter hinfällig:** Jede Kamera ist eine `CameraSwitch`, die das Gerät **on-demand** öffnet. Es gibt **einen** Grab-Pfad für alle: | | `stream: true` | `stream: false` | |---|---|---| | Was ist es | `CameraSwitch` (wie alle) | `CameraSwitch` (wie alle) | | Live-View im Viewer | ja (Live-Box) | nein (nur Snapshot-Symbol) — **reine Anzeige-Wahl** | | Snapshot 640 | `getFrame()` (startet Gerät on-demand) | identisch `getFrame()` | | Hires 1280 | `grabHires()` | identisch `grabHires()` | | CPU im Idle | **0 %** (On-Demand, niemand schaut) | **0 %** | | CPU aktiv | ~35 %/Kamera nur solange beobachtet/gegrabbt | nur während eines Grabs (~2 s) | **`stream` steuert jetzt nur, ob der Viewer eine Live-Box aufmacht** — nicht mehr den Grab-Mechanismus. Das alte „Phase-2-Dance" und der separate FFmpeg-one-shot-Pfad entfallen; der Schalter macht das einheitlich. **Faustregel (unverändert sinnvoll):** dauernd zu beobachtende Kameras → `stream: true` (Live-Box); nur beim Homing/Trigger relevante → `stream: false` (spart Viewer-Last und Bandbreite, da kein Dauer-``). --- ## Kamera-Konfigurationsdatei (`cameras.json`) Statt hardcodierter Gerätenamen eine maschinenlesbare Liste: ```json { "cameras": [ { "id": "cam_front", "device": "/dev/video0", "name": "Vorderseite", "position": "front", "stream": true, "hires": true, "note": "Logitech C270, Arm-Spitze" }, { "id": "cam_left", "device": "/dev/video2", "name": "Links", "position": "left", "stream": true, "hires": true, "note": "" }, { "id": "cam_top", "device": "/dev/video4", "name": "Draufsicht", "position": "top", "stream": false, "hires": true, "note": "Nur Snapshot, kein Live-Stream" } ] } ``` ### Felder | Feld | Typ | Bedeutung | |---|---|---| | `id` | string | stabiler Bezeichner, wird im Dateinamen verwendet | | `device` | string | `/dev/videoN` — oder besser persistenter Pfad (s.u.) | | `name` | string | Anzeigename im Viewer | | `position` | string | frei; hilfreich für Homing-Auswertung | | `stream` | bool | `true` → Viewer zeigt Live-Box; `false` → nur Snapshot-Symbol (Grab-Pfad identisch, On-Demand) | | `hires` | bool | `false` → nur 640er-Snapshot verfügbar (z.B. bei alter Kamera) | | `note` | string | Freitext, erscheint nicht im Viewer | ### Persistente Gerätenamen (empfohlen) `/dev/video0` kann nach Reboot wechseln. Stabiler: ``` /dev/v4l/by-id/usb-Logitech_HD_Webcam_C270_-video-index0 ``` Ausgabe: `ls /dev/v4l/by-id/` auf dem Server. Symlinks auf `/dev/videoN` — einmal prüfen, dann in `cameras.json` eintragen. --- ## Architektur-Überblick (aktualisiert 2026-06-05) ``` cameras.json │ └── server.js erzeugt je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand) │ ├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id (MJPEG multipart) └── stream:false → Viewer zeigt nur Snapshot-Symbol (kein Dauer-) (Grab-Pfad für beide identisch — getFrame / grabHires) Node.js / Express :8444 (go2rtc ENTFERNT) ├── GET /api/cameras → Liste aus cameras.json (mit Metadaten) [Phase 4A] ├── GET /api/stream/:id → MJPEG multipart/x-mixed-replace (Live, On-Demand) ├── GET /api/snapshot/:id → 640er JPEG (getFrame – startet Gerät bei Bedarf) ├── GET /api/snapshot/:id/hires → 1280er JPEG (grabHires – Live kurz pausieren) └── POST /api/snapshot/all → alle Kameras grabben, JSON mit Metadaten [Phase 4B] ``` --- ## USB-Hardware: Bandbreite **10 Kameras brauchen mehrere USB-Controller.** Faustregel: | Auflösung | fps | MJPEG-Bitrate (ca.) | Kameras pro USB-2.0-Controller | |---|---|---|---| | 640×480 | 30 | ~5 MB/s | 8 (theoretisch) | | 1280×960 | 15 | ~15 MB/s | 3 | Praktisch: 3–4 Kameras pro Controller ratsam (Headroom für Bursts). `lsusb -t` zeigt die Controller-Topologie. Streaming-Kameras (dauernd aktiv) auf **getrennten Controllern** halten. Snapshot-only-Kameras teilen sich einen Controller ohne Probleme (selten aktiv). --- ## Phasenplan ### Phase 1 — `cameras.json` + Viewer-Anpassung **Ziel:** bestehende 2 Kameras aus `cameras.json` lesen statt hardcodern. Kein funktionaler Unterschied, aber Grundlage für alles Folgende. **Änderungen:** **`cameras.json`** (neu, im Projektverzeichnis) - Startet mit den bestehenden cam0/cam1-Einträgen **`server.js`** - `cameras.json` beim Start laden, validieren - Je Eintrag eine `CameraSwitch` erzeugen (ersetzt das heutige `detectDevices()`-Mapping). ⚠ **Keine go2rtc-Config mehr** — die Kamera-Definition lebt nur noch in `cameras.json`. Geräte müssen weiterhin in `docker-compose.yaml` durchgereicht werden (`devices:`). **`src/snapshotService.js`** - `GET /api/snapshot` → liest Kamera-Metadaten aus `cameras.json` (Felder `name`, `position`, `stream` mitliefern). Die Switch-Map kommt aus `server.js`. **`public/viewer.js`** - Kamera-Box zeigt `name` + `position` statt rohem `id` - Nicht-Streaming-Kameras (`stream: false`): keine Live-Box, nur Snapshot-Symbol (Platzhalter-Box mit Kamera-Name und einem Snapshot-Button) **Dateinamen:** `${cam.id}_hires_${timestamp}.jpg` → bereits korrekt wenn `id` sprechend ist **Erfolgskriterium:** Viewer zeigt `name`/`position`, Dateinamen enthalten `id`. --- ### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot) — ⚠ GRÖSSTENTEILS OBSOLET > **Hinweis (2026-06-05):** Mit dem Node-Schalter ist dieser separate Pfad **nicht mehr > nötig.** Jede `CameraSwitch` macht Snapshots on-demand (`getFrame`/`grabHires`) — egal ob > `stream:true` oder `false`. ffmpeg im Container + Geräte-Durchreichung sind bereits erledigt. > Der einzige offene Teil aus dieser Phase: bei `stream:false` im Viewer **keine** Live-Box > rendern. Der untenstehende one-shot-Plan ist nur noch historischer Kontext. **Ziel (alt):** Kameras mit `stream: false` können Snapshots liefern, ohne go2rtc zu berühren. **Voraussetzung:** `ffmpeg` im Node-Container verfügbar. Aktuelles Dockerfile (`dockerfile_inline` in `docker-compose.yaml`) installiert kein ffmpeg. Erweiterung: ```dockerfile FROM node:lts-bookworm-slim RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app EXPOSE 8444 ``` **`docker-compose.yaml`** — Node-Container braucht Gerätezugang: ```yaml webcam: devices: - /dev/video4:/dev/video4 # je Snapshot-only-Kamera group_add: - video ``` **`src/snapshotService.js`** — neuer Grab-Pfad für `stream: false`: ``` GET /api/snapshot/:id → ffmpeg one-shot @ 640×480, -frames:v 1 GET /api/snapshot/:id/hires → ffmpeg one-shot @ 1280×960, -vf select=gte(n,15) (ersten 15 Frames verwerfen = Warmup, vgl. 04_*) ``` FFmpeg-Befehl (bewährt, vgl. `04_Delay_roadmap.md`): ```bash ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x960 -framerate 15 \ -i /dev/video4 -vf select=gte(n\\,15) -frames:v 1 -q:v 2 -f image2 pipe:1 ``` **Mutex:** pro `device` (nicht pro `id`), damit parallele Aufrufe auf demselben Gerät nicht kollidieren. **Erfolgskriterium:** - `GET /api/snapshot/cam_top/hires` liefert 1280er-JPEG - go2rtc-CPU unverändert (~25 % für Streaming-Kameras) --- ### Phase 3 — „Snapshot alle" inkl. Snapshot-only-Kameras **Ziel:** Ein Button-Klick erzeugt Bilder **aller** Kameras gleichzeitig. ⚠ **Vereinfacht (2026-06-05):** kein Unterschied mehr zwischen den Klassen. **Alle** Kameras nutzen `grabHires()` (Live kurz pausieren → 1280 → zurück). Da jede `CameraSwitch` ihr eigenes Gerät besitzt, laufen die Grabs gefahrlos parallel — genau das macht `snapshotAllHires()` im Viewer heute schon. **`public/viewer.js` — `snapshotAllHires()` erweitern:** ``` Promise.allSettled([ ...streamingCams.map(c => hiresGrab(c)), // Phase-2-Dance ...snapshotOnlyCams.map(c => oneshotGrab(c)) // direkter Fetch ]) ``` **Zeitbudget:** - Streaming-Kameras: ~8–10 s (release + warmup) - Snapshot-only-Kameras: ~2–3 s (FFmpeg one-shot + warmup) - Gesamtdauer: ~8–10 s (parallel, limitiert durch Streaming-Kameras) **Erfolgskriterium:** Alle Kameras liefern Bilder; Dateinamen enthalten `id`. --- ### Phase 4 (Option) — WebService-Schnittstelle #### Option A — Pull: Bestehende REST-Endpunkte (fast fertig) Andere Container (gleicher Host, `network_mode: host`) rufen bereits: ``` GET http://localhost:8444/api/snapshot/cam_front → 640er JPEG GET http://localhost:8444/api/snapshot/cam_front/hires → 1280er JPEG ``` Das funktioniert **jetzt schon**. Einzige Ergänzung nötig: ``` GET /api/cameras → JSON-Liste aller Kameras mit Metadaten ``` Damit kann ein fremder Container die verfügbaren Kameras abfragen und gezielt einzelne Snapshots holen. **Aufwand:** ~1 h (neuer Endpunkt, Metadaten aus `cameras.json`). --- #### Option B — Push-Trigger: „Mache Screenshot" für andere Container Ein fremder Container ruft einen Endpunkt auf; AppRobotWebcam grabbt alle Kameras und legt die Bilder auf ein **gemeinsames Volume**: ``` POST /api/snapshot/trigger Body (optional): { "cameras": ["cam_front", "cam_top"] } ← leer = alle Response: { "job": "abc123", "status": "grabbing" } GET /api/snapshot/job/abc123 Response: { "status": "done", "files": [ { "id": "cam_front", "name": "Vorderseite", "path": "/snapshots/cam_front_hires_1234.jpg" }, { "id": "cam_top", "name": "Draufsicht", "path": "/snapshots/cam_top_hires_1234.jpg" } ]} ``` **Nötige Änderungen:** `docker-compose.yaml` — gemeinsames Volume: ```yaml volumes: snapshots: # benanntes Volume webcam: volumes: - snapshots:/snapshots homing-service: # der aufrufende Container volumes: - snapshots:/snapshots:ro ``` `src/snapshotService.js`: - Bilder nicht nur als HTTP-Response, sondern auch nach `/snapshots/` schreiben - Job-Queue (In-Memory reicht für Single-Operator-Betrieb): Map von `jobId → status` - Dateiname: `${cam.id}_hires_${timestamp}.jpg` **Aufwand:** ~3–4 h. **Wann sinnvoll:** wenn der aufrufende Container die Bilder weiterverarbeiten soll (OCR, ArUco-Erkennung, ML) ohne Browser-Interaktion. --- #### Option C — Synchroner All-in-One-Endpunkt (einfachste WebService-Form) ``` GET /api/snapshot/all/hires Response: multipart/form-data mit je einem JPEG pro Kamera ODER: JSON { "cam_front": "", "cam_top": "" } ``` Nachteil: Antwort blockiert ~8–10 s (Grab-Dauer). Nur sinnvoll wenn der Aufrufer synchron warten kann und keine grossen Bilder überträgt. **Aufwand:** ~2 h. --- ## Offene Punkte / Risiken | Punkt | Risiko | Umgang | |---|---|---| | Gerätenamen `/dev/videoN` wechseln nach Reboot | mittel | persistente by-id-Pfade in `cameras.json` | | USB-Bandbreite bei >4 Kameras gleichzeitig | mittel | separate USB-Controller; `lsusb -t` prüfen | | ffmpeg im Node-Container (Phase 2) | niedrig | einmalige Dockerfile-Änderung; bewährt in `04_*` | | CPU bei vielen Kameras | niedriger als gedacht | On-Demand: nur **gleichzeitig beobachtete** Streams kosten CPU (~35 %/Kam). Idle = 0 %. Grenze ist die Zahl der parallel offenen Live-Views + USB-Bandbreite, nicht die Gesamtzahl der Kameras | | Warmup-Schwarzbild bei Hi-Res | bekannt, gelöst | `CameraSwitch.grabHires` verwirft die ersten Frames (`settleFrames`/`minSize`) | | Parallele Grabs auf gleichem Gerät | beherrschbar | Mutex pro Device (nicht pro ID) | | Job-Queue Phase 4B bei mehreren Clients | gering | für Single-Operator akzeptiert; sonst persistente Queue | --- ## Empfohlene Reihenfolge ``` Phase 1 (cameras.json + Switch-Erzeugung) ~2 h Grundlage, kein Risiko ↓ Phase 2 (Snapshot-only, ffmpeg) ~0 h ⚠ erledigt durch Schalter; nur noch: Viewer rendert bei stream:false keine Live-Box ↓ Phase 3 (Snapshot alle erweitert) ~1 h Logik existiert (snapshotAllHires), nur Liste erweitern ↓ Phase 4A (GET /api/cameras) ~1 h sofort nützlich für andere Container ↓ Phase 4B oder 4C ~3–4 h nur wenn Push-Trigger gebraucht wird ``` Phase 4B/C **erst wenn** ein konkreter aufrufender Container existiert — nicht auf Vorrat bauen.