diff --git a/doc/07_multipleCam_roadmap.md b/doc/07_multipleCam_roadmap.md new file mode 100644 index 0000000..449de8e --- /dev/null +++ b/doc/07_multipleCam_roadmap.md @@ -0,0 +1,359 @@ +# AppRobotWebcam – Multiple Kameras + +> Status: **Konzept**. Aktuelle Implementierung: 2 Streaming-Kameras + HD-Grab via Phase 2 +> (`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 USB-Kamera kann gleichzeitig nur **einmal** geöffnet werden. go2rtc hält +Streaming-Kameras offen. Für Nicht-Streaming-Kameras steht das Gerät dagegen +jederzeit frei. Daraus ergeben sich zwei Klassen mit unterschiedlichem Grab-Pfad: + +| Klasse | `stream: true` | `stream: false` | +|---|---|---| +| In go2rtc-Config | ja (`cam_front`, `cam_front_hires`) | nein | +| Live-View im Viewer | ja | nein (nur Snapshot-Symbol) | +| Hires-Grab | Phase-2-Dance (release → cam_hires) | FFmpeg one-shot direkt | +| CPU im Idle | ~25 % (solange Client verbunden) | 0 % | +| Grab-Komplexität | hoch | niedrig | + +**Faustregel:** Kameras, die permanent beobachtet werden müssen → `stream: true`. +Kameras, die nur beim Homing / Trigger relevant sind → `stream: false`. + +--- + +## 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` → Live-Stream in go2rtc; `false` → nur Snapshot | +| `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 + +``` +cameras.json + │ + ├── stream: true → go2rtc-Config (cam_front, cam_front_hires, …) + │ │ + │ └── Live-View (Browser WS → go2rtc :1984) + │ Hires-Grab (Phase-2-Dance) + │ + └── stream: false → kein go2rtc-Eintrag + │ + └── Hires-Grab (FFmpeg one-shot direkt, Node.js) + Kein Live-View + +Node.js / Express :8444 + ├── GET /api/cameras → Liste aus cameras.json (mit Metadaten) + ├── GET /api/snapshot/:id → 640er JPEG (streaming: via go2rtc; non-streaming: one-shot) + ├── GET /api/snapshot/:id/hires → 1280er JPEG (streaming: Phase-2; non-streaming: one-shot) + └── POST /api/snapshot/all → alle Kameras grabben, JSON-Antwort mit Metadaten [Phase 4] +``` + +--- + +## 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 +- go2rtc-Config-Block in `docker-compose.yaml` weiterhin manuell pflegen + (Redeploy nötig bei Kamera-Änderung — akzeptiert, da Infrastruktur) + +**`src/snapshotService.js`** +- `GET /api/snapshot` → liest Kamera-Metadaten aus `cameras.json` statt aus go2rtc +- Gefilterte Liste (kein `_hires`) bleibt; Felder `name`, `position`, `stream` mitliefern + +**`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) + +**Ziel:** 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. + +**Streaming-Kameras** (`stream: true`): bisheriger paralleler Phase-2-Dance (bereits implementiert). + +**Snapshot-only-Kameras** (`stream: false`): direkter FFmpeg one-shot, kein „Release" nötig +→ können parallel zu den Streaming-Grabs laufen (verschiedene Geräte). + +**`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_*` | +| go2rtc-Config bei >3 Streaming-Kameras | CPU | max. 2–3 `stream: true`; Rest `stream: false` | +| Warmup-Schwarzbild bei Snapshot-only (Phase 2) | bekannt | `select=gte(n,15)` bewährt aus `04_*` | +| 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) ~2 h Grundlage, kein Risiko + ↓ +Phase 2 (Snapshot-only, ffmpeg) ~3 h ffmpeg-Abhängigkeit klären + ↓ +Phase 3 (Snapshot alle erweitert) ~1 h baut auf Phase 1+2 + ↓ +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.