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:
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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, "实时监控")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user