feat(v1): structured camera config and RTSP url builder
Config accepts structured host/port/channel/username/password (or the existing rtsp_url_env) and builds the RTSP URL with RFC 3986 percent- encoded credentials, so passwords containing @/:/ no longer break parsing. Credentials are excluded from config_version; a guard test keeps the public example credential-free. probe_stream does a bounded connection test. 59 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+46
-7
@@ -7,12 +7,38 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when a configuration file cannot safely start V1."""
|
||||
|
||||
|
||||
# Hikvision RTSP path: channel "101" = channel 1 main stream, "102" = sub stream.
|
||||
_HIK_PATH = "/Streaming/Channels/{0}"
|
||||
|
||||
|
||||
def build_rtsp_url(
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
channel: str = "101",
|
||||
path_template: str = _HIK_PATH,
|
||||
) -> str:
|
||||
"""Build an RTSP URL, percent-encoding credentials per RFC 3986.
|
||||
|
||||
Encoding the username and password means special characters such as ``@``,
|
||||
``:`` and ``/`` in a password no longer break URL parsing. The returned URL
|
||||
holds credentials and must never be logged or persisted.
|
||||
"""
|
||||
|
||||
user = quote(str(username), safe="")
|
||||
secret = quote(str(password), safe="")
|
||||
path = path_template.format(quote(str(channel), safe=""))
|
||||
return "rtsp://{0}:{1}@{2}:{3}{4}".format(user, secret, host, int(port), path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventConfig:
|
||||
keypoint_confidence_threshold: float
|
||||
@@ -106,13 +132,26 @@ def load_config(path: Path) -> AppConfig:
|
||||
root = _mapping(raw, "config")
|
||||
source = _mapping(root.get("source"), "source")
|
||||
if "url" in source or "rtsp_url" in source:
|
||||
raise ConfigError("source must define rtsp_url_env, not an embedded address")
|
||||
environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env")
|
||||
if not _ENVIRONMENT_NAME.match(environment_name):
|
||||
raise ConfigError("source.rtsp_url_env must be an environment variable name")
|
||||
source_url = os.environ.get(environment_name)
|
||||
if not source_url:
|
||||
raise ConfigError("missing RTSP environment variable: {0}".format(environment_name))
|
||||
raise ConfigError("source must define structured host fields or rtsp_url_env, not a full URL")
|
||||
|
||||
if "host" in source:
|
||||
host = _text(source.get("host"), "source.host")
|
||||
port = int(_number(source.get("port", 554), "source.port", 1.0, 65535.0))
|
||||
channel = str(source.get("channel", "101")).strip()
|
||||
if not channel:
|
||||
raise ConfigError("source.channel must be a non-empty value")
|
||||
username = _text(source.get("username"), "source.username")
|
||||
password = source.get("password")
|
||||
if not isinstance(password, str) or password == "":
|
||||
raise ConfigError("source.password must be a non-empty string")
|
||||
source_url = build_rtsp_url(host, port, username, password, channel)
|
||||
else:
|
||||
environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env")
|
||||
if not _ENVIRONMENT_NAME.match(environment_name):
|
||||
raise ConfigError("source.rtsp_url_env must be an environment variable name")
|
||||
source_url = os.environ.get(environment_name)
|
||||
if not source_url:
|
||||
raise ConfigError("missing RTSP environment variable: {0}".format(environment_name))
|
||||
|
||||
source_mode = source.get("mode", "stream")
|
||||
if source_mode not in ("replay", "stream"):
|
||||
|
||||
Reference in New Issue
Block a user