diff --git a/cameras.json b/cameras.json index d0e551a..26ddfcf 100644 --- a/cameras.json +++ b/cameras.json @@ -7,16 +7,18 @@ "position": "front", "stream": true, "hires": true, - "note": "" + "note": "usb-046d_0825_3BB3FE20-video-index0", + "hiresSize": "1280x960" }, { "id": "cam1", "device": "/dev/video2", "name": "Kamera 1", "position": "left", - "stream": false, + "stream": true, "hires": true, - "note": "" + "note": "usb-046d_081b_342D4F40-video-index0", + "hiresSize": "1280x960" }, { "id": "cam2", @@ -25,7 +27,8 @@ "position": "right", "stream": true, "hires": true, - "note": "" + "note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0", + "hiresSize": "1920x1080" } ] } diff --git a/doc/07_multipleCam_roadmap.md b/doc/07_multipleCam_roadmap.md index 78a309a..e41cfd4 100644 --- a/doc/07_multipleCam_roadmap.md +++ b/doc/07_multipleCam_roadmap.md @@ -1,106 +1,78 @@ -> # ⚙ 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. +> Status: **Implementiert** (Phase 1–4A). Phase 4B/C offen. +> Aktueller Stand: 3 Kameras (2× C270, 1× C920), gemischte stream-Konfiguration. --- -## Ziel +## Architektur -| 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 | +``` +cameras.json + │ + └── server.js → je Eintrag EINE CameraSwitch (besitzt /dev/videoN, On-Demand) + │ + ├── stream:true → Viewer zeigt Live-Box → GET /api/stream/:id + └── stream:false → Viewer zeigt Platzhalter (kein Dauer-) + Grab-Pfad identisch: getFrame() / grabHires() + +Node.js / Express :8444 + ├── GET /api/cameras → Metadaten aller Kameras aus cameras.json + ├── GET /api/snapshot → Liste mit Metadaten (id, name, position, stream …) + ├── GET /api/snapshot/:id → 640er JPEG (on-demand, getFrame) + ├── GET /api/snapshot/:id/hires → hires JPEG (grabHires – Live kurz pausieren) + ├── GET /api/stream/:id → MJPEG multipart/x-mixed-replace (Live) + └── GET /health → Status aller Switches +``` + +**Eine Kamera = eine `CameraSwitch`.** Es gibt keinen separaten Grab-Pfad für +Live- vs. Snapshot-Kameras. Das Feld `stream` entscheidet nur, ob der Viewer +eine Live-Box aufbaut — der Server behandelt alle Kameras gleich. + +**CPU-Verbrauch:** On-Demand — 0 % idle, ~35 %/Kamera nur solange jemand schaut +oder ein Grab läuft. Limit ist USB-Bandbreite + Zahl gleichzeitiger Live-Views, +nicht die Gesamtzahl der Kameras. --- -## Grundproblem: ~~Zwei Kamera-Klassen~~ → EINE Klasse (aktualisiert 2026-06-05) +## `cameras.json` -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: +Einzige Konfigurationsquelle für Geräte, Namen und Auflösungen. +Liegt im Projektverzeichnis, wird beim Start geladen und validiert. ```json { "cameras": [ { - "id": "cam_front", - "device": "/dev/video0", - "name": "Vorderseite", - "position": "front", - "stream": true, - "hires": true, - "note": "Logitech C270, Arm-Spitze" + "id": "cam0", + "device": "/dev/video0", + "name": "Vorderseite", + "position": "front", + "stream": true, + "hires": true, + "note": "usb-046d_0825_3BB3FE20-video-index0" }, { - "id": "cam_left", - "device": "/dev/video2", - "name": "Links", - "position": "left", - "stream": true, - "hires": true, - "note": "" + "id": "cam1", + "device": "/dev/video2", + "name": "Links", + "position": "left", + "stream": false, + "hires": true, + "note": "usb-046d_081b_342D4F40-video-index0" }, { - "id": "cam_top", - "device": "/dev/video4", - "name": "Draufsicht", - "position": "top", - "stream": false, - "hires": true, - "note": "Nur Snapshot, kein Live-Stream" + "id": "cam2", + "device": "/dev/video4", + "name": "Rechts", + "position": "right", + "stream": true, + "hires": true, + "liveSize": "1280x720", + "liveFps": 30, + "hiresSize": "1920x1080", + "hiresFps": 30, + "note": "usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0" } ] } @@ -108,288 +80,147 @@ Statt hardcodierter Gerätenamen eine maschinenlesbare Liste: ### 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 | +| Feld | Typ | Pflicht | Bedeutung | +|---|---|---|---| +| `id` | string | ✓ | Stabiler Bezeichner; wird in Dateinamen und API-Pfaden verwendet | +| `device` | string | ✓ | `/dev/videoN` — der Pfad **im Container** (siehe by-id-Mapping unten) | +| `name` | string | | Anzeigename im Viewer (Fallback: `id`) | +| `position` | string | | Frei; wird im Viewer-Label angezeigt (`name · position`) | +| `stream` | bool | | `true` → Live-Box im Viewer; `false` → Platzhalter (Default: `true`) | +| `hires` | bool | | `false` → kein hires-Grab verfügbar, z.B. bei schwacher Kamera (Default: `true`) | +| `liveSize` | string | | z.B. `"1280x720"` — überschreibt globales `LIVE_SIZE`-Env | +| `liveFps` | int | | Überschreibt globales `LIVE_FPS`-Env | +| `hiresSize` | string | | Überschreibt globales `HIRES_SIZE`-Env | +| `hiresFps` | int | | Überschreibt globales `HIRES_FPS`-Env | +| `encode` | string | | `"copybsf"` oder `"mjpeg"` — überschreibt globales `ENCODE_MODE`-Env | +| `note` | string | | Freitext; empfohlen: by-id-Name des Geräts (Dokumentation) | -### 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. +**Globale Env-Defaults** (gelten wenn das Feld in cameras.json fehlt): +`LIVE_SIZE=640x480`, `LIVE_FPS=30`, `HIRES_SIZE=1280x960`, `HIRES_FPS=15`, +`ENCODE_MODE=copybsf`. --- -## Architektur-Überblick (aktualisiert 2026-06-05) +## Stabile Gerätepfade (`docker-compose.yaml`) -``` -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) +`/dev/videoN` kann nach einem Reboot eine andere Kamera bezeichnen. +Lösung: by-id auf dem **Host** → festes `/dev/videoN` im **Container**. -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] +```bash +ls -la /dev/v4l/by-id/ # zeigt die stabilen Namen ``` +In `docker-compose.yaml`: + +```yaml +devices: + - /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 + - /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 + - /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 +``` + +Der by-id-Name gehört zusätzlich ins `note`-Feld von cameras.json (Dokumentation). +`cameras.json` selbst verwendet `/dev/videoN` — den Pfad den der Container sieht. + +--- + +## MJPEG-Auflösung prüfen + +Nicht jede Auflösung ist bei jeder Kamera nativ MJPEG. Nicht-native Auflösungen +erzwingen Software-Re-Encode (~50 % CPU extra). + +```bash +v4l2-ctl --list-formats-ext -d /dev/video4 | grep -A 20 MJPG +``` + +Nur Auflösungen unter `'MJPG'` in `liveSize`/`hiresSize` eintragen. +Falls eine Auflösung nur unter `'YUYV'` erscheint → andere Auflösung wählen. + +Falls der Stream schwarz bleibt obwohl die Auflösung als MJPG gelistet ist: +`"encode": "mjpeg"` in cameras.json für diese Kamera erzwingt Re-Encode +(kompatibel mit jedem Kamera-MJPEG, aber höhere CPU-Last). + --- ## USB-Hardware: Bandbreite -**10 Kameras brauchen mehrere USB-Controller.** Faustregel: +Mehrere aktive Live-Streams brauchen genug USB-Kapazität. -| Auflösung | fps | MJPEG-Bitrate (ca.) | Kameras pro USB-2.0-Controller | +| Auflösung | fps | MJPEG-Bitrate | Streams pro USB-2.0-Controller | |---|---|---|---| -| 640×480 | 30 | ~5 MB/s | 8 (theoretisch) | -| 1280×960 | 15 | ~15 MB/s | 3 | +| 640×480 | 30 | ~5 MB/s | 8 (theoretisch), 3–4 (praktisch) | +| 1280×720 | 30 | ~12 MB/s | 2–3 | +| 1280×960 | 15 | ~15 MB/s | 2–3 | +| 1920×1080 | 30 | ~25 MB/s | 1–2 | -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). +`lsusb -t` zeigt welche Kameras am selben Controller hängen. +Streaming-Kameras (`stream:true`) möglichst auf **getrennten Controllern**. +Snapshot-only-Kameras (`stream:false`) teilen einen Controller problemlos. --- -## Phasenplan +## Neue Kamera hinzufügen -### 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`. +1. `ls -la /dev/v4l/by-id/` → by-id-Namen identifizieren +2. `v4l2-ctl --list-formats-ext -d /dev/videoN | grep -A 20 MJPG` → Auflösung wählen +3. Eintrag in `cameras.json` ergänzen (id, device, name, position, stream, liveSize …) +4. `docker-compose.yaml` → neues Device (`by-id → /dev/videoN`) eintragen +5. Stack in Portainer redeployen --- -### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot) — ⚠ GRÖSSTENTEILS OBSOLET +## Phase 4B/C — WebService-Push (offen) -> **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. +**Erst umsetzen wenn ein konkreter aufrufender Container existiert.** -**Ziel (alt):** Kameras mit `stream: false` können Snapshots liefern, ohne go2rtc zu berühren. +### Option B — Push-Trigger mit Job-ID -**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**: +Ein fremder Container löst einen Grab aus; Bilder landen auf einem gemeinsamen Volume. ``` POST /api/snapshot/trigger - Body (optional): { "cameras": ["cam_front", "cam_top"] } ← leer = alle + Body (optional): { "cameras": ["cam0", "cam2"] } ← 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" } + { "id": "cam0", "path": "/snapshots/cam0_hires_1234.jpg" }, + { "id": "cam2", "path": "/snapshots/cam2_hires_1234.jpg" } ]} ``` -**Nötige Änderungen:** - `docker-compose.yaml` — gemeinsames Volume: ```yaml volumes: - snapshots: # benanntes Volume - + snapshots: webcam: volumes: - snapshots:/snapshots -homing-service: # der aufrufende Container +homing-service: 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` +`src/snapshotService.js`: Bilder nach `/snapshots/` schreiben, In-Memory Job-Queue. **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) +### Option C — Synchroner Einzel-Endpunkt ``` GET /api/snapshot/all/hires - Response: multipart/form-data mit je einem JPEG pro Kamera - ODER: JSON { "cam_front": "", "cam_top": "" } + Response: JSON { "cam0": "", "cam2": "" } ``` -Nachteil: Antwort blockiert ~8–10 s (Grab-Dauer). Nur sinnvoll wenn der Aufrufer -synchron warten kann und keine grossen Bilder überträgt. - +Blockiert ~8–10 s. Nur sinnvoll wenn der Aufrufer synchron warten kann. **Aufwand:** ~2 h. --- -## Offene Punkte / Risiken +## Offene Punkte -| Punkt | Risiko | Umgang | +| Punkt | Priorität | Massnahme | |---|---|---| -| 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. +| Phase 4B/C WebService-Push | niedrig | erst wenn aufrufender Container konkret | +| USB-Bandbreite bei >4 aktiven Streams | mittel | `lsusb -t` prüfen, Kameras auf Controller verteilen | +| Stream-Freeze (selten) | niedrig | bekannt; noch kein reproduzierbarer Fall | diff --git a/docker-compose.yaml b/docker-compose.yaml index c5a1cee..43b5753 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -47,12 +47,11 @@ services: volumes: - ${APP_PATH:-.}:/usr/src/app devices: - # Jede Kamera aus cameras.json muss hier aufgeführt sein. - # Empfehlung: statt /dev/videoN → persistente by-id-Pfade verwenden - # (ls -la /dev/v4l/by-id/ auf dem Server zeigt die Namen) - - /dev/video0:/dev/video0 # C270 (046d:0825) → cam0 in cameras.json - - /dev/video2:/dev/video2 # C270 (046d:081b) → cam1 in cameras.json - - /dev/video4:/dev/video4 # C920 HD Pro → cam2 in cameras.json + # by-id (Host) → /dev/videoN (Container) – stabil über Reboots und USB-Re-Plugs. + # Rechte Seite = Pfad den cameras.json + FFmpeg im Container sehen. + - /dev/v4l/by-id/usb-046d_0825_3BB3FE20-video-index0:/dev/video0 # cam0 – C270 (046d:0825) + - /dev/v4l/by-id/usb-046d_081b_342D4F40-video-index0:/dev/video2 # cam1 – C270 (046d:081b) + - /dev/v4l/by-id/usb-046d_HD_Pro_Webcam_C920_9C5591DF-video-index0:/dev/video4 # cam2 – C920 group_add: - video environment: