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:
ila
2026-07-21 22:38:21 +08:00
co-authored by Claude Opus 4.8
parent 59925b8e30
commit ab574f1a27
5 changed files with 217 additions and 8 deletions
+57
View File
@@ -0,0 +1,57 @@
"""RTSP URL building and a bounded connection probe for camera setup.
Kept Qt-free so URL construction (including credential percent-encoding) and the
connection test are unit-testable without a display or a real camera. Credentials
are only ever held in memory here; callers must not log or persist the built URL.
"""
from dataclasses import dataclass
from typing import Optional
import numpy as np
from v1.config import build_rtsp_url # re-exported for the settings UI
from v1.video_source import SourceMode, SourceStatus, VideoSource
__all__ = ["build_rtsp_url", "ProbeResult", "probe_stream"]
@dataclass(frozen=True)
class ProbeResult:
ok: bool
message: str
frame: Optional[np.ndarray]
def probe_stream(
url: str,
attempts: int = 15,
capture_factory=None,
) -> ProbeResult:
"""Try to open the stream and grab one frame; never raise on a bad source.
Returns a decoded frame on success so the caller can show a preview. The
message is safe to display: it never contains the URL or credentials.
"""
source = VideoSource(
url,
SourceMode.STREAM,
retry_initial_seconds=0.1,
retry_max_seconds=0.2,
capture_factory=capture_factory,
)
last_error = None
try:
for _ in range(max(1, attempts)):
packet = source.read()
if packet.image is not None:
height, width = packet.image.shape[:2]
return ProbeResult(True, "连接成功 {0}x{1}".format(width, height), packet.image)
if packet.error:
last_error = packet.error
if packet.status is SourceStatus.ERROR:
return ProbeResult(False, "连接失败:{0}".format(last_error or "无法打开来源"), None)
return ProbeResult(False, "连接超时,未获取到画面", None)
finally:
source.close()
+46 -7
View File
@@ -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"):
+49
View File
@@ -0,0 +1,49 @@
import numpy as np
from v1.camera import build_rtsp_url, probe_stream
class _FakeCapture:
def __init__(self, frames):
self._frames = list(frames) if frames is not None else None
def isOpened(self):
return self._frames is not None
def read(self):
if self._frames:
return True, self._frames.pop(0)
return False, None
def release(self):
pass
def test_build_rtsp_url_percent_encodes_special_characters_in_password():
# Example TEST-NET address and a synthetic password (never a real credential).
url = build_rtsp_url("192.0.2.10", 554, "admin", "p@ss/w:d", "102")
assert url == "rtsp://admin:p%40ss%2Fw%3Ad@192.0.2.10:554/Streaming/Channels/102"
def test_build_rtsp_url_defaults_to_main_channel():
url = build_rtsp_url("192.0.2.10", 554, "admin", "secret")
assert url.endswith("/Streaming/Channels/101")
def test_probe_stream_returns_a_frame_on_success():
frame = np.zeros((480, 640, 3), dtype=np.uint8)
result = probe_stream("rtsp://x", capture_factory=lambda _u: _FakeCapture([frame]))
assert result.ok is True
assert "640x480" in result.message
assert result.frame is not None
def test_probe_stream_reports_failure_without_raising():
result = probe_stream("rtsp://x", attempts=3, capture_factory=lambda _u: _FakeCapture(None))
assert result.ok is False
assert result.frame is None
+62
View File
@@ -56,6 +56,68 @@ def test_load_config_rejects_embedded_source_address(tmp_path):
load_config(config_file)
def test_structured_source_builds_percent_encoded_rtsp_url(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{
"id": "hik-ipcamera-101",
"host": "192.0.2.10",
"port": 554,
"channel": "102",
"username": "admin",
"password": "p@ss/w:d",
},
)
config = load_config(config_file)
assert config.source_url == "rtsp://admin:p%40ss%2Fw%3Ad@192.0.2.10:554/Streaming/Channels/102"
assert config.source_id == "hik-ipcamera-101"
assert config.source_mode == "stream"
def test_structured_source_requires_password(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "hik-ipcamera-101", "host": "192.0.2.10", "username": "admin"},
)
with pytest.raises(ConfigError, match="password"):
load_config(config_file)
def test_structured_credentials_are_excluded_from_config_version(tmp_path):
first_file = tmp_path / "a.json"
second_file = tmp_path / "b.json"
_write_config(
first_file,
{"id": "hik-101", "host": "192.0.2.10", "username": "admin", "password": "secret-a"},
)
_write_config(
second_file,
{"id": "hik-101", "host": "192.0.2.20", "username": "operator", "password": "secret-b"},
)
first = load_config(first_file)
second = load_config(second_file)
assert first.source_url != second.source_url
assert first.runtime_config_version == second.runtime_config_version
def test_public_example_config_has_no_embedded_credentials():
from pathlib import Path
example = json.loads(Path("v1/config.example.json").read_text(encoding="utf-8"))
source = example["source"]
for forbidden in ("host", "username", "password", "url", "rtsp_url"):
assert forbidden not in source
assert "rtsp_url_env" in source
def test_source_mode_defaults_to_stream(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(