add a monitoring camera setting for cetana
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",)
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <img> or <video> depending on camera.",
|
||||
},
|
||||
"http": {
|
||||
"label": "HTTP Progressive (MP4/WebM)",
|
||||
"schemes": ("https", "http"),
|
||||
"default_port": 443,
|
||||
"default_path": "live.mp4",
|
||||
"browser_native": True,
|
||||
"mime_type": "video/mp4",
|
||||
"transports": (),
|
||||
"auth_methods": ("none", "basic", "bearer", "query_token"),
|
||||
"url_template": "{scheme}://{host}:{port}/{path}",
|
||||
"notes": "Progressive download / pseudo-live file URL.",
|
||||
},
|
||||
"onvif": {
|
||||
"label": "ONVIF Media Profile",
|
||||
"schemes": ("http", "https"),
|
||||
"default_port": 80,
|
||||
"default_path": "onvif/device_service",
|
||||
"browser_native": False,
|
||||
"mime_type": "",
|
||||
"transports": (),
|
||||
"auth_methods": ("none", "digest", "ws_username_token"),
|
||||
"url_template": "{scheme}://{host}:{port}/{path}",
|
||||
"notes": "Device discovery/control. Stream URI is usually resolved to RTSP by the NVR.",
|
||||
},
|
||||
"srt": {
|
||||
"label": "SRT (Secure Reliable Transport)",
|
||||
"schemes": ("srt",),
|
||||
"default_port": 9000,
|
||||
"default_path": "",
|
||||
"browser_native": False,
|
||||
"mime_type": "",
|
||||
"transports": ("udp",),
|
||||
"auth_methods": ("none", "passphrase"),
|
||||
"url_template": "{scheme}://{host}:{port}",
|
||||
"notes": "Low-latency contribution protocol. Needs a gateway for browsers.",
|
||||
},
|
||||
}
|
||||
|
||||
PROTOCOL_CHOICES = [(key, meta["label"]) for key, meta in STREAM_PROTOCOLS.items()]
|
||||
PROTOCOL_KEYS = tuple(STREAM_PROTOCOLS.keys())
|
||||
DEFAULT_PROTOCOL = "hls"
|
||||
|
||||
|
||||
def get_protocol(key: str | None) -> dict[str, Any]:
|
||||
if key and key in STREAM_PROTOCOLS:
|
||||
return STREAM_PROTOCOLS[key]
|
||||
return STREAM_PROTOCOLS[DEFAULT_PROTOCOL]
|
||||
|
||||
|
||||
def protocol_choice_options() -> list[dict[str, str]]:
|
||||
"""Settings-panel friendly {value, label} options."""
|
||||
return [{"value": key, "label": meta["label"]} for key, meta in STREAM_PROTOCOLS.items()]
|
||||
|
||||
|
||||
def detect_protocol_from_url(url: str) -> str | None:
|
||||
"""Best-effort guess from URL scheme / path."""
|
||||
lower = (url or "").strip().lower()
|
||||
if not lower:
|
||||
return None
|
||||
if lower.startswith("rtsps://") or lower.startswith("rtsp://"):
|
||||
return "rtsp"
|
||||
if lower.startswith("rtmps://") or lower.startswith("rtmp://"):
|
||||
return "rtmp"
|
||||
if lower.startswith("srt://"):
|
||||
return "srt"
|
||||
if ".m3u8" in lower:
|
||||
return "hls"
|
||||
if ".mpd" in lower:
|
||||
return "dash"
|
||||
if any(token in lower for token in (".mjpg", ".mjpeg", "multipart")):
|
||||
return "mjpeg"
|
||||
if "webrtc" in lower or lower.startswith("wss://") or lower.startswith("ws://"):
|
||||
return "webrtc"
|
||||
if "onvif" in lower:
|
||||
return "onvif"
|
||||
if lower.startswith("http://") or lower.startswith("https://"):
|
||||
return "http"
|
||||
return None
|
||||
@@ -4,7 +4,15 @@ from apps.live_monitoring.models import Camera
|
||||
|
||||
|
||||
class CameraSerializer(serializers.ModelSerializer):
|
||||
# auth_config is never exposed to the frontend.
|
||||
"""Public camera card. Stream URLs and credentials are never listed here —
|
||||
clients must go through the tokenized ``access`` / ``verify`` flow.
|
||||
"""
|
||||
|
||||
protocol = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Camera
|
||||
fields = ["id", "name", "facility", "processing_line", "stream_url", "active"]
|
||||
fields = ["id", "name", "facility", "processing_line", "protocol", "active"]
|
||||
|
||||
def get_protocol(self, obj: Camera) -> str:
|
||||
return obj.resolved_protocol()
|
||||
|
||||
@@ -2,17 +2,29 @@
|
||||
|
||||
Camera credentials are never exposed. A customer can request short-lived,
|
||||
HMAC-signed access tokens for streams authorized for their own orders; the
|
||||
`verify` endpoint validates the token and returns the stream URL.
|
||||
`verify` endpoint validates the token and returns a protocol-aware playable
|
||||
stream descriptor (URL rewritten through a media gateway when needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.live_monitoring.models import Camera, WorkflowStepCamera
|
||||
from apps.live_monitoring.protocols import (
|
||||
DEFAULT_PROTOCOL,
|
||||
STREAM_PROTOCOLS,
|
||||
detect_protocol_from_url,
|
||||
get_protocol,
|
||||
)
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.workflows.models import WorkflowStep
|
||||
|
||||
@@ -31,8 +43,17 @@ class StreamAccessService:
|
||||
).values_list("id", flat=True)
|
||||
return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists()
|
||||
|
||||
def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple:
|
||||
payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl}
|
||||
def _ttl(self, ttl: int | None) -> int:
|
||||
if ttl is not None:
|
||||
return int(ttl)
|
||||
try:
|
||||
return int(SettingsService().get("camera.stream_ttl_seconds", 3600))
|
||||
except (TypeError, ValueError):
|
||||
return 3600
|
||||
|
||||
def issue_token(self, camera: Camera, ttl: int | None = None) -> tuple:
|
||||
resolved_ttl = self._ttl(ttl)
|
||||
payload = {"camera": str(camera.id), "exp": int(time.time()) + resolved_ttl}
|
||||
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
||||
sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{raw}.{sig}", payload["exp"]
|
||||
@@ -53,3 +74,146 @@ class StreamAccessService:
|
||||
if payload.get("exp", 0) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
|
||||
def resolve_stream(self, camera: Camera) -> dict[str, Any]:
|
||||
"""Build a client-safe stream descriptor for an authorized camera.
|
||||
|
||||
Credentials from ``auth_config`` / global settings are applied
|
||||
server-side only. When the protocol is not browser-native and a media
|
||||
gateway is configured, the URL is rewritten to the gateway playlist.
|
||||
"""
|
||||
service = SettingsService()
|
||||
if not service.get_bool("camera.enabled", True):
|
||||
return {
|
||||
"protocol": camera.resolved_protocol(),
|
||||
"stream_url": None,
|
||||
"browser_native": False,
|
||||
"mime_type": "",
|
||||
"player": "none",
|
||||
"error": "Live monitoring is disabled in settings.",
|
||||
}
|
||||
|
||||
protocol_key = camera.protocol or detect_protocol_from_url(camera.stream_url) or camera.resolved_protocol()
|
||||
if protocol_key not in STREAM_PROTOCOLS:
|
||||
protocol_key = DEFAULT_PROTOCOL
|
||||
meta = get_protocol(protocol_key)
|
||||
|
||||
playable_url = self._build_playable_url(camera, protocol_key, service)
|
||||
browser_native = bool(meta.get("browser_native"))
|
||||
|
||||
# Non-browser protocols need a gateway remux (typically to HLS).
|
||||
if not browser_native:
|
||||
gateway = (service.get("camera.media_gateway_url", "") or "").rstrip("/")
|
||||
if gateway:
|
||||
template = service.get("camera.media_gateway_path_template", "{camera_id}/index.m3u8") or "{camera_id}/index.m3u8"
|
||||
path = template.format(camera_id=str(camera.id), protocol=protocol_key)
|
||||
playable_url = f"{gateway}/{path.lstrip('/')}"
|
||||
protocol_key = "hls"
|
||||
meta = get_protocol("hls")
|
||||
browser_native = True
|
||||
else:
|
||||
return {
|
||||
"protocol": protocol_key,
|
||||
"stream_url": None,
|
||||
"browser_native": False,
|
||||
"mime_type": meta.get("mime_type", ""),
|
||||
"player": "none",
|
||||
"transport": self._transport(protocol_key, camera, service),
|
||||
"error": (
|
||||
f"{meta['label']} is not playable in the browser. "
|
||||
"Configure a media gateway URL in Live Monitoring settings."
|
||||
),
|
||||
}
|
||||
|
||||
player = self._player_hint(protocol_key)
|
||||
result: dict[str, Any] = {
|
||||
"protocol": protocol_key,
|
||||
"stream_url": playable_url,
|
||||
"browser_native": browser_native,
|
||||
"mime_type": meta.get("mime_type", ""),
|
||||
"player": player,
|
||||
"transport": self._transport(protocol_key, camera, service),
|
||||
}
|
||||
if protocol_key == "webrtc":
|
||||
ice = service.get("camera.webrtc.ice_servers", "")
|
||||
if ice:
|
||||
result["ice_servers"] = ice
|
||||
return result
|
||||
|
||||
def _transport(self, protocol_key: str, camera: Camera, service: SettingsService) -> str | None:
|
||||
auth = camera.auth_config or {}
|
||||
if auth.get("transport"):
|
||||
return auth["transport"]
|
||||
value = service.get(f"camera.{protocol_key}.transport")
|
||||
return value or None
|
||||
|
||||
def _player_hint(self, protocol_key: str) -> str:
|
||||
return {
|
||||
"hls": "hls",
|
||||
"dash": "dash",
|
||||
"webrtc": "webrtc",
|
||||
"mjpeg": "mjpeg",
|
||||
"http": "video",
|
||||
}.get(protocol_key, "none")
|
||||
|
||||
def _build_playable_url(self, camera: Camera, protocol_key: str, service: SettingsService) -> str:
|
||||
"""Return a URL safe to hand to the client (no embedded passwords)."""
|
||||
raw = (camera.stream_url or "").strip()
|
||||
auth = dict(camera.auth_config or {})
|
||||
|
||||
# Absolute URL already provided — strip userinfo credentials.
|
||||
if "://" in raw:
|
||||
return self._strip_userinfo(raw)
|
||||
|
||||
# Relative / host-based config: assemble from protocol defaults + auth_config.
|
||||
meta = get_protocol(protocol_key)
|
||||
prefer_secure = service.get_bool("camera.prefer_secure", True)
|
||||
schemes = meta["schemes"]
|
||||
scheme = schemes[0]
|
||||
if prefer_secure:
|
||||
for candidate in schemes:
|
||||
if candidate.endswith("s") or candidate in {"https", "wss", "rtsps", "rtmps"}:
|
||||
scheme = candidate
|
||||
break
|
||||
|
||||
host = auth.get("host") or raw
|
||||
if not host:
|
||||
return raw
|
||||
|
||||
port = auth.get("port")
|
||||
if port in (None, ""):
|
||||
port = service.get(f"camera.{protocol_key}.default_port", meta["default_port"])
|
||||
path = (auth.get("path") or service.get(f"camera.{protocol_key}.default_path", meta["default_path"]) or "").lstrip("/")
|
||||
|
||||
netloc = f"{host}:{port}" if port not in (None, "") else host
|
||||
url = urlunsplit((scheme, netloc, path, "", ""))
|
||||
|
||||
# Query-token auth can be appended without embedding secrets in userinfo.
|
||||
auth_method = (
|
||||
auth.get("auth_method")
|
||||
or service.get("camera.default_auth_method", "none")
|
||||
or "none"
|
||||
)
|
||||
token = auth.get("token") or auth.get("password")
|
||||
if auth_method == "query_token" and token:
|
||||
sep = "&" if "?" in url else "?"
|
||||
url = f"{url}{sep}{urlencode({'token': token})}"
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def _strip_userinfo(url: str) -> str:
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return url
|
||||
if not parts.username and not parts.password:
|
||||
return url
|
||||
host = parts.hostname or ""
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
# Bracket IPv6
|
||||
if parts.hostname and ":" in parts.hostname and not host.startswith("["):
|
||||
host = f"[{parts.hostname}]"
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.protocols import PROTOCOL_KEYS, detect_protocol_from_url
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
|
||||
|
||||
class StreamAccessTests(TestCase):
|
||||
def setUp(self):
|
||||
self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8")
|
||||
self.camera = Camera.objects.create(
|
||||
name="Cam1",
|
||||
protocol="hls",
|
||||
stream_url="https://example.com/live.m3u8",
|
||||
)
|
||||
self.service = StreamAccessService()
|
||||
|
||||
def test_issue_and_verify_token(self):
|
||||
@@ -24,3 +30,74 @@ class StreamAccessTests(TestCase):
|
||||
def test_expired_token_rejected(self):
|
||||
token, _ = self.service.issue_token(self.camera, ttl=-1)
|
||||
self.assertIsNone(self.service.verify_token(token))
|
||||
|
||||
def test_ttl_comes_from_settings(self):
|
||||
SettingsService().set("camera.stream_ttl_seconds", 120)
|
||||
token, exp = self.service.issue_token(self.camera)
|
||||
payload = self.service.verify_token(token)
|
||||
self.assertAlmostEqual(payload["exp"], exp, delta=1)
|
||||
# Token should expire roughly 120s from now (not the old 3600 default).
|
||||
import time
|
||||
|
||||
self.assertLess(payload["exp"] - int(time.time()), 130)
|
||||
|
||||
def test_resolve_hls_stream(self):
|
||||
result = self.service.resolve_stream(self.camera)
|
||||
self.assertEqual(result["protocol"], "hls")
|
||||
self.assertEqual(result["stream_url"], "https://example.com/live.m3u8")
|
||||
self.assertTrue(result["browser_native"])
|
||||
self.assertEqual(result["player"], "hls")
|
||||
|
||||
def test_rtsp_requires_gateway_or_errors(self):
|
||||
cam = Camera.objects.create(
|
||||
name="RTSP Cam",
|
||||
protocol="rtsp",
|
||||
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
|
||||
)
|
||||
result = self.service.resolve_stream(cam)
|
||||
self.assertIsNone(result["stream_url"])
|
||||
self.assertIn("media gateway", result["error"].lower())
|
||||
|
||||
def test_rtsp_rewrites_through_gateway(self):
|
||||
SettingsService().set("camera.media_gateway_url", "https://media.example.com")
|
||||
cam = Camera.objects.create(
|
||||
name="RTSP Cam",
|
||||
protocol="rtsp",
|
||||
stream_url="rtsp://user:secret@192.168.1.10:554/stream1",
|
||||
)
|
||||
result = self.service.resolve_stream(cam)
|
||||
self.assertEqual(result["protocol"], "hls")
|
||||
self.assertEqual(result["stream_url"], f"https://media.example.com/{cam.id}/index.m3u8")
|
||||
self.assertNotIn("secret", result["stream_url"])
|
||||
|
||||
def test_strips_embedded_credentials(self):
|
||||
cam = Camera.objects.create(
|
||||
name="Cred Cam",
|
||||
protocol="http",
|
||||
stream_url="https://user:pass@cdn.example.com/live.mp4",
|
||||
)
|
||||
result = self.service.resolve_stream(cam)
|
||||
self.assertEqual(result["stream_url"], "https://cdn.example.com/live.mp4")
|
||||
|
||||
def test_inherits_default_protocol_from_settings(self):
|
||||
SettingsService().set("camera.default_protocol", "mjpeg")
|
||||
cam = Camera.objects.create(name="Inherit", stream_url="https://cam/video.mjpg")
|
||||
self.assertEqual(cam.resolved_protocol(), "mjpeg")
|
||||
|
||||
def test_monitoring_disabled(self):
|
||||
SettingsService().set("camera.enabled", False)
|
||||
result = self.service.resolve_stream(self.camera)
|
||||
self.assertIsNone(result["stream_url"])
|
||||
self.assertIn("disabled", result["error"].lower())
|
||||
|
||||
|
||||
class ProtocolCatalogTests(TestCase):
|
||||
def test_all_standard_protocols_registered(self):
|
||||
for key in ("rtsp", "rtmp", "hls", "dash", "webrtc", "mjpeg", "http", "onvif", "srt"):
|
||||
self.assertIn(key, PROTOCOL_KEYS)
|
||||
|
||||
def test_detect_from_url(self):
|
||||
self.assertEqual(detect_protocol_from_url("rtsp://x/y"), "rtsp")
|
||||
self.assertEqual(detect_protocol_from_url("https://x/a.m3u8"), "hls")
|
||||
self.assertEqual(detect_protocol_from_url("https://x/a.mpd"), "dash")
|
||||
self.assertEqual(detect_protocol_from_url("srt://1.2.3.4:9000"), "srt")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import decorators, mixins, permissions, response, status, views, viewsets
|
||||
|
||||
from apps.core.errors import error_response
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.serializers import CameraSerializer
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
@@ -13,8 +14,18 @@ class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.G
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"}
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if not SettingsService().get_bool("camera.enabled", True):
|
||||
return response.Response({"count": 0, "next": None, "previous": None, "results": []})
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def access(self, request, pk=None):
|
||||
if not SettingsService().get_bool("camera.enabled", True):
|
||||
return response.Response(
|
||||
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
camera = self.get_object()
|
||||
service = StreamAccessService()
|
||||
if not service.can_access(request.user, camera):
|
||||
@@ -31,8 +42,14 @@ class VerifyStreamView(views.APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
if not SettingsService().get_bool("camera.enabled", True):
|
||||
return response.Response(
|
||||
error_response("MONITORING_DISABLED", "Live monitoring is disabled."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
token = request.query_params.get("token")
|
||||
payload = StreamAccessService().verify_token(token)
|
||||
service = StreamAccessService()
|
||||
payload = service.verify_token(token)
|
||||
if not payload:
|
||||
return response.Response(
|
||||
error_response("INVALID_TOKEN", "Invalid or expired stream token."),
|
||||
@@ -45,4 +62,6 @@ class VerifyStreamView(views.APIView):
|
||||
error_response("CAMERA_NOT_FOUND", "Camera not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return response.Response({"stream_url": camera.stream_url})
|
||||
stream = service.resolve_stream(camera)
|
||||
# Keep ``url`` alias for older clients; ``stream_url`` is canonical.
|
||||
return response.Response({**stream, "url": stream.get("stream_url")})
|
||||
|
||||
Reference in New Issue
Block a user