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