15 KiB
⚙ 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, eineCameraSwitchpro 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:truevia go2rtc,stream:falsevia eigenem FFmpeg)Eine Klasse: jede Kamera ist eine CameraSwitchmit On-DemandPhase 2 (separater one-shot-Pfad) entfällt — getFrame()/grabHires()gelten für allego2rtc-Config parallel pflegen (Redeploy je Kamera) nur cameras.json→ erzeugtCameraSwitch-InstanzenPhase 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), fertigPhase-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-Boxreine 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-<img>).
Kamera-Konfigurationsdatei (cameras.json)
Statt hardcodierter Gerätenamen eine maschinenlesbare Liste:
{
"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: 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.jsonbeim Start laden, validieren- Je Eintrag eine
CameraSwitcherzeugen (ersetzt das heutigedetectDevices()-Mapping). ⚠ Keine go2rtc-Config mehr — die Kamera-Definition lebt nur noch incameras.json. Geräte müssen weiterhin indocker-compose.yamldurchgereicht werden (devices:).
src/snapshotService.js
GET /api/snapshot→ liest Kamera-Metadaten auscameras.json(Feldername,position,streammitliefern). Die Switch-Map kommt ausserver.js.
public/viewer.js
- Kamera-Box zeigt
name+positionstatt rohemid - 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
CameraSwitchmacht Snapshots on-demand (getFrame/grabHires) — egal obstream:trueoderfalse. ffmpeg im Container + Geräte-Durchreichung sind bereits erledigt. Der einzige offene Teil aus dieser Phase: beistream:falseim 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:
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:
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):
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/hiresliefert 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:
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": "<base64>", "cam_top": "<base64>" }
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.