Files
appRobotWebcam/doc/07_multipleCam_roadmap.md
2026-06-05 07:32:05 +02:00

396 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> # ⚙ 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** | „23 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 |
| **23 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-`<img>`).
---
## 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_<serial>-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-<img>)
(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: 34 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: ~810 s (release + warmup)
- Snapshot-only-Kameras: ~23 s (FFmpeg one-shot + warmup)
- Gesamtdauer: ~810 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:** ~34 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": "<base64>", "cam_top": "<base64>" }
```
Nachteil: Antwort blockiert ~810 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 ~34 h nur wenn Push-Trigger gebraucht wird
```
Phase 4B/C **erst wenn** ein konkreter aufrufender Container existiert —
nicht auf Vorrat bauen.