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:
+3
-1
@@ -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 必须以受控模型的真实哈希替换并验证后才能启动推理。
|
||||||
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
- 缺少环境变量、模型不存在或哈希不符时,应用显示配置错误,不启动监控。
|
||||||
|
|||||||
@@ -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
@@ -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,13 +132,26 @@ 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")
|
||||||
environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env")
|
|
||||||
if not _ENVIRONMENT_NAME.match(environment_name):
|
if "host" in source:
|
||||||
raise ConfigError("source.rtsp_url_env must be an environment variable name")
|
host = _text(source.get("host"), "source.host")
|
||||||
source_url = os.environ.get(environment_name)
|
port = int(_number(source.get("port", 554), "source.port", 1.0, 65535.0))
|
||||||
if not source_url:
|
channel = str(source.get("channel", "101")).strip()
|
||||||
raise ConfigError("missing RTSP environment variable: {0}".format(environment_name))
|
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")
|
source_mode = source.get("mode", "stream")
|
||||||
if source_mode not in ("replay", "stream"):
|
if source_mode not in ("replay", "stream"):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user