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 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-21 22:44:09 +08:00
co-authored by Claude Opus 4.8
parent edf8a9a94e
commit 2603a10fb1
8 changed files with 218 additions and 11 deletions
+26 -4
View File
@@ -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())
+32
View File
@@ -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.
+123 -2
View File
@@ -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, "实时监控")
+22
View File
@@ -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