From 093e0b9d1aff0cce90edd95b0dc18d18d9d1e7b7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 31 Oct 2026 21:08:26 +0330 Subject: [PATCH] add a monitoring camera setting for cetana --- .env.example | 8 + apps/core/settings_registry.py | 175 ++++++++++++++++++ apps/core/tests.py | 14 ++ apps/live_monitoring/admin.py | 38 ++++ .../0002_camera_protocol_and_stream_url.py | 36 ++++ apps/live_monitoring/models.py | 21 ++- apps/live_monitoring/protocols.py | 165 +++++++++++++++++ apps/live_monitoring/serializers.py | 12 +- apps/live_monitoring/services.py | 170 ++++++++++++++++- apps/live_monitoring/tests.py | 79 +++++++- apps/live_monitoring/views.py | 23 ++- 11 files changed, 732 insertions(+), 9 deletions(-) create mode 100644 apps/live_monitoring/admin.py create mode 100644 apps/live_monitoring/migrations/0002_camera_protocol_and_stream_url.py create mode 100644 apps/live_monitoring/protocols.py diff --git a/.env.example b/.env.example index 24479e3..53db31e 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,14 @@ PAYMENT_GATEWAY_MERCHANT_ID= SMS_PROVIDER=mock SMS_PROVIDER_API_KEY= +# ---- Live camera monitoring (overrides settings panel defaults) ---- +CAMERA_ENABLED=1 +CAMERA_DEFAULT_PROTOCOL=hls +CAMERA_STREAM_TTL_SECONDS=3600 +CAMERA_PREFER_SECURE=1 +CAMERA_MEDIA_GATEWAY_URL= +CAMERA_MEDIA_GATEWAY_PATH_TEMPLATE={camera_id}/index.m3u8 + # ---- Celery ---- CELERY_BROKER_URL=redis://localhost:6379/0 diff --git a/apps/core/settings_registry.py b/apps/core/settings_registry.py index af69080..95d7597 100644 --- a/apps/core/settings_registry.py +++ b/apps/core/settings_registry.py @@ -5,7 +5,180 @@ is rendered from this list, so adding a new integration/provider only requires adding entries here — no frontend or model changes. Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``. + +Optional keys: +- ``depends_on``: ``{"other.key": ["value_a", "value_b"]}`` — field is shown + only when every referenced setting currently holds one of the listed values. +- ``options``: list of strings or ``{"value", "label"}`` dicts (for ``choice``). """ +from apps.live_monitoring.protocols import ( + DEFAULT_PROTOCOL, + PROTOCOL_KEYS, + STREAM_PROTOCOLS, + protocol_choice_options, +) + + +def _camera_settings() -> list: + """Build Live Monitoring settings from the protocol catalog. + + Shared defaults always appear. Protocol-specific fields use ``depends_on`` + so the settings panel stays dynamic as standards are added to the catalog. + """ + entries = [ + { + "key": "camera.enabled", + "label": "Enable live camera monitoring", + "type": "boolean", + "category": "Live Monitoring", + "default": True, + }, + { + "key": "camera.default_protocol", + "label": "Default stream protocol", + "type": "choice", + "category": "Live Monitoring", + "options": protocol_choice_options(), + "default": DEFAULT_PROTOCOL, + }, + { + "key": "camera.stream_ttl_seconds", + "label": "Stream access token TTL (seconds)", + "type": "number", + "category": "Live Monitoring", + "default": 3600, + }, + { + "key": "camera.prefer_secure", + "label": "Prefer secure schemes (HTTPS / RTSPS / RTMPS)", + "type": "boolean", + "category": "Live Monitoring", + "default": True, + }, + { + "key": "camera.media_gateway_url", + "label": "Media gateway base URL", + "type": "string", + "category": "Live Monitoring", + "default": "", + "placeholder": "https://media.example.com", + }, + { + "key": "camera.media_gateway_path_template", + "label": "Gateway path template", + "type": "string", + "category": "Live Monitoring", + "default": "{camera_id}/index.m3u8", + "placeholder": "{camera_id}/index.m3u8", + }, + { + "key": "camera.default_username", + "label": "Default camera username", + "type": "string", + "category": "Live Monitoring", + "default": "", + }, + { + "key": "camera.default_password", + "label": "Default camera password", + "type": "secret", + "category": "Live Monitoring", + "default": "", + }, + { + "key": "camera.default_auth_method", + "label": "Default stream auth method", + "type": "choice", + "category": "Live Monitoring", + "options": [ + {"value": "none", "label": "None"}, + {"value": "basic", "label": "HTTP Basic"}, + {"value": "digest", "label": "HTTP Digest"}, + {"value": "bearer", "label": "Bearer token"}, + {"value": "query_token", "label": "Query token"}, + {"value": "ws_username_token", "label": "WS-UsernameToken (ONVIF)"}, + {"value": "passphrase", "label": "Passphrase (SRT)"}, + ], + "default": "none", + }, + ] + + # Protocol-specific defaults — visibility follows camera.default_protocol. + for key in PROTOCOL_KEYS: + meta = STREAM_PROTOCOLS[key] + dep = {"camera.default_protocol": [key]} + entries.append( + { + "key": f"camera.{key}.default_port", + "label": f"{meta['label']} — default port", + "type": "number", + "category": "Live Monitoring", + "default": meta["default_port"], + "depends_on": dep, + } + ) + entries.append( + { + "key": f"camera.{key}.default_path", + "label": f"{meta['label']} — default path", + "type": "string", + "category": "Live Monitoring", + "default": meta["default_path"], + "placeholder": meta["default_path"] or "/", + "depends_on": dep, + } + ) + if meta.get("transports"): + entries.append( + { + "key": f"camera.{key}.transport", + "label": f"{meta['label']} — transport", + "type": "choice", + "category": "Live Monitoring", + "options": list(meta["transports"]), + "default": meta["transports"][0], + "depends_on": dep, + } + ) + + # WebRTC needs ICE servers when selected. + entries.append( + { + "key": "camera.webrtc.ice_servers", + "label": "WebRTC ICE servers (JSON)", + "type": "string", + "category": "Live Monitoring", + "default": '[{"urls":"stun:stun.l.google.com:19302"}]', + "placeholder": '[{"urls":"stun:stun.l.google.com:19302"}]', + "depends_on": {"camera.default_protocol": ["webrtc"]}, + } + ) + # ONVIF profile token when ONVIF is the default. + entries.append( + { + "key": "camera.onvif.profile_token", + "label": "ONVIF media profile token", + "type": "string", + "category": "Live Monitoring", + "default": "", + "placeholder": "Profile_1", + "depends_on": {"camera.default_protocol": ["onvif"]}, + } + ) + # SRT passphrase (secret) when SRT is selected. + entries.append( + { + "key": "camera.srt.passphrase", + "label": "SRT passphrase", + "type": "secret", + "category": "Live Monitoring", + "default": "", + "depends_on": {"camera.default_protocol": ["srt"]}, + } + ) + return entries + + SETTINGS_SCHEMA = [ # --- Odoo integration --- {"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False}, @@ -25,6 +198,8 @@ SETTINGS_SCHEMA = [ {"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"}, {"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30}, {"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2}, + # --- Live camera monitoring (protocol catalog driven) --- + *_camera_settings(), ] SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA} diff --git a/apps/core/tests.py b/apps/core/tests.py index b2cb11b..d3a2d95 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -12,6 +12,8 @@ class SettingsServiceTests(TestCase): service = SettingsService() self.assertEqual(service.get("sms.provider"), "mock") self.assertIs(service.get("odoo.enabled"), False) + self.assertEqual(service.get("camera.default_protocol"), "hls") + self.assertIs(service.get("camera.enabled"), True) def test_set_and_get(self): service = SettingsService() @@ -24,6 +26,18 @@ class SettingsServiceTests(TestCase): values = service.all_values() self.assertEqual(values["odoo.api_key"], "••••••") + def test_camera_protocol_settings_are_in_schema(self): + from apps.core.settings_registry import SETTINGS_SCHEMA + + keys = {e["key"] for e in SETTINGS_SCHEMA} + self.assertIn("camera.default_protocol", keys) + self.assertIn("camera.rtsp.transport", keys) + self.assertIn("camera.webrtc.ice_servers", keys) + proto = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.default_protocol") + self.assertTrue(any(isinstance(o, dict) and o.get("value") == "rtsp" for o in proto["options"])) + rtsp_transport = next(e for e in SETTINGS_SCHEMA if e["key"] == "camera.rtsp.transport") + self.assertEqual(rtsp_transport["depends_on"], {"camera.default_protocol": ["rtsp"]}) + class JalaliTests(TestCase): """The PDF and the customer SMS are the only places dates are rendered for diff --git a/apps/live_monitoring/admin.py b/apps/live_monitoring/admin.py new file mode 100644 index 0000000..1fa4be9 --- /dev/null +++ b/apps/live_monitoring/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from apps.live_monitoring.models import Camera, LiveStream, WorkflowStepCamera + + +@admin.register(Camera) +class CameraAdmin(admin.ModelAdmin): + list_display = ("name", "facility", "processing_line", "protocol", "active", "updated_at") + list_filter = ("active", "protocol", "facility") + search_fields = ("name", "facility", "processing_line", "stream_url") + readonly_fields = ("id", "created_at", "updated_at") + fieldsets = ( + (None, {"fields": ("name", "facility", "processing_line", "active")}), + ( + "Stream", + { + "fields": ("protocol", "stream_url", "auth_config"), + "description": ( + "Protocol blank = use Live Monitoring settings default. " + "auth_config stays server-side (username, password, token, host, port, path…)." + ), + }, + ), + ("Meta", {"fields": ("id", "created_at", "updated_at")}), + ) + + +@admin.register(LiveStream) +class LiveStreamAdmin(admin.ModelAdmin): + list_display = ("name", "camera", "active", "updated_at") + list_filter = ("active",) + + +@admin.register(WorkflowStepCamera) +class WorkflowStepCameraAdmin(admin.ModelAdmin): + list_display = ("step", "camera", "id") + list_filter = ("camera",) + search_fields = ("camera__name",) diff --git a/apps/live_monitoring/migrations/0002_camera_protocol_and_stream_url.py b/apps/live_monitoring/migrations/0002_camera_protocol_and_stream_url.py new file mode 100644 index 0000000..06599bf --- /dev/null +++ b/apps/live_monitoring/migrations/0002_camera_protocol_and_stream_url.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("live_monitoring", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="camera", + name="protocol", + field=models.CharField( + blank=True, + choices=[ + ("rtsp", "RTSP (Real Time Streaming Protocol)"), + ("rtmp", "RTMP (Real-Time Messaging Protocol)"), + ("hls", "HLS (HTTP Live Streaming)"), + ("dash", "MPEG-DASH"), + ("webrtc", "WebRTC"), + ("mjpeg", "MJPEG over HTTP"), + ("http", "HTTP Progressive (MP4/WebM)"), + ("onvif", "ONVIF Media Profile"), + ("srt", "SRT (Secure Reliable Transport)"), + ], + default="", + help_text="Streaming standard. Leave blank to use the system default protocol.", + max_length=32, + ), + ), + migrations.AlterField( + model_name="camera", + name="stream_url", + field=models.CharField(max_length=1000), + ), + ] diff --git a/apps/live_monitoring/models.py b/apps/live_monitoring/models.py index 37497d2..937474b 100644 --- a/apps/live_monitoring/models.py +++ b/apps/live_monitoring/models.py @@ -1,20 +1,39 @@ from django.db import models from apps.core.models import TimeStampedModel, UUIDModel +from apps.live_monitoring.protocols import DEFAULT_PROTOCOL, PROTOCOL_CHOICES class Camera(UUIDModel, TimeStampedModel): name = models.CharField(max_length=200) facility = models.CharField(max_length=200, blank=True) processing_line = models.CharField(max_length=200, blank=True) - stream_url = models.URLField() + # Blank = inherit camera.default_protocol from Live Monitoring settings. + protocol = models.CharField( + max_length=32, + choices=PROTOCOL_CHOICES, + blank=True, + default="", + help_text="Streaming standard. Leave blank to use the system default protocol.", + ) + # CharField (not URLField) so RTSP/RTMP/SRT URLs are accepted. + stream_url = models.CharField(max_length=1000) # Never expose credentials to the frontend; keep auth config server-side. + # Expected keys: username, password, token, auth_method, host, port, path, transport… auth_config = models.JSONField(default=dict, blank=True) active = models.BooleanField(default=True) def __str__(self) -> str: return self.name + def resolved_protocol(self) -> str: + if self.protocol: + return self.protocol + from apps.core.settings_service import SettingsService + + value = SettingsService().get("camera.default_protocol", DEFAULT_PROTOCOL) + return value if value in dict(PROTOCOL_CHOICES) else DEFAULT_PROTOCOL + class LiveStream(UUIDModel, TimeStampedModel): camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams") diff --git a/apps/live_monitoring/protocols.py b/apps/live_monitoring/protocols.py new file mode 100644 index 0000000..40aca52 --- /dev/null +++ b/apps/live_monitoring/protocols.py @@ -0,0 +1,165 @@ +"""Standard IP-camera / live-stream protocol catalog. + +This is the single source of truth for which streaming standards Cetana +supports. Settings schema entries and Camera.protocol choices are derived +from this registry, so adding a new standard only requires one entry here. +""" + +from __future__ import annotations + +from typing import Any + + +# Each protocol describes how streams are addressed and whether a browser can +# play them natively (without a media gateway / WebRTC stack). +STREAM_PROTOCOLS: dict[str, dict[str, Any]] = { + "rtsp": { + "label": "RTSP (Real Time Streaming Protocol)", + "schemes": ("rtsp", "rtsps"), + "default_port": 554, + "default_path": "stream1", + "browser_native": False, + "mime_type": "", + "transports": ("tcp", "udp"), + "auth_methods": ("none", "basic", "digest"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Most IP cameras. Needs a media gateway (e.g. MediaMTX) for browser playback.", + }, + "rtmp": { + "label": "RTMP (Real-Time Messaging Protocol)", + "schemes": ("rtmp", "rtmps"), + "default_port": 1935, + "default_path": "live/stream", + "browser_native": False, + "mime_type": "", + "transports": (), + "auth_methods": ("none", "basic"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Common for encoders/NVR publish. Needs HLS/WebRTC remux for browsers.", + }, + "hls": { + "label": "HLS (HTTP Live Streaming)", + "schemes": ("https", "http"), + "default_port": 443, + "default_path": "live/index.m3u8", + "browser_native": True, + "mime_type": "application/vnd.apple.mpegurl", + "transports": (), + "auth_methods": ("none", "basic", "bearer", "query_token"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Browser-friendly (.m3u8). Preferred default for web monitoring.", + }, + "dash": { + "label": "MPEG-DASH", + "schemes": ("https", "http"), + "default_port": 443, + "default_path": "live/manifest.mpd", + "browser_native": True, + "mime_type": "application/dash+xml", + "transports": (), + "auth_methods": ("none", "basic", "bearer", "query_token"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Adaptive HTTP streaming via MPD manifest.", + }, + "webrtc": { + "label": "WebRTC", + "schemes": ("https", "http", "wss", "ws"), + "default_port": 443, + "default_path": "webrtc/offer", + "browser_native": True, + "mime_type": "", + "transports": ("udp", "tcp"), + "auth_methods": ("none", "bearer", "query_token"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Low-latency browser playback via a signaling endpoint.", + }, + "mjpeg": { + "label": "MJPEG over HTTP", + "schemes": ("https", "http"), + "default_port": 80, + "default_path": "video.mjpg", + "browser_native": True, + "mime_type": "multipart/x-mixed-replace", + "transports": (), + "auth_methods": ("none", "basic", "digest"), + "url_template": "{scheme}://{host}:{port}/{path}", + "notes": "Simple motion-JPEG snapshot stream; plays in or