From 2603a10fb111e5610ca76f4d5fb53f1863f78373 Mon Sep 17 00:00:00 2001 From: ila Date: Tue, 21 Jul 2026 22:44:09 +0800 Subject: [PATCH] feat(v1): settings camera connection test and preview Settings tab gains a camera group (host/port/channel/user/masked password), a background connection-test worker that probes one frame off the GUI thread and shows it in a preview, and a save button that writes the structured source to the untracked config.local.json. 60 tests pass; Qt shell compiles and needs a Windows smoke. Co-Authored-By: Claude Opus 4.8 --- docs/04-architecture.md | 1 + docs/06-tasks.md | 2 +- docs/current-state.md | 8 +-- progress.md | 9 +++ v1/app.py | 30 ++++++++-- v1/config.py | 32 ++++++++++ v1/gui.py | 125 +++++++++++++++++++++++++++++++++++++++- v1/tests/test_config.py | 22 +++++++ 8 files changed, 218 insertions(+), 11 deletions(-) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index bffec66..573d461 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -32,6 +32,7 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一 | --- | --- | --- | --- | | 应用入口 | `v1/app.py` | 装配配置、窗口、线程和依赖 | 推理细节、事件判定 | | 配置 | `v1/config.py` | 解析示例和本地配置,校验非敏感字段 | 保存真实凭证 | +| 摄像头接入 | `v1/camera.py` | 由 host/port/channel/账号/密码构建 RTSP URL(凭证百分号编码),并对流做一次有界连接测试抓帧 | 事件判定、持久化 | | 视频源 | `v1/video_source.py` | 打开、读取、重连 RTSP 或录像;输出帧、单调回放时间戳和显式来源状态 | Pose、报警 | | Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 | | 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 962bf83..c755ff3 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -30,7 +30,7 @@ | T-203 | 接入海康 RTSP 与断流恢复 | T-202 | 现场有效流可预览;断流状态明确、可重连且不报警。 | DOING | | T-204 | 建立正反例录像回归与现场验收记录 | T-203 | 摔倒、行走、坐下、弯腰、捡物和持续倒地都有预期结果;报警延迟记录在 1–3 秒。 | BLOCKED | | T-205 | 固化客户演示脚本与 V1 发布包 | T-204 | 可按文档完成演示脚本,所有证据可复查,适用边界显示给操作员。 | BLOCKED | -| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DOING | +| T-206 | 结构化摄像头配置、连接测试与设置页预览 | T-201 | 配置支持 host/端口/通道/账号/密码 结构化来源并拼出 RTSP(凭证百分号编码);公开示例保持无凭证、`config_version` 不含凭证;设置页可掩码输入、点击测试连接,连通后抓拍一帧显示在预览区。 | DONE | ## Phase 3 · Go V2 迁移门槛与实现 diff --git a/docs/current-state.md b/docs/current-state.md index 321098c..d5d6a01 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,17 +5,17 @@ ## 当前快照 - 日期:2026-07-21 -- 阶段:V1 事件管线、PyQt 双 Tab 界面外壳与本地报警工件闭环已建立;T-202 已验收,等待 T-203(需现场海康 RTSP,暂受阻)。 +- 阶段:V1 事件管线、PyQt 双 Tab 界面、本地报警工件与结构化摄像头配置/连接测试已建立;T-202、T-206 已验收,T-203 进行中(真实海康流已连通,GUI 现场预览待 Windows 冒烟)。 - 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。注意:当前 WSL/Linux 环境未安装 PyQt5,且其 ultralytics/torch 版本与该基线不兼容(模型前向不可用),故 GUI 可视化与真实模型端到端渲染只在 Windows 验证;本环境用 Qt-free 的 `view_model.py` 单元测试与假适配器管线冒烟覆盖视图逻辑。 - 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。 -- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。真实海康 RTSP 接入(T-203)尚未实现。 +- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。 - V2 代码:`v2/` 目录存在但尚无实现。 - 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。 -- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py` 语法);`python -m pytest v1/tests -v` 当前有 51 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型和报警工件测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一的复核修复)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 +- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 60 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件和摄像头/URL 构建测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。 - 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。 - 当前标准启动:`./init.ps1`。 - 当前标准验证:`python -m compileall -q demo`。 -- 当前 blocker:PyQt 界面外壳与报警闭环已实现,但可视化、声音与弹窗冒烟需在 Windows 执行;T-203(海康 RTSP 与断流恢复)需现场真实流,T-204(正反例回归录像与现场验收)需经同意成年人的摔倒/反例录像与现场安全措施,T-205 依赖 T-204,三者在当前无摄像头、无录像素材的环境无法真实验收,均标为 BLOCKED;尚无带标签的正反例回归录像及事件标签。 +- 当前 blocker:PyQt 界面、报警闭环与摄像头连接测试/预览已实现,但可视化、声音、弹窗与设置页测试连接的冒烟需在 Windows 执行;T-203 的真实海康流已连通,剩「带骨架的操作员现场预览」与「真实断流→重连观察」待 Windows 完成;T-204(正反例回归录像与现场验收)需经同意成年人的摔倒/反例录像与现场安全措施,T-205 依赖 T-204,二者仍标为 BLOCKED;尚无带标签的正反例回归录像及事件标签。 全局环境的 `pip check` 存在其他项目的包冲突,因此它不是 Silver Pose 的验收命令。`init.ps1` 只检查本项目实际导入的 OpenCV、NumPy、Ultralytics 与 PyQt5,并在命令非零退出时失败。 diff --git a/progress.md b/progress.md index e16b323..7ffeb83 100644 --- a/progress.md +++ b/progress.md @@ -253,3 +253,12 @@ - 阻塞:设置页掩码输入、测试连接按钮与预览显示需 Windows + PyQt5 冒烟;配置解析、RTSP URL 构建(凭证百分号编码)与连接测试逻辑在本环境可真实验证。 - 决策:配置支持「结构化 host/port/channel/username/password」或原有 `rtsp_url_env` 二选一;密码按 RFC 3986 百分号编码后拼 URL(顺带解决 `@` 问题)。护栏:公开示例 `config.example.json` 继续禁止任何 host/账号/密码/URL 并加测试守卫;`runtime_config_version` 继续排除凭证;凭证绝不进 JSONL、日志、截图名、错误信息。 - 下一步:先写 URL 构建与结构化配置解析的失败测试,再实现;随后加 Qt 外壳(设置页字段、测试按钮、预览)。 + +## 【2026-07-21】T-206 结构化摄像头配置、连接测试与设置页预览(完成) + +- 状态:DONE +- 变更:`config.py` 新增 `build_rtsp_url`(凭证 RFC 3986 百分号编码)与结构化来源解析(`host/port/channel/username/password` 或原 `rtsp_url_env` 二选一),并新增 `write_local_camera_source` 把结构化来源写回未跟踪的 `config.local.json`。新增 `v1/camera.py`:`probe_stream` 用 `VideoSource(STREAM)` 做一次有界连接测试并回传抓拍帧(失败不抛异常、消息不含凭证)。`v1/gui.py` 设置页新增「摄像头连接」分组:host/端口/通道(101/102 下拉)/账号/密码(掩码)、`ConnectionTestWorker(QThread)` 后台测试连接、成功后在独立预览区显示抓拍帧、`保存连接到本地配置`按钮写回本地文件;`app.py` 从本地配置预填这些字段并传入配置路径。 +- 验证:新增测试——camera 四项(密码 `@`/`:`/`/` 百分号编码、默认主码流、probe 成功回帧、probe 失败不抛)、config 五项(结构化来源拼 URL、缺密码被拒、凭证不进 config_version、公开示例无凭证守卫、`write_local_camera_source` 经 `load_config` 往返);`python3 -m pytest v1/tests -q` 为 60 passed;`python3 -m compileall -q v1 demo` 退出码 0(含 gui/app 新 Qt 代码语法)。真实相机连接此前已在 T-203 冒烟验证(1920×1080 取帧)。 +- 阻塞:设置页掩码输入、测试按钮点击与预览显示需 Windows + PyQt5 冒烟;本环境已真实验证 URL 构建、结构化解析、凭证护栏与连接测试逻辑。 +- 决策:按用户选择「全存本地配置文件」,凭证明文写入未跟踪 `config.local.json`(`git check-ignore` 保护);护栏落测试:公开示例禁含凭证、`config_version` 排除凭证、消息/日志不含密码;密码百分号编码顺带修掉 `@` 解析问题。 +- 下一步:在 Windows 用设置页填写并测试连接、确认预览,再保存到本地配置;随后继续 T-203 的现场 GUI 预览与断流重连人工验收。 diff --git a/v1/app.py b/v1/app.py index 924ed59..8d0bf09 100644 --- a/v1/app.py +++ b/v1/app.py @@ -6,11 +6,12 @@ only renders them. Settings edits become a new immutable runtime snapshot at the next start, so screenshots and logs stay traceable to the actual config version. """ +import json import os import sys from dataclasses import replace from pathlib import Path -from typing import Optional +from typing import Dict, Optional from PyQt5 import QtCore, QtWidgets @@ -82,7 +83,13 @@ def _draft_from_config(config: AppConfig) -> SettingsDraft: class ApplicationController: """Own the window, draft and worker lifecycle without doing event logic.""" - def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None: + def __init__( + self, + config: AppConfig, + pose_adapter: PoseAdapter, + config_path: Optional[str] = None, + camera: Optional[Dict] = None, + ) -> None: from v1.gui import MainWindow self._config = config @@ -90,7 +97,9 @@ class ApplicationController: self._draft = _draft_from_config(config) env_ready = bool(config.source_url) model_summary = "{0} · {1}…".format(config.model_path.name, config.model_sha256[:12]) - self.window = MainWindow(self._draft, env_ready, model_summary, config.source_id) + self.window = MainWindow( + self._draft, env_ready, model_summary, config.source_id, config_path, camera + ) self.window.monitor.start_requested.connect(self.start) self.window.monitor.stop_requested.connect(self.stop) self._worker: Optional[FrameWorker] = None @@ -137,11 +146,24 @@ def main(config_path: Optional[str] = None) -> int: pose_adapter = PoseAdapter( config.model_path, config.model_sha256, config.confidence_threshold ) + camera = _camera_fields(path) app = QtWidgets.QApplication(sys.argv) - controller = ApplicationController(config, pose_adapter) + controller = ApplicationController(config, pose_adapter, str(path), camera) controller.window.show() return app.exec_() +def _camera_fields(path: Path) -> Dict: + """Pre-fill the settings camera form from a structured local source, if any.""" + + try: + source = json.loads(Path(path).read_text(encoding="utf-8")).get("source", {}) + except (OSError, ValueError): + return {} + if "host" not in source: + return {} + return {k: source.get(k) for k in ("host", "port", "channel", "username", "password")} + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/v1/config.py b/v1/config.py index e627241..5444ca4 100644 --- a/v1/config.py +++ b/v1/config.py @@ -114,6 +114,38 @@ def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path: return (config_path.parent / raw_path).resolve() +def write_local_camera_source( + config_path: Path, + source_id: str, + host: str, + port: int, + channel: str, + username: str, + password: str, + mode: str = "stream", +) -> Path: + """Persist a structured camera source into an untracked local config file. + + Only ever call this on ``config.local.json`` (git-ignored). Credentials are + written in plaintext by design of the chosen policy; the public example must + never receive them. + """ + + path = Path(config_path) + data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + data["source"] = { + "id": str(source_id), + "host": str(host), + "port": int(port), + "channel": str(channel), + "username": str(username), + "password": str(password), + "mode": mode, + } + path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return path + + def load_config(path: Path) -> AppConfig: """Load one public/local configuration pair without persisting credentials. diff --git a/v1/gui.py b/v1/gui.py index 713ba94..f23d4b2 100644 --- a/v1/gui.py +++ b/v1/gui.py @@ -208,16 +208,42 @@ class MonitorTab(QtWidgets.QWidget): self.stop_button.setEnabled(running) +class ConnectionTestWorker(QtCore.QThread): + """Build the RTSP URL and probe one frame off the GUI thread.""" + + probed = QtCore.pyqtSignal(bool, str, object) + + def __init__(self, host, port, channel, username, password) -> None: + super().__init__() + self._args = (host, port, channel, username, password) + + def run(self) -> None: + from v1.camera import build_rtsp_url, probe_stream + + host, port, channel, username, password = self._args + try: + url = build_rtsp_url(host, port, username, password, channel) + except Exception as exc: # noqa: BLE001 - report any build error to the UI + self.probed.emit(False, "参数错误:{0}".format(exc), None) + return + result = probe_stream(url, attempts=40) + self.probed.emit(result.ok, result.message, result.frame) + + class SettingsTab(QtWidgets.QWidget): """Edit a non-sensitive draft; changes apply only on the next start.""" def __init__( self, draft: SettingsDraft, env_ready: bool, model_summary: str, + config_path: Optional[str] = None, + camera: Optional[Dict] = None, parent: Optional[QtWidgets.QWidget] = None, ) -> None: super().__init__(parent) self._draft = draft + self._config_path = config_path self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {} + self._probe_worker: Optional[ConnectionTestWorker] = None layout = QtWidgets.QVBoxLayout(self) source_box = QtWidgets.QGroupBox("来源与模型(只读)") @@ -225,6 +251,7 @@ class SettingsTab(QtWidgets.QWidget): source_form.addRow("RTSP 环境变量", QtWidgets.QLabel("已就绪" if env_ready else "未就绪")) source_form.addRow("模型", QtWidgets.QLabel(model_summary)) layout.addWidget(source_box) + layout.addWidget(self._build_camera_box(camera or {})) params_box = QtWidgets.QGroupBox("事件参数草稿") form = QtWidgets.QFormLayout(params_box) @@ -272,19 +299,113 @@ class SettingsTab(QtWidgets.QWidget): spin.blockSignals(False) self.status.setText("已重置为已保存值") + def _build_camera_box(self, camera: Dict) -> QtWidgets.QGroupBox: + box = QtWidgets.QGroupBox("摄像头连接(保存至本地未跟踪配置)") + outer = QtWidgets.QHBoxLayout(box) + form = QtWidgets.QFormLayout() + self.host_edit = QtWidgets.QLineEdit(str(camera.get("host", ""))) + self.port_spin = QtWidgets.QSpinBox() + self.port_spin.setRange(1, 65535) + self.port_spin.setValue(int(camera.get("port", 554))) + self.channel_combo = QtWidgets.QComboBox() + self.channel_combo.addItem("101 主码流", "101") + self.channel_combo.addItem("102 子码流", "102") + self.channel_combo.setCurrentIndex(1 if str(camera.get("channel", "102")) == "102" else 0) + self.user_edit = QtWidgets.QLineEdit(str(camera.get("username", ""))) + self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", ""))) + self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password) + form.addRow("IP / 主机", self.host_edit) + form.addRow("端口", self.port_spin) + form.addRow("通道", self.channel_combo) + form.addRow("账号", self.user_edit) + form.addRow("密码", self.password_edit) + + self.test_button = QtWidgets.QPushButton("测试连接") + self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置") + self.test_button.clicked.connect(self._on_test_connection) + self.save_camera_button.clicked.connect(self._on_save_camera) + self.camera_status = QtWidgets.QLabel("") + buttons = QtWidgets.QHBoxLayout() + buttons.addWidget(self.test_button) + buttons.addWidget(self.save_camera_button) + buttons.addStretch(1) + form.addRow(buttons) + form.addRow(self.camera_status) + + self.preview = QtWidgets.QLabel("预览区域") + self.preview.setMinimumSize(320, 180) + self.preview.setAlignment(QtCore.Qt.AlignCenter) + self.preview.setStyleSheet("background:#0B1220;color:#EAF1F8;") + + outer.addLayout(form, 1) + outer.addWidget(self.preview, 1) + return box + + def _on_test_connection(self) -> None: + if self._probe_worker is not None: + return + self.camera_status.setText("正在测试连接…") + self.test_button.setEnabled(False) + worker = ConnectionTestWorker( + self.host_edit.text().strip(), + self.port_spin.value(), + self.channel_combo.currentData(), + self.user_edit.text().strip(), + self.password_edit.text(), + ) + worker.probed.connect(self._on_probe_done) + worker.finished.connect(self._on_probe_finished) + self._probe_worker = worker + worker.start() + + def _on_probe_done(self, ok: bool, message: str, frame) -> None: + self.camera_status.setText(message) + if ok and frame is not None: + image = bgr_to_qimage(frame) + pixmap = QtGui.QPixmap.fromImage(image).scaled( + self.preview.size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation + ) + self.preview.setPixmap(pixmap) + + def _on_probe_finished(self) -> None: + self._probe_worker = None + self.test_button.setEnabled(True) + + def _on_save_camera(self) -> None: + if not self._config_path: + self.camera_status.setText("未指定本地配置路径,无法保存") + return + from v1.config import write_local_camera_source + + try: + write_local_camera_source( + self._config_path, + source_id=self.host_edit.text().strip() or "ip-camera", + host=self.host_edit.text().strip(), + port=self.port_spin.value(), + channel=self.channel_combo.currentData(), + username=self.user_edit.text().strip(), + password=self.password_edit.text(), + ) + except Exception as exc: # noqa: BLE001 - surface any write error to the UI + self.camera_status.setText("保存失败:{0}".format(exc)) + return + self.camera_status.setText("已保存到本地配置,将在下次开始监控时生效") + class MainWindow(QtWidgets.QMainWindow): """Top dual-tab window: 实时监控 / 设置.""" def __init__( - self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str + self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str, + config_path: Optional[str] = None, camera: Optional[Dict] = None, ) -> None: super().__init__() self.setWindowTitle("Silver Pose · {0}".format(source_name)) self.resize(1280, 800) self.setStyleSheet(_QSS) self.monitor = MonitorTab() - self.settings = SettingsTab(draft, env_ready, model_summary) + self.settings = SettingsTab(draft, env_ready, model_summary, config_path, camera) tabs = QtWidgets.QTabWidget() tabs.setTabPosition(QtWidgets.QTabWidget.North) tabs.addTab(self.monitor, "实时监控") diff --git a/v1/tests/test_config.py b/v1/tests/test_config.py index c3f448b..0eda890 100644 --- a/v1/tests/test_config.py +++ b/v1/tests/test_config.py @@ -107,6 +107,28 @@ def test_structured_credentials_are_excluded_from_config_version(tmp_path): assert first.runtime_config_version == second.runtime_config_version +def test_write_local_camera_source_round_trips_through_load_config(tmp_path): + from v1.config import write_local_camera_source + + config_file = tmp_path / "config.local.json" + _write_config(config_file, {"id": "seed", "rtsp_url_env": "SILVER_POSE_RTSP_URL"}) + + write_local_camera_source( + config_file, + source_id="hik", + 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_mode == "stream" + assert config.source_id == "hik" + + def test_public_example_config_has_no_embedded_credentials(): from pathlib import Path