From 45d9837f4f309a784af5dbf3908e97ac6bba31fd Mon Sep 17 00:00:00 2001
From: chk <79915315+ChKendel@users.noreply.github.com>
Date: Fri, 5 Jun 2026 07:32:05 +0200
Subject: [PATCH] Umbau mit cameraSwitch Dokumentation
---
doc/04_Delay_roadmap.md | 12 ++++
doc/05_screenShot_roadmap.md | 12 +++-
doc/07_multipleCam_roadmap.md | 130 ++++++++++++++++++++++------------
doc/09_Bug_reports.md | 27 ++++++-
4 files changed, 131 insertions(+), 50 deletions(-)
diff --git a/doc/04_Delay_roadmap.md b/doc/04_Delay_roadmap.md
index a7ed89a..651a77c 100644
--- a/doc/04_Delay_roadmap.md
+++ b/doc/04_Delay_roadmap.md
@@ -4,6 +4,18 @@
> 2026-06-05 **nicht mehr über go2rtc**, sondern über einen Node-eigenen MJPEG-Schalter
> (`src/cameraSwitch.js`). Grund: das 106%-Race beim HD-Snapshot. Maßgeblich:
> `05_screenshot_roadmap.md` (Abschnitt „Node-MJPEG-Schalter") und `09_Bug_reports.md`.
+>
+> ## 🏁 Endergebnis Delay (gemessen 2026-06-05)
+> | Variante | Latenz | CPU | Freezes |
+> |----------|--------|-----|---------|
+> | go2rtc H.264 (WebRTC) | ~130 ms | ~100 % | ja |
+> | go2rtc MJPEG | ~200 ms | ~50 % | nein |
+> | **Node-MJPEG-Schalter** | **139 ms** | **~5 % idle · ~35 %/Kam aktiv** | nein |
+> >
+> > Der Schalter unterbietet die go2rtc-MJPEG-Latenz (139 vs. 200 ms) und kommt nahe an
+> > H.264 (139 vs. 130 ms) — **ohne** dessen CPU-Last und Freezes. Stellschrauben, die das
+> > brachten: **`-fflags nobuffer` + `-flush_packets 1`** (FFmpeg) und **`socket.setNoDelay(true)`
+> > + `cork/uncork`** (Node-Stream, ein TCP-Segment pro Frame). On-Demand drückt Idle auf ~5 %.
# AppRobotWebcam – Delay / Ruckler-Analyse
diff --git a/doc/05_screenShot_roadmap.md b/doc/05_screenShot_roadmap.md
index 00235f5..89186d7 100644
--- a/doc/05_screenShot_roadmap.md
+++ b/doc/05_screenShot_roadmap.md
@@ -442,8 +442,16 @@ Reconnect innerhalb der Karenz hält Live (kein Thrashing). Abschaltbar: `ON_DEM
(acquire/release/idle-Stop/Reconnect-Karenz/getFrame/always-on) per Mock-Unittest.
- **FFmpeg Default `copybsf`** = `-c:v copy -bsf:v mjpeg2jpeg` (Bitstream-Copy, kein
Transcode; auf dem Host getestet). Fallback `ENCODE_MODE=mjpeg` (~50%, Re-Encode).
-- **Auf der Hardware:** CPU **69 % für 2 Kameras bestätigt** (User, copybsf). Latenz nach
- den Flags oben + Bug-Reproweg noch gegenzumessen.
+- **✅ Auf der Hardware bestätigt (User, 2026-06-05):**
+ - **Latenz 139 ms** Kamera→Browser (vorher 340 ms) — nach `nobuffer`/`flush_packets`/`setNoDelay`/`cork`.
+ - **CPU ~5 % idle** (On-Demand, keine Clients), ~35 %/Kamera beim aktiven Streamen (copybsf).
+ - **HD-Grab beider Kameras parallel:** je echtes 1280×960-JPEG (~133 KB) in ~2,3 s. Live kehrt sauber zurück.
+ - **Login/Logout + Screenshot+Reconnect:** kein 106%-Race mehr.
+- **Bekanntes Restproblem (niedrige Prio):** ein Live-Stream ist einmal eingefroren
+ (Einzelfall, akzeptiert). Verdacht: gedroppte/abgebrochene multipart-Verbindung, die
+ nicht von selbst reconnectet. Später prüfen: clientseitiger Watchdog (Frame-Timeout →
+ `img.src` neu setzen) bzw. ein abgebrochener `onFrame`-Write, der `cleanup()` auslöst,
+ ohne dass der Browser neu verbindet.
## Hardware-Testplan
diff --git a/doc/07_multipleCam_roadmap.md b/doc/07_multipleCam_roadmap.md
index 449de8e..78a309a 100644
--- a/doc/07_multipleCam_roadmap.md
+++ b/doc/07_multipleCam_roadmap.md
@@ -1,7 +1,31 @@
+> # ⚙ 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**. Aktuelle Implementierung: 2 Streaming-Kameras + HD-Grab via Phase 2
-> (`05_screenShot_roadmap.md`). Diese Datei beschreibt den Ausbau auf bis zu 10 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.
---
@@ -18,22 +42,29 @@
---
-## Grundproblem: Zwei Kamera-Klassen
+## Grundproblem: ~~Zwei Kamera-Klassen~~ → EINE Klasse (aktualisiert 2026-06-05)
-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:
+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:
-| Klasse | `stream: true` | `stream: false` |
+| | `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 |
+| 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) |
-**Faustregel:** Kameras, die permanent beobachtet werden müssen → `stream: true`.
-Kameras, die nur beim Homing / Trigger relevant sind → `stream: false`.
+**`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-`
`).
---
@@ -83,7 +114,7 @@ Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:
| `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 |
+| `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 |
@@ -100,26 +131,23 @@ Symlinks auf `/dev/videoN` — einmal prüfen, dann in `cameras.json` eintragen.
---
-## Architektur-Überblick
+## Architektur-Überblick (aktualisiert 2026-06-05)
```
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
+ └── 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
- ├── 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]
+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]
```
---
@@ -155,12 +183,13 @@ Kein funktionaler Unterschied, aber Grundlage für alles Folgende.
**`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)
+- 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` statt aus go2rtc
-- Gefilterte Liste (kein `_hires`) bleibt; Felder `name`, `position`, `stream` mitliefern
+- `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`
@@ -173,9 +202,15 @@ Kein funktionaler Unterschied, aber Grundlage für alles Folgende.
---
-### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot)
+### Phase 2 — Snapshot-only-Kameras (FFmpeg one-shot) — ⚠ GRÖSSTENTEILS OBSOLET
-**Ziel:** Kameras mit `stream: false` können Snapshots liefern, ohne go2rtc zu berühren.
+> **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.
@@ -223,10 +258,10 @@ nicht kollidieren.
**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).
+⚠ **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:**
```
@@ -334,8 +369,8 @@ synchron warten kann und keine grossen Bilder überträgt.
| 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_*` |
+| 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 |
@@ -344,15 +379,16 @@ synchron warten kann und keine grossen Bilder überträgt.
## Empfohlene Reihenfolge
```
-Phase 1 (cameras.json) ~2 h Grundlage, kein Risiko
+Phase 1 (cameras.json + Switch-Erzeugung) ~2 h Grundlage, kein Risiko
↓
-Phase 2 (Snapshot-only, ffmpeg) ~3 h ffmpeg-Abhängigkeit klären
+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 baut auf Phase 1+2
+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 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 oder 4C ~3–4 h nur wenn Push-Trigger gebraucht wird
```
Phase 4B/C **erst wenn** ein konkreter aufrufender Container existiert —
diff --git a/doc/09_Bug_reports.md b/doc/09_Bug_reports.md
index 7f0c466..c18c0de 100644
--- a/doc/09_Bug_reports.md
+++ b/doc/09_Bug_reports.md
@@ -1,5 +1,11 @@
## Multi-User ##
+> ✅ **GELÖST (2026-06-05) durch den Node-MJPEG-Schalter.** Genau die „Schalter"-Idee aus
+> Option 2 wurde umgesetzt: ein FFmpeg pro Gerät, der Server verteilt an alle Browser
+> (Fan-out). Clients halten kein Gerät mehr → HD-Grab pausiert nur kurz die eine Quelle,
+> unabhängig von der Client-Zahl. Details: `05_screenShot_roadmap.md`. (Beschreibung unten
+> = ursprünglicher Bug-Report.)
+
Wenn zwei (oder mehr) User streamen, dann kann kein High-Res Bild mehr
gemacht werden. Das Problem: Der Stream bleibt irgendwo aufrecht erhalten.
@@ -124,4 +130,23 @@ kein Transcode. Fallback `mjpeg` = Re-Encode mjpeg→mjpeg (~50%, wie go2rtc).
→ als Default `copybsf` verdrahtet, `mjpeg` bleibt als Fallback (`ENCODE_MODE`).
Lehre erneut: **erst die Doku-Fakten anwenden, dann bauen** — und Optimierungen **messen**
-(Punkt 3), nicht vorhersagen.
\ No newline at end of file
+(Punkt 3), nicht vorhersagen.
+
+---
+
+## Stream-Freeze (Einzelfall) — offen, niedrige Priorität
+
+**Beobachtet 2026-06-05:** Ein Live-Stream ist **einmal** eingefroren (Bild stand still,
+während die Last normal blieb). Vom User als akzeptabel eingestuft, später anschauen.
+
+**Verdachtsmomente (zu prüfen, wenn es erneut auftritt):**
+- Die multipart-Verbindung des `
` brach ab (z. B. ein fehlgeschlagener `res.write`
+ → `cleanup()` meldet den Verbraucher ab), **ohne** dass der Browser von selbst neu
+ verbindet. Der `
`-`error`-Handler in `viewer.js` reconnectet nur bei einem echten
+ `error`-Event — ein „eingefrorenes" Bild ohne Fehler-Event fällt durch.
+- Ein einzelnes korruptes JPEG aus dem Kamera-MJPEG (die `APP fields`-Warnung), das der
+ Browser nicht rendert und danach hängt.
+
+**Möglicher Fix (wenn relevant):** clientseitiger Watchdog — Frames/Zeit zählen
+(`img.onload`-Takt); kommt N Sekunden kein neues Bild, `img.src` neu setzen (Reconnect).
+Server-seitig ist die Quelle stabil (CPU/Log unauffällig), daher zuerst clientseitig ansetzen.
\ No newline at end of file