====== ionpy – Kamera & Video Stream Roadmap ====== ===== USB-Cams, Netz-Cams, Mobile Cam & Handy-Sensoren ===== Erstellt: 2026-02-27\\ Status: In Planung\\ Abhängigkeiten: Keine – unabhängig von Wizard und PWA MVP ---- ===== Architektur-Übersicht ===== ┌─────────────────────────────────────────────────────┐ │ Quellen │ │ 📱 Handy-Cam 🔌 USB-Cam 🌐 IP-Cam (RTSP) │ └──────────┬──────────┬──────────┬────────────────────┘ │ WS │ OpenCV │ OpenCV/ffmpeg ▼ ▼ ▼ ┌─────────────────────────────────────────────────────┐ │ web/camera.py – Frame Store │ │ _frame_store { device_id → bytes (JPEG) } │ │ _frame_subscribers { device_id → [Queue, ...] } │ └────────────────────┬────────────────────────────────┘ │ ┌─────────┴──────────┐ ▼ ▼ GET /api/camera/ GET /api/camera/ snapshot/{id} mjpeg/{id} (Einzelbild) (MJPEG Stream) │ │ ▼ ▼ Desktop Widget Desktop Widget (img + Refresh) (img src=mjpeg) Mobile Snapshot Browser / VLC / ffmpeg **Design-Prinzip:** Alle Quellen landen im selben ''_frame_store''. Alle Consumers lesen aus demselben MJPEG-Endpoint. Quellen und Consumers kennen sich nicht. ---- ===== Neue Abhängigkeiten ===== # requirements.txt Ergänzungen: opencv-python-headless # USB + RTSP Cams (headless = kein GUI) # Oder: opencv-python wenn GUI gewünscht **Hinweis:** Kein ''ffmpeg'' Binary nötig – OpenCV bringt eigene Codec-Unterstützung mit. MJPEG-Streaming ist pure Python. ---- ====== PHASE 0: Backend Fundament ====== //(~1.5 Tage)// ===== 0.1 Frame Store & MJPEG Router ===== **Datei:** ''web/camera.py'' (NEU) **Hinweis für KI:** Der ''_frame_store'' und ''_frame_subscribers'' sind globale Dicts – das ist bewusst simpel gehalten. Bei mehreren Worker-Prozessen (z.B. Gunicorn) müsste man Redis verwenden, aber für Single-Process FastAPI/Uvicorn ist das absolut ausreichend. """ web/camera.py Zentrales Kamera-Backend für ionpy. Empfängt Frames von beliebigen Quellen und stellt sie als MJPEG-Stream und Snapshot bereit. """ import asyncio import time import logging from pathlib import Path from typing import Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect, UploadFile, Form from fastapi.responses import StreamingResponse, JSONResponse logger = logging.getLogger("Web.Camera") router = APIRouter(prefix="/api/camera", tags=["Camera"]) # ========================================================================= # GLOBALER FRAME STORE # ========================================================================= # Letzter JPEG-Frame pro device_id _frame_store: dict[str, bytes] = {} # Unix-Timestamp des letzten Frames pro device_id _frame_timestamps: dict[str, float] = {} # Aktive MJPEG-Consumer pro device_id (Queue bekommt neue Frames) _frame_subscribers: dict[str, list[asyncio.Queue]] = {} # Kamera-Metadaten (Name, Beschreibung, etc.) _camera_meta: dict[str, dict] = {} def _notify_subscribers(device_id: str, frame: bytes): """Neuen Frame an alle wartenden MJPEG-Consumer senden.""" for q in _frame_subscribers.get(device_id, []): try: q.put_nowait(frame) except asyncio.QueueFull: pass # Langsamer Consumer – Frame überspringen def register_camera(device_id: str, name: str = None, source_type: str = "unknown"): """Kamera im System anmelden (optional, für /list Endpoint).""" _camera_meta[device_id] = { "device_id": device_id, "name": name or device_id, "source_type": source_type, # "mobile", "usb", "rtsp", "http" "registered": time.time() } # ========================================================================= # FRAME EMPFANG – Handy via POST # ========================================================================= @router.post("/frame") async def receive_frame( device_id: str = Form(...), image: UploadFile = None ): """ Empfängt einen JPEG-Frame von der Mobile PWA. Die PWA sendet alle N Sekunden ein Standbild. """ if not image: return JSONResponse(status_code=400, content={"error": "Kein Bild erhalten"}) data = await image.read() # Größe sanity check (max 5 MB) if len(data) > 5 * 1024 * 1024: return JSONResponse(status_code=413, content={"error": "Frame zu groß (max 5MB)"}) _frame_store[device_id] = data _frame_timestamps[device_id] = time.time() _notify_subscribers(device_id, data) if device_id not in _camera_meta: register_camera(device_id, source_type="mobile") return {"status": "ok", "size_bytes": len(data)} # ========================================================================= # FRAME EMPFANG – Handy via WebSocket (Binary Stream) # ========================================================================= @router.websocket("/stream/{device_id}") async def camera_websocket_stream( websocket: WebSocket, device_id: str ): """ Empfängt kontinuierliche JPEG-Frames via WebSocket. Für Mobile-Cam Echtzeit-Streaming (10 FPS). Binary-Protokoll: Jede Nachricht = ein vollständiger JPEG-Frame. """ await websocket.accept() logger.info(f"Kamera-Stream verbunden: {device_id}") if device_id not in _camera_meta: register_camera(device_id, source_type="mobile_ws") try: while True: data = await websocket.receive_bytes() # Größen-Check if len(data) > 5 * 1024 * 1024: logger.warning(f"Frame zu groß von {device_id}: {len(data)} bytes") continue _frame_store[device_id] = data _frame_timestamps[device_id] = time.time() _notify_subscribers(device_id, data) except WebSocketDisconnect: logger.info(f"Kamera-Stream getrennt: {device_id}") # Letzten Frame für ~30s behalten, dann als inaktiv markieren except Exception as e: logger.error(f"Kamera WS Fehler ({device_id}): {e}") # ========================================================================= # CONSUMER – Einzelbild # ========================================================================= @router.get("/snapshot/{device_id}") async def get_snapshot(device_id: str): """ Gibt den aktuell gespeicherten Frame als JPEG zurück. Ideal für: gelegentliche Aktualisierung, Thumbnails. """ frame = _frame_store.get(device_id) if not frame: return JSONResponse(status_code=404, content={"error": "Kein Frame verfügbar", "device_id": device_id}) ts = _frame_timestamps.get(device_id, 0) return StreamingResponse( iter([frame]), media_type="image/jpeg", headers={ "Cache-Control": "no-cache, no-store", "X-Frame-Age-Ms": str(int((time.time() - ts) * 1000)), "X-Frame-Timestamp": str(ts) } ) # ========================================================================= # CONSUMER – MJPEG Live Stream # ========================================================================= @router.get("/mjpeg/{device_id}") async def mjpeg_stream(device_id: str): """ MJPEG-Stream für Browser, VLC, ffmpeg, OpenCV. Einfach als verwenden – kein JavaScript nötig! Kompatibel mit: - Browser: , ===== 0.2 Router in server.py einbinden ===== **Datei:** ''web/server.py'' # In create_app() nach den bestehenden Routern: from web.camera import router as camera_router app.include_router(camera_router) ---- ====== PHASE 1: Server-seitige Kameras (USB & RTSP) ====== //(~2 Tage)// ===== 1.1 ServerCamera Klasse ===== **Datei:** ''devices/drivers/camera/server_camera.py'' (NEU) **Hinweis für KI:** * OpenCV ist **nicht async** – läuft in einem Thread * ''threading.Thread(daemon=True)'' damit der Thread beim Prozessende automatisch stirbt * Kein asyncio in der Capture-Loop – nur in ''start()'' / ''stop()'' * Frame-Rate über ''time.sleep()'' steuern * ''cv2.VideoCapture(0)'' für erste USB-Cam * ''cv2.VideoCapture("rtsp://...")'' für IP-Cams """ devices/drivers/camera/server_camera.py Kamera-Treiber für USB- und RTSP-Kameras am Server. Verwendet OpenCV für Frame-Capture, schreibt in web/camera.py Frame Store. """ import cv2 import time import threading import logging from typing import Union from web.camera import ( _frame_store, _frame_timestamps, _notify_subscribers, register_camera ) logger = logging.getLogger("Camera.Server") class ServerCamera: """ Liest Frames von einer USB-Kamera oder RTSP-Quelle. Schreibt JPEG-Frames in den zentralen Frame Store. """ def __init__( self, device_id: str, source: Union[int, str], # 0, 1, 2 oder "rtsp://..." fps: int = 10, width: int = 1280, height: int = 720, jpeg_quality: int = 75, # 0-100, niedriger = kleiner name: str = None ): self.device_id = device_id self.source = source self.fps = fps self.width = width self.height = height self.jpeg_quality = jpeg_quality self.name = name or f"Kamera {device_id}" self._running = False self._thread = None self._cap = None self.error_msg = None self.frame_count = 0 register_camera(device_id, name=self.name, source_type="usb" if isinstance(source, int) else "rtsp") def start(self) -> bool: """Startet den Capture-Thread. Gibt True zurück wenn Kamera gefunden.""" if self._running: return True # Vorab prüfen ob Kamera vorhanden cap = cv2.VideoCapture(self.source) if not cap.isOpened(): self.error_msg = f"Kamera '{self.source}' konnte nicht geöffnet werden" logger.error(self.error_msg) cap.release() return False cap.release() self._running = True self._thread = threading.Thread( target=self._capture_loop, daemon=True, name=f"cam_{self.device_id}" ) self._thread.start() logger.info(f"Kamera gestartet: {self.device_id} " f"({self.source}, {self.fps}fps, " f"{self.width}x{self.height})") return True def stop(self): """Stoppt den Capture-Thread sauber.""" self._running = False if self._thread: self._thread.join(timeout=3.0) logger.info(f"Kamera gestoppt: {self.device_id}") def _capture_loop(self): """Haupt-Capture-Loop (läuft im eigenen Thread).""" self._cap = cv2.VideoCapture(self.source) # Auflösung setzen self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width) self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height) # Buffer minimieren → weniger Latenz self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) interval = 1.0 / self.fps last_frame = 0 error_count = 0 MAX_ERRORS = 10 encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality] while self._running: now = time.time() # Frame-Rate einhalten elapsed = now - last_frame if elapsed < interval: time.sleep(interval - elapsed) continue ret, frame = self._cap.read() if not ret: error_count += 1 logger.warning(f"Frame-Read Fehler ({self.device_id}): " f"{error_count}/{MAX_ERRORS}") if error_count >= MAX_ERRORS: logger.error(f"Kamera {self.device_id} hat aufgehört " f"zu senden. Versuche Reconnect...") self._cap.release() time.sleep(2.0) self._cap = cv2.VideoCapture(self.source) error_count = 0 continue error_count = 0 last_frame = time.time() # JPEG encodieren success, jpeg = cv2.imencode('.jpg', frame, encode_params) if not success: continue jpeg_bytes = jpeg.tobytes() # In Frame Store schreiben _frame_store[self.device_id] = jpeg_bytes _frame_timestamps[self.device_id] = last_frame _notify_subscribers(self.device_id, jpeg_bytes) self.frame_count += 1 if self._cap: self._cap.release() @property def is_running(self) -> bool: return self._running and ( self._thread is not None and self._thread.is_alive() ) def get_status(self) -> dict: return { "device_id": self.device_id, "name": self.name, "source": str(self.source), "running": self.is_running, "fps": self.fps, "resolution": f"{self.width}x{self.height}", "frame_count": self.frame_count, "error": self.error_msg } ===== 1.2 Kamera-Manager ===== **Datei:** ''core/camera_manager.py'' (NEU)\\ **Aufwand:** 0.5 Tage **Hinweis für KI:** Wird von ''SystemEngine'' verwaltet, ähnlich wie ''DeviceManager''. Liest Kamera-Konfiguration aus ''config.yaml''. """ core/camera_manager.py Verwaltet alle Server-seitigen Kameras. Liest Konfiguration aus config.yaml Sektion 'cameras'. """ import logging from typing import Dict from devices.drivers.camera.server_camera import ServerCamera logger = logging.getLogger("CameraManager") class CameraManager: def __init__(self): self.cameras: Dict[str, ServerCamera] = {} def load_config(self, config: dict): """ Liest Kameras aus config.yaml. Beispiel config.yaml: cameras: - id: "usb_cam_0" source: 0 fps: 10 width: 1280 height: 720 name: "Laborübersicht" - id: "ip_cam_garage" source: "rtsp://192.168.1.100:554/stream" fps: 5 name: "Eingangskamera" """ camera_list = config.get("cameras", []) if not camera_list: logger.info("Keine Kameras in config.yaml konfiguriert.") return for cam_conf in camera_list: cam_id = cam_conf.get("id") if not cam_id: logger.error("Kamera-Eintrag ohne 'id' – übersprungen") continue source = cam_conf.get("source", 0) # String "0", "1" → int konvertieren if isinstance(source, str) and source.isdigit(): source = int(source) cam = ServerCamera( device_id = cam_id, source = source, fps = cam_conf.get("fps", 10), width = cam_conf.get("width", 1280), height = cam_conf.get("height", 720), jpeg_quality = cam_conf.get("quality", 75), name = cam_conf.get("name", cam_id) ) if cam.start(): self.cameras[cam_id] = cam logger.info(f"Kamera '{cam_id}' gestartet") else: logger.error(f"Kamera '{cam_id}' konnte nicht gestartet werden") def stop_all(self): for cam in self.cameras.values(): cam.stop() self.cameras.clear() def get_status(self) -> list: return [cam.get_status() for cam in self.cameras.values()] ===== 1.3 Engine: CameraManager einbinden ===== **Datei:** ''core/engine.py'' # In SystemEngine.__init__(): from core.camera_manager import CameraManager self.camera_manager = CameraManager() # In start(): self.camera_manager.load_config(self._load_raw_config()) # Hilfsmethode: def _load_raw_config(self) -> dict: import yaml with open(self.config_path, 'r') as f: return yaml.safe_load(f) or {} # In stop(): self.camera_manager.stop_all() ===== 1.4 REST Endpoint: Kamera-Status ===== **Datei:** ''web/camera.py'' – ergänzen @router.get("/server/status") async def get_server_cameras(request: Request): """Status aller Server-seitig konfigurierten Kameras.""" engine = request.app.state.engine if not engine or not hasattr(engine, 'camera_manager'): return [] return engine.camera_manager.get_status() ===== 1.5 USB-Kamera Discovery ===== **Datei:** ''web/inventory/cameras.py'' (NEU)\\ **Aufwand:** 0.5 Tage from fastapi import APIRouter import cv2 router = APIRouter() @router.get("/cameras/usb") def list_usb_cameras(): """ Scannt USB-Kamera-Indices 0-9. Gibt alle tatsächlich öffnenbaren Kameras zurück. Etwas langsam (~1s pro Index) aber unumgänglich ohne plattformspezifische APIs. """ found = [] for index in range(10): cap = cv2.VideoCapture(index) if cap.isOpened(): # Eigenschaften auslesen w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) fps = int(cap.get(cv2.CAP_PROP_FPS)) cap.release() found.append({ "index": index, "source": index, "resolution": f"{w}x{h}", "fps": fps, "suggested_id": f"usb_cam_{index}" }) else: cap.release() return found ---- ====== PHASE 2: Mobile Kamera ===== //(~1.5 Tage)// ===== 2.1 PWA: Kamera-Komponente ===== **Datei:** ''static/mobile/views/camera_sender.js'' (NEU) **Hinweis für KI:** * Zwei Modi: ''snapshot'' (POST alle N Sekunden) und ''stream'' (WebSocket Binary) * Standard: ''snapshot'' Modus, einfacher und batteriesparender * Rückkamera bevorzugen: ''facingMode: 'environment''' * Canvas zum JPEG-Encodieren im Browser verwenden * Qualitäts-Slider damit User Bandbreite vs. Qualität abwägen kann // static/mobile/views/camera_sender.js export default { setup() { const { ref, onUnmounted } = Vue; const isStreaming = ref(false); const mode = ref('snapshot'); // 'snapshot' | 'stream' const intervalSec = ref(2); const quality = ref(0.7); // JPEG Qualität 0-1 const deviceId = ref( 'mobile_' + Math.random().toString(36).slice(2, 8) ); const statusText = ref('Bereit'); const frameCount = ref(0); const lastFrameSize = ref(0); let videoEl = null; let canvasEl = null; let stream = null; let timer = null; let ws = null; const startCamera = async () => { try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } } }); videoEl = document.createElement('video'); videoEl.srcObject = stream; videoEl.muted = true; await videoEl.play(); canvasEl = document.createElement('canvas'); canvasEl.width = 1280; canvasEl.height = 720; statusText.value = 'Kamera aktiv'; isStreaming.value = true; if (mode.value === 'snapshot') { startSnapshotMode(); } else { await startStreamMode(); } } catch (e) { statusText.value = 'Fehler: ' + e.message; } }; const startSnapshotMode = () => { const ctx = canvasEl.getContext('2d'); timer = setInterval(async () => { ctx.drawImage(videoEl, 0, 0); const blob = await new Promise(resolve => canvasEl.toBlob(resolve, 'image/jpeg', quality.value) ); lastFrameSize.value = blob.size; const fd = new FormData(); fd.append('device_id', deviceId.value); fd.append('image', blob, 'frame.jpg'); try { await fetch('/api/camera/frame', { method: 'POST', body: fd }); frameCount.value++; } catch (e) { statusText.value = 'Upload Fehler: ' + e.message; } }, intervalSec.value * 1000); }; const startStreamMode = async () => { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket( `${protocol}//${location.host}/api/camera/stream/${deviceId.value}` ); ws.binaryType = 'arraybuffer'; ws.onopen = () => { statusText.value = 'Stream aktiv'; }; ws.onerror = () => { statusText.value = 'WS Fehler'; }; const ctx = canvasEl.getContext('2d'); // 10 FPS timer = setInterval(async () => { if (ws.readyState !== WebSocket.OPEN) return; ctx.drawImage(videoEl, 0, 0, 640, 480); // 640x480 für Stream – weniger Bandbreite const blob = await new Promise(resolve => canvasEl.toBlob(resolve, 'image/jpeg', quality.value) ); lastFrameSize.value = blob.size; const buffer = await blob.arrayBuffer(); ws.send(buffer); frameCount.value++; }, 100); }; const stopCamera = () => { if (timer) { clearInterval(timer); timer = null; } if (ws) { ws.close(); ws = null; } if (stream) { stream.getTracks().forEach(t => t.stop()); } isStreaming.value = false; statusText.value = 'Gestoppt'; }; onUnmounted(stopCamera); const formatBytes = (b) => { if (b < 1024) return b + ' B'; return (b / 1024).toFixed(1) + ' KB'; }; return { isStreaming, mode, intervalSec, quality, deviceId, statusText, frameCount, lastFrameSize, startCamera, stopCamera, formatBytes }; }, template: `
Geräte-ID (am Desktop sichtbar als):
Modus
Intervall: {{ intervalSec }}s
Qualität: {{ Math.round(quality * 100) }}%
{{ statusText }} {{ frameCount }} Frames ({{ formatBytes(lastFrameSize) }})
` };
---- ====== PHASE 3: Desktop Camera Widget ====== //(~1 Tag)// ===== 3.1 camera_view.js – Widget ===== **Datei:** ''static/views/camera_view.js'' (NEU) **Hinweis für KI:** * Einfachste mögliche Implementierung: '''' mit MJPEG-URL * Kein kompliziertes JS – Browser macht den Stream selbst * Snapshot-Modus als Fallback ('''' + ''setInterval'') * ''exportState()'' für Workspace-Persistenz import { globalStore } from '/static/js/store.js'; export default { props: ['deviceId', 'widgetState'], setup(props) { const { ref, computed, onMounted, onUnmounted } = Vue; const mode = ref(props.widgetState?.mode || 'mjpeg'); const isActive = ref(false); const snapshotSrc = ref(''); const frameAge = ref(null); let snapshotTimer = null; // Direkte MJPEG-URL – Browser streamt selbst const mjpegUrl = computed(() => `/api/camera/mjpeg/${props.deviceId}?t=${Date.now()}` ); const snapshotUrl = computed(() => `/api/camera/snapshot/${props.deviceId}` ); // Snapshot-Modus: alle 2s neu laden const startSnapshot = () => { const refresh = () => { snapshotSrc.value = snapshotUrl.value + '?t=' + Date.now(); }; refresh(); snapshotTimer = setInterval(refresh, 2000); }; onMounted(() => { if (mode.value === 'snapshot') startSnapshot(); }); onUnmounted(() => { if (snapshotTimer) clearInterval(snapshotTimer); }); const exportState = () => ({ mode: mode.value }); return { mode, isActive, snapshotSrc, mjpegUrl, snapshotUrl, frameAge, exportState }; }, template: `
📷 {{ deviceId }}
Kein Stream verfügbar
` };
===== 3.2 Widget-Wizard: Kamera-Eintrag ===== **Datei:** ''static/components/widget_wizard.js'' // In widgetCatalogue Array ergänzen: { id: 'camera_view', type: 'camera_view', title: 'Kamera-Stream', icon: 'camera', desc: 'MJPEG Live-Stream von USB-, IP- oder Handy-Kamera.', needs: 'camera' // Neuer needs-Typ → zeigt Kamera-Liste } **Hinweis für KI:** Der ''needs: 'camera''' Typ braucht einen neuen Zweig im ''confirmWizard()'' der ''GET /api/camera/list'' aufruft und eine Kamera-Auswahl anzeigt statt dem Entity-Picker. ---- ====== PHASE 4: Handy als Sensor-Device ====== //(~2 Tage – Rückkanal vom Handy ans Backend)// ===== 4.1 Backend: Bidirektionaler WebSocket ===== **Datei:** ''web/realtime.py'' **Hinweis für KI:** Den bestehenden WebSocket-Endpoint um einen ''receiver()'' Task erweitern. Der ''sender()'' Task bleibt identisch. Beide laufen via ''asyncio.gather()''. Vollständige Implementierung und ''MobileSensorSample'' sind in der PWA-Roadmap Phase 4.6 beschrieben. Neuer Sample-Typ: # structures/samples/mobile.py (NEU) from dataclasses import dataclass from typing import Any from structures.samples.base import BaseSample @dataclass(kw_only=True) class MobileSensorSample(BaseSample): type: str = "mobile_sensor" value: Any unit: str = "" accuracy: int = 2 ===== 4.2 Verfügbare Handy-Sensoren ===== ^ Sensor ^ Browser API ^ iOS ^ Android ^ Aufwand ^ | Batterie Level/Status | Battery Status API | ❌ | ✅ | 1h | | Beschleunigung X/Y/Z | DeviceMotion | ⚠️ HTTPS+Permission | ✅ | 2h | | Gyroskop | DeviceMotion | ⚠️ | ✅ | inkl. oben | | GPS Position | Geolocation | ✅ | ✅ | 2h | | GPS Speed/Heading | Geolocation | ✅ | ✅ | inkl. oben | | Lärmpegel dB | Web Audio API | ✅ | ✅ | 3h | | Netzwerk Latenz | Network Info API | ❌ | ✅ | 1h | | Bildschirm-Orientierung | Screen API | ✅ | ✅ | 0.5h | | Umgebungslicht | DeviceLight (experimentell) | ❌ | ⚠️ Chrome only | – | **Empfehlung für MVP:** - Batterie (einfachster Start) - Beschleunigung (coolster Wow-Effekt) - GPS (nützlichster für Feldmessung) ===== 4.3 MobileSensorDevice – Browser-Treiber ===== **Datei:** ''static/mobile/sensor_device.js'' (NEU) Vollständige Implementierung in der PWA-Roadmap, Abschnitt "Frage 1 & 2" – ''MobileSensorDevice'' Klasse. ===== 4.4 UI: Sensor-Aktivierung in der PWA ===== **Datei:** ''static/mobile/views/sensors.js'' (NEU) Dritter Tab in der Bottom-Navigation: * Toggle pro Sensor-Typ (Batterie, Bewegung, GPS, Mikrofon) * Sendeintervall konfigurierbar * Live-Vorschau der gesendeten Werte * Geräte-ID editierbar (wie der Handy am Desktop heißt) * Verbindungsstatus zum Backend ---- ====== PHASE 5: Erweiterte Kamera-Features ====== ===== 5.1 HTTP MJPEG Quellen (IP-Cams ohne RTSP) ===== **Aufwand:** 0.5 Tage Viele günstige IP-Kameras senden bereits MJPEG über HTTP. Diese kann man direkt als Quelle lesen: # devices/drivers/camera/http_mjpeg_camera.py (NEU) import urllib.request import threading import time class HttpMjpegCamera: """ Liest MJPEG-Stream von einer HTTP-URL. Beispiel: http://192.168.1.50/video.mjpeg Kein OpenCV nötig! """ def __init__(self, device_id, url, ...): ... def _parse_mjpeg_stream(self, response): """MJPEG-Boundary-Parser: extrahiert einzelne JPEG-Frames.""" boundary = None buffer = b"" # Boundary aus Content-Type Header extrahieren # Dann in einem Loop Frames extrahieren ... ===== 5.2 Bewegungserkennung (Motion Detection) ===== **Aufwand:** 1 Tag\\ **Usecase:** Alarm wenn Kamera Bewegung erkennt # In ServerCamera._capture_loop() optional: import cv2 class MotionDetector: def __init__(self, threshold=1000): self.prev_frame = None self.threshold = threshold def check(self, frame) -> bool: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (21, 21), 0) if self.prev_frame is None: self.prev_frame = gray return False delta = cv2.absdiff(self.prev_frame, gray) self.prev_frame = gray thresh = cv2.threshold(delta, 25, 255, cv2.THRESH_BINARY)[1] changed_pixels = cv2.countNonZero(thresh) return changed_pixels > self.threshold Bei erkannter Bewegung: SystemEvent publishen → Desktop-Alarm. ===== 5.3 Bild-Speicherung & Zeitraffer ===== **Aufwand:** 1 Tag # In web/camera.py: @router.post("/camera/{device_id}/record/start") async def start_recording(device_id: str, interval_sec: float = 1.0): """Startet Zeitraffer-Aufnahme: speichert alle N Sekunden ein Bild.""" ... @router.post("/camera/{device_id}/record/stop") async def stop_recording(device_id: str): """Stoppt Aufnahme und gibt Bildanzahl zurück.""" ... @router.get("/camera/{device_id}/timelapse") async def create_timelapse(device_id: str, fps: int = 10): """Erstellt MP4-Zeitraffer aus gespeicherten Bildern (benötigt ffmpeg).""" ... ===== 5.4 WebRTC (echter Low-Latency Video-Stream) ===== **Aufwand:** 3-5 Tage\\ **Wann:** Wenn MJPEG Latenz (1-3s) zu hoch ist WebRTC ermöglicht <200ms Latenz, ist aber deutlich komplexer. Empfohlene Library: ''aiortc'' (Python WebRTC) pip install aiortc Sinnvoll wenn: * Kamera-gestützte Live-Steuerung (Roboter, CNC) * Mehrere gleichzeitige Zuschauer (MJPEG skaliert schlecht) * Mobile Handy-Cam mit sehr niedriger Latenz ---- ====== API-Übersicht ====== ^ Endpoint ^ Methode ^ Beschreibung ^ | ''/api/camera/frame'' | POST | Frame von Mobile (Formdata) | | ''/api/camera/stream/{id}'' | WS | Frame-Stream von Mobile (Binary) | | ''/api/camera/snapshot/{id}'' | GET | Letztes Standbild | | ''/api/camera/mjpeg/{id}'' | GET | MJPEG Live-Stream | | ''/api/camera/list'' | GET | Alle Kamera-Quellen | | ''/api/camera/server/status'' | GET | Server-Kamera Status | | ''/api/inventory/cameras/usb'' | GET | USB-Kamera Discovery | | ''/api/camera/{id}'' | DELETE | Kamera entfernen | ---- ====== Gesamtübersicht Zeitplan Kamera ====== ^ Phase ^ Inhalt ^ Tage ^ Ergebnis ^ | 0 | Frame Store + MJPEG Backend | 1.5 | Fundament steht | | 1 | USB + RTSP Server-Cams | 2.0 | Kamera am Server läuft | | 2 | Mobile Kamera Sender | 1.5 | Handy sendet ins Backend | | 3 | Desktop Camera Widget | 1.0 | Kamera im Dashboard | | **MVP** | | **~6 Tage** | **Vollständig nutzbar** | | 4 | Handy als Sensor-Device | 2.0 | Handy-Sensoren im Store | | 5.1 | HTTP MJPEG IP-Cams | 0.5 | Günstige IP-Cams | | 5.2 | Bewegungserkennung | 1.0 | Alarm bei Bewegung | | 5.3 | Zeitraffer-Aufnahme | 1.0 | Langzeit-Dokumentation | | 5.4 | WebRTC | 4.0 | Ultra-Low-Latency | ---- ====== config.yaml Erweiterung ====== # config.yaml – Kamera-Sektion cameras: - id: "usb_cam_0" source: 0 # USB-Index fps: 10 width: 1280 height: 720 quality: 75 # JPEG Qualität 0-100 name: "Laborübersicht" enabled: true - id: "ip_cam_eingang" source: "rtsp://192.168.1.100:554/stream1" fps: 5 width: 1920 height: 1080 quality: 80 name: "Eingangskamera" enabled: true - id: "ip_cam_http" source: "http://192.168.1.101/video.mjpeg" fps: 10 name: "Decken-Cam" enabled: false ---- //Ende Kamera-Roadmap//\\ //Code-Beispiele sind Implementierungshinweise – kein fertiger Produktionscode//\\ //MJPEG-Streaming ist die empfohlene Einstiegstechnologie –// //WebRTC als optionale Erweiterung wenn Latenz kritisch wird//