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
+3 -1
View File
@@ -32,8 +32,10 @@
} }
``` ```
- `rtsp_url_env` 必填;应用从同名环境变量读取真实 URL。 - 来源二选一:`rtsp_url_env`(从同名环境变量读取真实 URL)**或**结构化字段 `host`/`port`(默认 554)/`channel`(默认 `101`,`102` 为子码流)/`username`/`password`;两者都会在内存中解析出 RTSP URL。结构化凭证只允许出现在**未跟踪的** `config.local.json`;公开示例 `config.example.json` 只能用 `rtsp_url_env`,禁止任何 `host`/`username`/`password`/完整 URL(有测试守卫)。
- 结构化模式下应用用 `build_rtsp_url` 拼接,并对用户名/密码做 RFC 3986 百分号编码,因此密码含 `@`、`:`、`/` 也不会破坏 URL 解析。
- `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。 - `source.mode` 显式声明来源类型,取值 `stream`(默认,实时 RTSP,允许有界重连)或 `replay`(本地录像,EOF 不重放);由配置决定,不再按 URL 前缀猜测。
- 凭证(`host`/`username`/`password`/解析出的 URL)绝不进入 `runtime_config_version`、事件 JSONL、日志或截图文件名。
- `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。 - `model.confidence_threshold` 是模型检测置信度,可在设置草稿中调整,并在下次开始监控时经 `PoseAdapter.set_confidence_threshold` 真正生效。
- 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。 - 数值是待现场录像校准的默认值;每个值必须真正进入事件逻辑:`keypoint_confidence_threshold` 决定姿态质量门槛;`suspect_window_seconds` 限制快速下移到水平姿态的最大间隔;`confirm_window_seconds` 是水平倒地候选需持续的确认时间;`recovery_window_seconds` 是恢复姿态需持续的时间;`cooldown_seconds` 是确认事件后允许开始恢复判断前的最短等待时间。示例中的全零 SHA-256 只占位配置形状,T-103 必须以受控模型的真实哈希替换并验证后才能启动推理。
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。 - 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
+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()
+40 -1
View File
@@ -7,12 +7,38 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import quote
class ConfigError(ValueError): class ConfigError(ValueError):
"""Raised when a configuration file cannot safely start V1.""" """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) @dataclass(frozen=True)
class EventConfig: class EventConfig:
keypoint_confidence_threshold: float keypoint_confidence_threshold: float
@@ -106,7 +132,20 @@ def load_config(path: Path) -> AppConfig:
root = _mapping(raw, "config") root = _mapping(raw, "config")
source = _mapping(root.get("source"), "source") source = _mapping(root.get("source"), "source")
if "url" in source or "rtsp_url" in source: if "url" in source or "rtsp_url" in source:
raise ConfigError("source must define rtsp_url_env, not an embedded address") 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") environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env")
if not _ENVIRONMENT_NAME.match(environment_name): if not _ENVIRONMENT_NAME.match(environment_name):
raise ConfigError("source.rtsp_url_env must be an environment variable name") raise ConfigError("source.rtsp_url_env must be an environment variable name")
+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) 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): def test_source_mode_defaults_to_stream(tmp_path, monkeypatch):
config_file = tmp_path / "config.json" config_file = tmp_path / "config.json"
_write_config( _write_config(