some webUI fixes

This commit is contained in:
Ayzen
2026-06-10 14:39:25 +03:00
parent c12ae05148
commit 21f76d7cd2
14 changed files with 767 additions and 32 deletions
+2
View File
@@ -124,6 +124,8 @@ class AppWindow(
self._project_root = project_root self._project_root = project_root
self._root_profile_path = project_root / "run_config.json" self._root_profile_path = project_root / "run_config.json"
self._active_profile_path = self._root_profile_path self._active_profile_path = self._root_profile_path
# Run-config profiles the web UI can browse and load (it has no file dialog).
self._run_configs_dir = project_root / "run_configs"
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = [] self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
# Guards closeEvent against re-entrant teardown (e.g. a second signal). # Guards closeEvent against re-entrant teardown (e.g. a second signal).
self._closing = False self._closing = False
@@ -298,11 +298,23 @@ class AppWindowLiveProcessingMixin:
``_on_processing_live_settings_changed`` handler — one writer, one redraw — ``_on_processing_live_settings_changed`` handler — one writer, one redraw —
so the browser and the desktop never desync and ``processing_live.json`` has so the browser and the desktop never desync and ``processing_live.json`` has
exactly one author. The per-widget change signals are neutralized by a exactly one author. The per-widget change signals are neutralized by a
suppression flag so the final handler runs once instead of dozens of times; suppression flag so the final handler runs once instead of dozens of times.
a history command keeps its own server-managed sequence-bump path.
A destructive history command is not a settings edit: it routes to the very
same handler the desktop's "Clear History" / "Remove Last" button uses, so the
browser drains the ring readers, empties the runtime histories, clears the
render caches and the processor's replay state, and redraws — rather than only
nudging the processor while the on-screen history stays put.
""" """
try: try:
history_command = str(fields.get("history_command", "none")) history_command = str(fields.get("history_command", "none"))
if history_command == "clear_all":
self._clear_all_runtime_history()
return
if history_command == "remove_last":
self._remove_last_runtime_history()
return
settings = { settings = {
name: value name: value
for name, value in fields.items() for name, value in fields.items()
@@ -310,10 +322,7 @@ class AppWindowLiveProcessingMixin:
} }
# Log only field names (not values) so remote edits are traceable # Log only field names (not values) so remote edits are traceable
# without recording arbitrary client-supplied payloads. # without recording arbitrary client-supplied payloads.
self._log_debug( self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
f"Applying web live settings: fields={sorted(settings)}, "
f"history_command={history_command}."
)
self._suppress_live_settings_handler = True self._suppress_live_settings_handler = True
try: try:
# processor_mode first: dual-sourced gpr_* fields route to the gpr or # processor_mode first: dual-sourced gpr_* fields route to the gpr or
@@ -324,9 +333,6 @@ class AppWindowLiveProcessingMixin:
_set_web_live_field(self, name, value) _set_web_live_field(self, name, value)
finally: finally:
self._suppress_live_settings_handler = False self._suppress_live_settings_handler = False
if history_command in {"clear_all", "remove_last"}:
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
self._on_processing_live_settings_changed() self._on_processing_live_settings_changed()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to apply web live settings", exc) self._show_exception("Failed to apply web live settings", exc)
@@ -172,32 +172,39 @@ class AppWindowConfigProfileIOMixin:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to save config profile", exc) self._show_exception("Failed to save config profile", exc)
def _load_config_from_dialog(self) -> None: def _ensure_ready_to_load_config(self) -> bool:
"""Load full GUI profile from a user-selected JSON file.""" """Return whether a config load may proceed, reporting the blocker if not.
Loading rebuilds every widget and resizes history, so it is refused while a
capture sequence or the pipeline is active — the operator must stop first.
Shared by the desktop dialog and the web loader so both honor the same rule.
"""
if self._capture_session is not None: if self._capture_session is not None:
self._show_error( self._show_error(
"Cannot load config during active capture sequence", "Cannot load config during active capture sequence",
details=self._capture_state_details(), details=self._capture_state_details(),
) )
return return False
if self._supervisor.is_running(): if self._supervisor.is_running():
self._show_error( self._show_error(
"Stop all pipeline processes before loading a config profile", "Stop all pipeline processes before loading a config profile",
details=self._process_state_details(), details=self._process_state_details(),
) )
return return False
return True
selected_path, _selected_filter = QFileDialog.getOpenFileName( def _load_config_from_path(self, config_path: Path) -> None:
self, """Load a full GUI profile from ``config_path`` and apply it to the UI.
"Load Config Profile",
str(self._active_profile_path),
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
This is the single authoritative load path: the desktop "Load Config" dialog
and the web config picker both funnel here, differing only in how they obtain
the path. The pipeline is left untouched; live settings reach a running
data_processor immediately while stable settings stage for the next Start.
"""
if not self._ensure_ready_to_load_config():
return
try: try:
normalized_path = self._normalize_profile_path(Path(selected_path)) normalized_path = self._normalize_profile_path(config_path)
profile = GuiProfileModel.load_from_path(normalized_path) profile = GuiProfileModel.load_from_path(normalized_path)
processor_running = self._supervisor.is_processor_running() processor_running = self._supervisor.is_processor_running()
self._apply_loaded_profile(profile, normalized_path) self._apply_loaded_profile(profile, normalized_path)
@@ -217,6 +224,20 @@ class AppWindowConfigProfileIOMixin:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._show_exception("Failed to load config profile", exc) self._show_exception("Failed to load config profile", exc)
def _load_config_from_dialog(self) -> None:
"""Load a full GUI profile from a user-selected JSON file (desktop button)."""
if not self._ensure_ready_to_load_config():
return
selected_path, _selected_filter = QFileDialog.getOpenFileName(
self,
"Load Config Profile",
str(self._active_profile_path),
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
self._load_config_from_path(Path(selected_path))
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None: def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
"""Apply an already parsed profile to GUI state without restarting the pipeline. """Apply an already parsed profile to GUI state without restarting the pipeline.
@@ -7,11 +7,27 @@ import time
from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QFileDialog
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
from python_app.storage.npz.paths import radar_config_filename_prefix
class AppWindowSnapshotMixin: class AppWindowSnapshotMixin:
"""Saves runtime data snapshots and maintains ring-reader freshness.""" """Saves runtime data snapshots and maintains ring-reader freshness."""
def _radar_config_name_prefix(self) -> str:
"""Filename prefix encoding the current radar setup (model, sweep span, points, power).
Prepended to every saved dataset name so the file records the configuration it was
captured with — identical for desktop and web saves, which share these handlers.
"""
radar = self._build_config().radar
return radar_config_filename_prefix(
radar.model,
radar.sweep.start_hz,
radar.sweep.stop_hz,
radar.sweep.points,
radar.sweep.power_dbm,
)
@staticmethod @staticmethod
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path: def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
"""Return companion config-profile path inside a saved snapshot directory.""" """Return companion config-profile path inside a saved snapshot directory."""
@@ -41,6 +57,7 @@ class AppWindowSnapshotMixin:
list(self._pre_history), list(self._pre_history),
list(self._result_history), list(self._result_history),
last_n, last_n,
name_prefix=self._radar_config_name_prefix(),
) )
config_profile_path = self._snapshot_config_profile_path(snapshot_dir) config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
try: try:
@@ -94,6 +111,7 @@ class AppWindowSnapshotMixin:
last_n, last_n,
channel=channel, channel=channel,
primary_stage="preprocessed", primary_stage="preprocessed",
name_prefix=self._radar_config_name_prefix(),
) )
output_stem = str(summary.get("output_stem", "")).strip() output_stem = str(summary.get("output_stem", "")).strip()
if not output_stem: if not output_stem:
@@ -19,6 +19,7 @@ import base64
import contextlib import contextlib
import os import os
import time import time
from pathlib import Path
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
@@ -36,6 +37,26 @@ _GRAB_INTERVAL_S = 0.2
_LIVE_FIELD_NAMES = web_apply_field_names() _LIVE_FIELD_NAMES = web_apply_field_names()
def _safe_run_config_path(run_configs_dir: Path, name: str) -> Path | None:
"""Resolve a client-supplied config name to a file strictly inside ``run_configs_dir``.
The web sends only a bare file name; this is the trust boundary. The name must be a
plain ``.json`` file living directly in the directory — ``name != Path(name).name``
rejects any separator or ``..`` traversal. Returns the path, or ``None`` if invalid.
"""
if not name or name != Path(name).name or not name.lower().endswith(".json"):
return None
candidate = run_configs_dir / name
return candidate if candidate.is_file() else None
def _list_run_config_names(run_configs_dir: Path) -> list[str]:
"""Return the sorted ``.json`` file names directly inside ``run_configs_dir``."""
if not run_configs_dir.is_dir():
return []
return sorted(entry.name for entry in run_configs_dir.glob("*.json") if entry.is_file())
class AppWindowWebController(QObject): class AppWindowWebController(QObject):
"""Qt bridge satisfying the web contract: streams the plot, forwards controls. """Qt bridge satisfying the web contract: streams the plot, forwards controls.
@@ -50,9 +71,12 @@ class AppWindowWebController(QObject):
single_capture_requested = pyqtSignal() single_capture_requested = pyqtSignal()
capture_requested = pyqtSignal() capture_requested = pyqtSignal()
apply_settings_requested = pyqtSignal(dict) apply_settings_requested = pyqtSignal(dict)
load_config_requested = pyqtSignal(str)
save_dataset_requested = pyqtSignal(str, str)
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._run_configs_dir = run_configs_dir
self._status: dict = {} self._status: dict = {}
self._live_settings: list = [] self._live_settings: list = []
self._frame: dict | None = None self._frame: dict | None = None
@@ -74,6 +98,10 @@ class AppWindowWebController(QObject):
def current_live_settings(self) -> list: def current_live_settings(self) -> list:
return list(self._live_settings) return list(self._live_settings)
def list_configs(self) -> list[str]:
"""Return the run-config file names available to load (a plain directory scan)."""
return _list_run_config_names(self._run_configs_dir)
def peek_frame(self) -> dict | None: def peek_frame(self) -> dict | None:
"""Return the most recent rendered-plot frame (or None before the first).""" """Return the most recent rendered-plot frame (or None before the first)."""
return self._frame return self._frame
@@ -99,6 +127,21 @@ class AppWindowWebController(QObject):
self.apply_settings_requested.emit(dict(fields)) self.apply_settings_requested.emit(dict(fields))
return self.current_live_settings() return self.current_live_settings()
def load_config(self, name: str) -> None:
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
Validates the name against the directory here — on the web thread — so an invalid
or unsafe name fails the HTTP request immediately instead of silently doing nothing
on the Qt side; the actual load runs through the queued signal.
"""
if _safe_run_config_path(self._run_configs_dir, name) is None:
raise ValueError(f"Unknown run config: {name}")
self.load_config_requested.emit(name)
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
self.save_dataset_requested.emit(path, name)
class AppWindowWebMixin: class AppWindowWebMixin:
"""Start/stop the embedded web server and feed it the live plot + settings.""" """Start/stop the embedded web server and feed it the live plot + settings."""
@@ -118,12 +161,17 @@ class AppWindowWebMixin:
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"): if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
self.resize(*_HEADLESS_PLOT_SIZE) self.resize(*_HEADLESS_PLOT_SIZE)
controller = AppWindowWebController(parent=self) # The web picker browses this directory; ensure it exists on fresh deploys.
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
controller = AppWindowWebController(self._run_configs_dir, parent=self)
controller.start_requested.connect(self._start_run) controller.start_requested.connect(self._start_run)
controller.stop_requested.connect(self._stop_run) controller.stop_requested.connect(self._stop_run)
controller.single_capture_requested.connect(self._start_single_capture) controller.single_capture_requested.connect(self._start_single_capture)
controller.capture_requested.connect(self._capture_tmp_reference) controller.capture_requested.connect(self._capture_tmp_reference)
controller.apply_settings_requested.connect(self._apply_web_live_settings) controller.apply_settings_requested.connect(self._apply_web_live_settings)
controller.load_config_requested.connect(self._load_web_config)
controller.save_dataset_requested.connect(self._save_web_dataset)
self._web_controller = controller self._web_controller = controller
self._web_update_snapshot() # seed snapshots before the first request self._web_update_snapshot() # seed snapshots before the first request
@@ -137,6 +185,32 @@ class AppWindowWebMixin:
self._web_controller = None self._web_controller = None
self._web_server = None self._web_server = None
def _load_web_config(self, name: str) -> None:
"""Load a run config chosen in the browser through the shared desktop load path.
Runs on the Qt main thread (queued from the web thread). The name is re-resolved
against the directory here as the authoritative trust boundary before any file
access, then handed to the same loader the desktop "Load Config" button uses.
"""
config_path = _safe_run_config_path(self._run_configs_dir, name)
if config_path is None:
self._log_warning(f"Ignored web request to load unknown run config: {name}")
return
self._load_config_from_path(config_path)
def _save_web_dataset(self, path: str, name: str) -> None:
"""Save the dataset from the browser via the same handler as the desktop button.
The web fields are remote editors of the shared save-path/name widgets: the name
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
path is ignored to avoid wiping the configured destination. The save itself — and
the radar-config filename prefix — is the unchanged desktop action.
"""
if path.strip():
self._save_path_input.setText(path)
self._save_name_input.setText(name)
self._save_snapshot()
def _web_update_snapshot(self) -> None: def _web_update_snapshot(self) -> None:
"""Refresh the snapshots the bridge serves (called on the Qt poll tick). """Refresh the snapshots the bridge serves (called on the Qt poll tick).
@@ -152,6 +226,17 @@ class AppWindowWebMixin:
"running": self._supervisor.is_running(), "running": self._supervisor.is_running(),
"processor_running": self._supervisor.is_processor_running(), "processor_running": self._supervisor.is_processor_running(),
"ring_name": self._defaults_config.rings.results.name, "ring_name": self._defaults_config.rings.results.name,
# File name of the currently loaded config profile, so the web picker
# can show what is active (the desktop tracks the full path).
"active_config": self._active_profile_path.name,
# Current save path/name, so the web fields can prefill the desktop values.
"save_path": self._save_path_input.text(),
"save_name": self._save_name_input.text(),
# Per-stage capture counts, identical to the desktop history
# label (raw -> preprocessed -> results), mirrored to the browser.
"raw_count": len(self._raw_history),
"preprocessed_count": len(self._pre_history),
"result_count": len(self._result_history),
}, },
live_settings=self._web_settings_schema(), live_settings=self._web_settings_schema(),
frame=self._web_grab_frame_if_due(), frame=self._web_grab_frame_if_due(),
+37
View File
@@ -33,6 +33,43 @@ def radar_key_from_config(
) )
def radar_config_filename_prefix(
model_name: str,
sweep_start_hz: float,
sweep_stop_hz: float,
sweep_points: int,
power_dbm: float,
) -> str:
"""Build a readable, filesystem-safe prefix describing the radar acquisition setup.
Encodes the radar model, sweep frequency span, point count, and power so a saved
dataset's name carries the configuration it was captured with. ADC-mode radars have
no fixed sweep, so the point count is reported as ``adc``.
"""
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts"
tokens = (
model_name,
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
points_token,
f"{power_dbm:g}dBm",
)
return sanitize_path_component("_".join(tokens))
def _format_frequency_span(start_hz: float, stop_hz: float) -> str:
"""Format a frequency span compactly, both bounds sharing the larger bound's unit."""
unit, scale = _frequency_unit(max(start_hz, stop_hz))
return f"{start_hz / scale:g}-{stop_hz / scale:g}{unit}"
def _frequency_unit(hz: float) -> tuple[str, float]:
"""Pick the largest unit (GHz/MHz/kHz/Hz) that keeps the magnitude at or above one."""
for unit, scale in (("GHz", 1e9), ("MHz", 1e6), ("kHz", 1e3)):
if hz >= scale:
return unit, scale
return "Hz", 1.0
def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str: def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str:
"""Build canonical collection directory name for snapshot stages.""" """Build canonical collection directory name for snapshot stages."""
return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}" return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}"
+14 -4
View File
@@ -27,6 +27,15 @@ from python_app.storage.store_api import StoreApi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
"""Compose a sanitized snapshot stem from an optional prefix and the operator's name.
The name falls back to a UTC timestamp when blank, so a save is always uniquely named.
"""
base = name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
return sanitize_path_component(f"{name_prefix}_{base}" if name_prefix else base)
class NpzStore(StoreApi): class NpzStore(StoreApi):
"""Persist preprocess sets and runtime snapshots using NumPy files.""" """Persist preprocess sets and runtime snapshots using NumPy files."""
@@ -193,13 +202,14 @@ class NpzStore(StoreApi):
preprocessed_history: list[SweepCollection], preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection], result_history: list[ResultCollection],
last_n: int, last_n: int,
*,
name_prefix: str = "",
) -> tuple[Path, dict[str, Any]]: ) -> tuple[Path, dict[str, Any]]:
"""Save historical runtime collections in NumPy tree format.""" """Save historical runtime collections in NumPy tree format."""
if last_n <= 0: if last_n <= 0:
raise ValueError("last_n must be > 0") raise ValueError("last_n must be > 0")
snapshot_stem = snapshot_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") snapshot_stem = _compose_snapshot_stem(snapshot_name, name_prefix)
snapshot_stem = sanitize_path_component(snapshot_stem)
output_root_dir.mkdir(parents=True, exist_ok=True) output_root_dir.mkdir(parents=True, exist_ok=True)
snapshot_dir = output_root_dir / snapshot_stem snapshot_dir = output_root_dir / snapshot_stem
@@ -324,13 +334,13 @@ class NpzStore(StoreApi):
*, *,
channel: str = "s21", channel: str = "s21",
primary_stage: str = "preprocessed", primary_stage: str = "preprocessed",
name_prefix: str = "",
) -> tuple[list[Path], dict[str, Any]]: ) -> tuple[list[Path], dict[str, Any]]:
"""Save one runtime VNA-history JSON per available combo.""" """Save one runtime VNA-history JSON per available combo."""
if last_n <= 0: if last_n <= 0:
raise ValueError("last_n must be > 0") raise ValueError("last_n must be > 0")
output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") output_stem = _compose_snapshot_stem(output_name, name_prefix)
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True) output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem) output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
+49 -1
View File
@@ -27,6 +27,7 @@ from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
AppWindowWebMixin, AppWindowWebMixin,
) )
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402 from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
from python_app.storage.npz.paths import radar_config_filename_prefix # noqa: E402
from python_app.storage.npz.store import NpzStore # noqa: E402 from python_app.storage.npz.store import NpzStore # noqa: E402
from python_app.storage.npz.vna_history_json import ( # noqa: E402 from python_app.storage.npz.vna_history_json import ( # noqa: E402
_complex_to_points, _complex_to_points,
@@ -84,6 +85,29 @@ class NpzStoreRoundTripTest(unittest.TestCase):
self.store.load_set("calibration", "radar1", "absent") self.store.load_set("calibration", "radar1", "absent")
# --------------------------------------------------------------------------- #
# Radar-config filename prefix
# --------------------------------------------------------------------------- #
class RadarConfigPrefixTest(unittest.TestCase):
def test_encodes_model_span_points_power(self) -> None:
self.assertEqual(
radar_config_filename_prefix("librevna", 1e9, 6e9, 201, -10.0),
"librevna_1-6GHz_201pts_-10dBm",
)
def test_megahertz_span_keeps_fractional_values(self) -> None:
self.assertEqual(
radar_config_filename_prefix("librevna", 1.5e6, 6e6, 51, -3.5),
"librevna_1.5-6MHz_51pts_-3.5dBm",
)
def test_adc_model_reports_adc_point_count(self) -> None:
self.assertEqual(
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
"kamil_adc_1-6GHz_adc_0dBm",
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# vna_history export # vna_history export
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -121,7 +145,12 @@ class VnaHistoryTest(unittest.TestCase):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class WebControllerTest(unittest.TestCase): class WebControllerTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.controller = AppWindowWebController() self._cfg_dir = tempfile.TemporaryDirectory()
self.addCleanup(self._cfg_dir.cleanup)
self.configs_dir = Path(self._cfg_dir.name)
(self.configs_dir / "alpha.json").write_text("{}", encoding="utf-8")
(self.configs_dir / "beta.json").write_text("{}", encoding="utf-8")
self.controller = AppWindowWebController(self.configs_dir)
self.addCleanup(self.controller.deleteLater) self.addCleanup(self.controller.deleteLater)
def test_rejects_unknown_field(self) -> None: def test_rejects_unknown_field(self) -> None:
@@ -160,6 +189,25 @@ class WebControllerTest(unittest.TestCase):
self.controller.capture_tmp_reference() self.controller.capture_tmp_reference()
self.assertEqual(fired, ["start", "stop", "single", "capture"]) self.assertEqual(fired, ["start", "stop", "single", "capture"])
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"])
requested: list[str] = []
self.controller.load_config_requested.connect(requested.append)
self.controller.load_config("beta.json")
self.assertEqual(requested, ["beta.json"])
def test_load_config_rejects_unknown_or_unsafe_name(self) -> None:
# Missing file, traversal, sub-path, and non-json are all refused (HTTP 400 upstream).
for bad in ("ghost.json", "../escape.json", "sub/alpha.json", "alpha.txt"):
with self.assertRaisesRegex(ValueError, "Unknown run config"):
self.controller.load_config(bad)
def test_save_dataset_forwards_path_and_name(self) -> None:
received: list[tuple[str, str]] = []
self.controller.save_dataset_requested.connect(lambda p, n: received.append((p, n)))
self.controller.save_dataset("/tmp/out", "run1")
self.assertEqual(received, [("/tmp/out", "run1")])
class WebPortTest(unittest.TestCase): class WebPortTest(unittest.TestCase):
def _port_with_env(self, value: str | None) -> int: def _port_with_env(self, value: str | None) -> int:
+9
View File
@@ -32,9 +32,18 @@ class WebController(Protocol):
def apply_live_settings(self, fields: dict) -> list: def apply_live_settings(self, fields: dict) -> list:
"""Apply live processor settings; returns the current settings schema.""" """Apply live processor settings; returns the current settings schema."""
def load_config(self, name: str) -> None:
"""Load a run-config profile by file name (the desktop "Load Config" button)."""
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
def current_live_settings(self) -> list: def current_live_settings(self) -> list:
"""Return the live-settings schema (built from the Qt widgets).""" """Return the live-settings schema (built from the Qt widgets)."""
def list_configs(self) -> list[str]:
"""Return the run-config file names available to load."""
def status(self) -> dict: def status(self) -> dict:
"""Return a snapshot of pipeline/run state.""" """Return a snapshot of pipeline/run state."""
+29
View File
@@ -63,6 +63,35 @@ async def post_tmp_reference(request: Request) -> dict:
return controller.status() return controller.status()
@router.get("/api/configs")
async def get_configs(request: Request) -> dict:
return {"names": _controller(request).list_configs()}
@router.post("/api/load_config")
async def post_load_config(request: Request, name: str = Body(..., embed=True)) -> dict:
logger.info("Web UI request: load config %r", name)
controller = _controller(request)
try:
controller.load_config(name)
except ValueError as exc:
logger.warning("Web UI rejected config load: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
return controller.status()
@router.post("/api/save_dataset")
async def post_save_dataset(
request: Request,
path: str = Body("", embed=True),
name: str = Body("", embed=True),
) -> dict:
logger.info("Web UI request: save dataset")
controller = _controller(request)
controller.save_dataset(path, name)
return controller.status()
@router.get("/api/live_settings") @router.get("/api/live_settings")
async def get_live_settings(request: Request) -> list: async def get_live_settings(request: Request) -> list:
return _controller(request).current_live_settings() return _controller(request).current_live_settings()
+81 -1
View File
@@ -15,6 +15,12 @@ const btnStop = document.getElementById("btn-stop");
const btnTmpRef = document.getElementById("btn-tmp-ref"); const btnTmpRef = document.getElementById("btn-tmp-ref");
const btnApply = document.getElementById("btn-apply"); const btnApply = document.getElementById("btn-apply");
const btnResetHistory = document.getElementById("btn-reset-history"); const btnResetHistory = document.getElementById("btn-reset-history");
const btnLoadConfig = document.getElementById("btn-load-config");
const configSelect = document.getElementById("config-select");
const configActiveEl = document.getElementById("config-active");
const savePathInput = document.getElementById("save-path");
const saveNameInput = document.getElementById("save-name");
const btnSaveDataset = document.getElementById("btn-save-dataset");
const settingsToggle = document.getElementById("settings-toggle"); const settingsToggle = document.getElementById("settings-toggle");
const sidePanel = document.querySelector(".side-panel"); const sidePanel = document.querySelector(".side-panel");
@@ -24,12 +30,15 @@ const settingsNote = document.getElementById("settings-note");
const runningEl = document.getElementById("stat-running"); const runningEl = document.getElementById("stat-running");
const processorEl = document.getElementById("stat-processor"); const processorEl = document.getElementById("stat-processor");
const ringEl = document.getElementById("stat-ring"); const ringEl = document.getElementById("stat-ring");
const scansEl = document.getElementById("stat-scans");
const staleEl = document.getElementById("stat-stale"); const staleEl = document.getElementById("stat-stale");
const toastEl = document.getElementById("toast"); const toastEl = document.getElementById("toast");
/* ---- state ------------------------------------------------------- */ /* ---- state ------------------------------------------------------- */
let pendingPng = null; // newest PNG (base64), shown on the next animation frame let pendingPng = null; // newest PNG (base64), shown on the next animation frame
let lastFrameTs = 0; // performance.now() of the last received frame let lastFrameTs = 0; // performance.now() of the last received frame
let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline)
let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits
/* ---- helpers ----------------------------------------------------- */ /* ---- helpers ----------------------------------------------------- */
function toast(message, isError) { function toast(message, isError) {
@@ -245,6 +254,63 @@ async function loadSettings() {
} }
} }
/* ---- config profile --------------------------------------------- */
// Loading a config does exactly what the desktop "Load Config" button does. The file
// list comes from the server's run_configs/ directory (the browser has no file access),
// and loading is gated to a stopped pipeline — mirroring the desktop precondition.
function updateLoadButtonState() {
btnLoadConfig.disabled = pipelineRunning || configSelect.options.length === 0;
}
async function loadConfigList() {
try {
const { names = [] } = await api("/api/configs");
const previous = configSelect.value;
configSelect.innerHTML = "";
for (const name of names) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
configSelect.appendChild(opt);
}
if (names.includes(previous)) configSelect.value = previous; // keep the operator's choice
updateLoadButtonState();
} catch (err) {
toast("Could not load config list: " + err.message, true);
}
}
btnLoadConfig.addEventListener("click", async () => {
const name = configSelect.value;
if (!name) return;
btnLoadConfig.disabled = true;
try {
await api("/api/load_config", { name });
toast(`Config loaded: ${name}`); // the new settings schema arrives via the live status push
} catch (err) {
toast(err.message, true);
} finally {
updateLoadButtonState();
}
});
/* ---- save dataset ----------------------------------------------- */
// Saves to the path in the field, mirroring the desktop "Save Dataset" button exactly.
btnSaveDataset.addEventListener("click", async () => {
btnSaveDataset.disabled = true;
try {
await api("/api/save_dataset", {
path: savePathInput.value.trim(),
name: saveNameInput.value.trim(),
});
toast("Save requested"); // a radar-config prefix is prepended to the name server-side
} catch (err) {
toast(err.message, true);
} finally {
btnSaveDataset.disabled = false;
}
});
/* ---- status ------------------------------------------------------ */ /* ---- status ------------------------------------------------------ */
function setStat(el, label, value, cls) { function setStat(el, label, value, cls) {
el.className = "stat" + (cls ? " " + cls : ""); el.className = "stat" + (cls ? " " + cls : "");
@@ -253,13 +319,26 @@ function setStat(el, label, value, cls) {
} }
function applyStatus(s) { function applyStatus(s) {
if ("running" in s) if ("running" in s) {
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off"); setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
pipelineRunning = !!s.running;
updateLoadButtonState();
}
if ("active_config" in s)
configActiveEl.textContent = s.active_config ? `current: ${s.active_config}` : "";
if ("save_path" in s && !saveFieldsPrefilled) {
savePathInput.value = s.save_path || ""; // seed once; don't clobber later edits
saveNameInput.value = s.save_name || "";
saveFieldsPrefilled = true;
}
if ("processor_running" in s) if ("processor_running" in s)
setStat(processorEl, "processor", s.processor_running ? "yes" : "no", setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
s.processor_running ? "ok" : "off"); s.processor_running ? "ok" : "off");
if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—", if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—",
s.ring_name ? "" : "off"); s.ring_name ? "" : "off");
if ("raw_count" in s)
setStat(scansEl, "scans",
`raw ${s.raw_count} / preprocessed ${s.preprocessed_count} / results ${s.result_count}`);
} }
/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */ /* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */
@@ -305,6 +384,7 @@ async function init() {
settingsNote.textContent = "Status unavailable: " + err.message; settingsNote.textContent = "Status unavailable: " + err.message;
} }
await loadSettings(); await loadSettings();
await loadConfigList();
connectWs(); connectWs();
} }
init(); init();
+21
View File
@@ -28,6 +28,26 @@
</section> </section>
<aside class="side-panel"> <aside class="side-panel">
<div class="config-section">
<div class="config-title">Config profile</div>
<div class="config-row">
<select id="config-select" class="config-control" aria-label="Run config"></select>
<button id="btn-load-config" class="btn">Load</button>
</div>
<div class="config-active" id="config-active"></div>
</div>
<div class="config-section">
<div class="config-title">Save dataset</div>
<div class="config-row">
<input id="save-path" class="config-control" type="text" placeholder="Save path" aria-label="Save path" />
</div>
<div class="config-row">
<input id="save-name" class="config-control" type="text" placeholder="Name (optional)" aria-label="Save name" />
<button id="btn-save-dataset" class="btn">Save</button>
</div>
</div>
<div class="panel-head" id="settings-toggle"> <div class="panel-head" id="settings-toggle">
<span class="panel-title">Processor settings</span> <span class="panel-title">Processor settings</span>
<span class="chevron" id="settings-chevron"></span> <span class="chevron" id="settings-chevron"></span>
@@ -47,6 +67,7 @@
<span class="stat" id="stat-running">running: —</span> <span class="stat" id="stat-running">running: —</span>
<span class="stat" id="stat-processor">processor: —</span> <span class="stat" id="stat-processor">processor: —</span>
<span class="stat" id="stat-ring">ring: —</span> <span class="stat" id="stat-ring">ring: —</span>
<span class="stat" id="stat-scans">scans: —</span>
<span class="stat" id="stat-stale">live</span> <span class="stat" id="stat-stale">live</span>
</footer> </footer>
+29
View File
@@ -134,6 +134,35 @@ body {
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
/* Config profile picker (top of the side panel) */
.config-section {
padding: 10px 12px;
border-bottom: 1px solid var(--border-soft);
}
.config-title { font-weight: 600; color: var(--status); margin-bottom: 8px; }
.config-row { display: flex; gap: 8px; }
.config-row + .config-row { margin-top: 8px; }
.config-control {
flex: 1;
min-width: 0;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 7px;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
}
.config-control:focus { outline: none; border-color: var(--accent); }
.config-active {
margin-top: 6px;
min-height: 14px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
word-break: break-all;
}
.panel-head { .panel-head {
display: flex; display: flex;
align-items: center; align-items: center;
+340
View File
@@ -0,0 +1,340 @@
{
"radar": {
"model": "librevna",
"serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
"driver_mode": "mock",
"mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": {
"slave_serials": [],
"force_external_reference": false,
"recovery_attempts": 3
},
"kamil_adc": {
"project_dir": "",
"executable_path": "",
"tty_path": "",
"args": [],
"env": {},
"startup_timeout_s": 5.0,
"sweep_timeout_s": 5.0,
"stop_timeout_s": 2.0
},
"laser_control": {
"enabled": false,
"port": "",
"mode": "manual",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2560,
"pi_coeff2_i": 128,
"manual": {
"temp1": 25.0,
"temp2": 25.0,
"current1": 30.0,
"current2": 30.0
},
"variation": {
"variation_type": "CHANGE_CURRENT_LD1",
"static_temp1": 25.0,
"static_temp2": 25.0,
"static_current1": 30.0,
"static_current2": 30.0,
"min_value": 30.0,
"max_value": 35.0,
"step": 0.1,
"time_step": 20,
"delay_time": 3
}
},
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0,
"points": 201
}
},
"switches": {
"port1": {
"name": "port1",
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 1,
"positions": 2,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 17,
"pin_b": 27,
"invert_logic": false
},
"port2": {
"name": "port2",
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 2,
"positions": 4,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 22,
"pin_b": 23,
"invert_logic": false
}
},
"control_button": {
"enabled": false,
"gpio_chip": "/dev/gpiochip0",
"pin": -1,
"active_low": true,
"bias": "",
"debounce_ms": 50,
"action": "capture_tmp_reference"
},
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"continuous": true,
"processing_live_config_path": "python_app/runtime/processing_live.json",
"locator_server": {
"device_id": 3,
"protocol_version": 1,
"host": "0.0.0.0",
"port": 8888,
"max_payload_bytes": 65536,
"client_queue_size": 32,
"logger_name": "locator_runtime"
},
"combos": [
{
"input": 0,
"output": 0
},
{
"input": 1,
"output": 0
},
{
"input": 2,
"output": 0
},
{
"input": 3,
"output": 0
},
{
"input": 0,
"output": 1
},
{
"input": 1,
"output": 1
},
{
"input": 2,
"output": 1
},
{
"input": 3,
"output": 1
}
]
},
"preprocess": {
"s21": {
"calibration": {
"set_name": "smoke_cal",
"bundle_path": ""
},
"reference": {
"set_name": "smoke_ref",
"bundle_path": ""
}
},
"s11": {
"calibration": {
"open": {
"set_name": "",
"bundle_path": ""
},
"short": {
"set_name": "",
"bundle_path": ""
},
"load": {
"set_name": "",
"bundle_path": ""
}
},
"reference": {
"set_name": "",
"bundle_path": ""
}
},
"notch": {
"enabled": true,
"bands_hz": [],
"taper_width_hz": 40000000.0,
"taper_type": "cosine"
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
"x_m": 0.905,
"y_m": 0.0,
"z_m": 0.0
},
{
"output_pos": 1,
"x_m": -0.905,
"y_m": 0.0,
"z_m": 0.0
}
],
"rx_geometry": [
{
"input_pos": 0,
"x_m": -0.18,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 1,
"x_m": 0.485,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 2,
"x_m": -0.49,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 3,
"x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
}
]
},
"rings": {
"raw": {
"name": "/radar_raw_simulator",
"capacity": 50,
"slot_size_bytes": 2097152
},
"raw_tap": {
"name": "/radar_raw_tap_simulator",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed": {
"name": "/radar_preprocessed_simulator",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed_tap": {
"name": "/radar_preprocessed_tap_simulator",
"capacity": 50,
"slot_size_bytes": 2097152
},
"results": {
"name": "/radar_results_simulator",
"capacity": 50,
"slot_size_bytes": 2097152
}
},
"gui": {
"version": 1,
"switches": {
"combo_mode": "text",
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1",
"single_input": "0",
"single_output": "0"
},
"processing": {
"selected_mode": "gpr",
"pass_through": {
"show_magnitude": true,
"show_phase": false,
"combo_filter": "",
"fixed_y_enabled": false,
"y_min_db": -100.0,
"y_max_db": 0.0
},
"bscan": {
"axis": "abs",
"cut_m": 0.0,
"max_depth_m": 3.0,
"gain": 1.0,
"start_freq_mhz": 100.0,
"stop_freq_mhz": 6000.0,
"subtract_mean_ascan": false
},
"gpr": {
"input_positions": "0,1,2,3",
"output_positions": "0,1",
"min_depth_m": 2.0,
"max_depth_m": 14.0,
"range_comp_power": 0.28,
"angle_comp_power": 0.1,
"score_mode": "combined",
"max_detected_objects_to_draw": 5,
"draw_top_m_objects": 2,
"start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0,
"background_subtract_enabled": true,
"background_mean_count": 10,
"remove_sidelobe_objects_enabled": false,
"imaging_plane_y_m": 0.0,
"render_mode": "heatmap",
"min_visible_score": 0.0,
"visible_x_min_m": -2.0,
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
},
"legacy_gpr": {
"mode": "point",
"input_positions": "0,1,2,3",
"output_positions": "0,1",
"min_depth_m": 2.0,
"max_depth_m": 14.0,
"comp_power": 0.2,
"start_freq_mhz": 3000.0,
"stop_freq_mhz": 6000.0,
"speed_m_s": 0.0,
"ignore_socket_speed_enabled": false,
"look_angle_deg": 0.0,
"apply_freq_phase_correction": true,
"reference_mode": "frame_center",
"snr_thresh": 4.5,
"snr_comp_max": 25.0,
"background_subtract_enabled": true,
"background_mean_count": 10,
"render_mode": "heatmap",
"min_visible_pair_count": 1,
"visible_x_min_m": -2.0,
"visible_x_max_m": 2.0,
"visible_z_min_m": 0.0,
"visible_z_max_m": 14.0
}
},
"data_actions": {
"save_count": 10,
"save_path": "python_app/data/snapshots",
"save_name": "snapshot_simulator"
},
"preprocess_dialog": {
"set_name": "smoke_cal",
"radar_config_dir": "",
"use_all_radar_configs": false,
"median_sweep_count": 5
}
}
}