Rework radar system
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""Serve GUI snapshots and marshal browser controls onto the Qt thread."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget
|
||||
|
||||
from python_app.gui.settings_defaults import env_enabled
|
||||
from python_app.gui.controllers.config_controller import web_apply_field_names
|
||||
from python_app.webui.controller import WebActionError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_app.gui.controllers.live_settings_controller import LiveSettingsController
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.preprocess_controller import PreprocessController
|
||||
from python_app.gui.controllers.profile_controller import ProfileController
|
||||
from python_app.gui.controllers.snapshot_controller import SnapshotController
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_WEB_ACTION_TIMEOUT_S = 30.0
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
_LIVE_FIELD_NAMES = web_apply_field_names()
|
||||
|
||||
|
||||
class _WebActionCall:
|
||||
"""A timeout can cancel queued work; running work must never be retried blindly."""
|
||||
|
||||
def __init__(self, action: Callable[[], object]) -> None:
|
||||
self.action = action
|
||||
self.done = threading.Event()
|
||||
self.error: str | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._started = False
|
||||
|
||||
def begin(self) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set() or self._started:
|
||||
return False
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
def finish(self, error: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if not self.done.is_set():
|
||||
self.error = error
|
||||
self.done.set()
|
||||
|
||||
def cancel(self, error: str, *, queued_only: bool = True) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set():
|
||||
return True
|
||||
if queued_only and self._started:
|
||||
return False
|
||||
self.error = error
|
||||
self.done.set()
|
||||
return True
|
||||
|
||||
|
||||
def web_ui_port() -> int:
|
||||
try:
|
||||
port = int(os.environ.get("RADAR_SYSTEM_WEBUI_PORT", "8080"))
|
||||
return port if 1 <= port <= 65535 else 8080
|
||||
except ValueError:
|
||||
return 8080
|
||||
|
||||
|
||||
class WebBridge(QObject):
|
||||
"""Own web lifecycle, queued actions, immutable snapshots and change-gated PNGs."""
|
||||
|
||||
_action_requested = pyqtSignal(object)
|
||||
_streaming_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
self, parent: QWidget, run_configs_dir: Path, pipeline: PipelineController,
|
||||
preprocess: PreprocessController, snapshots: SnapshotController, profile: ProfileController,
|
||||
live: LiveSettingsController, log: LogController, *,
|
||||
save_path: QLineEdit, save_name: QLineEdit, record_count: QSpinBox,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent, self._run_configs_dir = parent, run_configs_dir
|
||||
self._pipeline, self._preprocess, self._snapshots = pipeline, preprocess, snapshots
|
||||
self._profile, self._live, self._log = profile, live, log
|
||||
self._save_path, self._save_name, self._record_count = save_path, save_name, record_count
|
||||
self._server: WebUiServer | None = None
|
||||
self._status: dict = {}
|
||||
self._live_settings: list[dict] = []
|
||||
self._frame: dict | None = None
|
||||
self._pending_lock = threading.Lock()
|
||||
self._pending_calls: set[_WebActionCall] = set()
|
||||
self._closed = self._streaming = self._grabbing = False
|
||||
self._dirty = True
|
||||
self._last_png: str | None = None
|
||||
self._last_grab_s = 0.0
|
||||
self._frame_seq = self._snapshot_errors = 0
|
||||
self._action_requested.connect(self._run_action)
|
||||
self._streaming_changed.connect(self._set_streaming)
|
||||
plots = pipeline.plots
|
||||
plots.stack.currentChanged.connect(self._mark_dirty)
|
||||
for plot in plots.stack.findChildren(pg.PlotWidget):
|
||||
scene = plot.scene()
|
||||
assert scene is not None
|
||||
scene.changed.connect(self._mark_dirty)
|
||||
plot.sigDeviceRangeChanged.connect(self._mark_dirty)
|
||||
|
||||
def start_server(self) -> None:
|
||||
if self._closed or self._server is not None:
|
||||
return
|
||||
try:
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
if env_enabled("RADAR_SYSTEM_HEADLESS"):
|
||||
self._parent.resize(1600, 900)
|
||||
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.refresh_snapshot()
|
||||
self._server = WebUiServer(self, port=web_ui_port())
|
||||
self._server.start()
|
||||
except Exception:
|
||||
logger.warning("Failed to start web UI", exc_info=True)
|
||||
if self._server is not None:
|
||||
with suppress(Exception):
|
||||
self._server.stop()
|
||||
self._server = None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._pending_lock:
|
||||
self._closed, self._streaming = True, False
|
||||
pending = tuple(self._pending_calls)
|
||||
for call in pending:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
if self._server is not None:
|
||||
with suppress(Exception):
|
||||
self._server.stop()
|
||||
self._server = None
|
||||
|
||||
def _dispatch(self, action: Callable[[], object]) -> None:
|
||||
call = _WebActionCall(action)
|
||||
with self._pending_lock:
|
||||
if self._closed:
|
||||
raise WebActionError("The application is closing")
|
||||
self._pending_calls.add(call)
|
||||
try:
|
||||
self._action_requested.emit(call)
|
||||
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
|
||||
if not call.cancel("The desktop did not start the action in time; the request was cancelled"):
|
||||
raise WebActionError("The desktop action is still running; check its status before retrying")
|
||||
if call.error is not None:
|
||||
raise WebActionError(call.error)
|
||||
finally:
|
||||
with self._pending_lock:
|
||||
self._pending_calls.discard(call)
|
||||
|
||||
def _run_action(self, call: _WebActionCall) -> None:
|
||||
if self._closed or self._pipeline.closing:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
return
|
||||
if not call.begin():
|
||||
return
|
||||
error = None
|
||||
try:
|
||||
with self._log.capture_errors() as captured:
|
||||
call.action()
|
||||
error = captured[0] if captured else None
|
||||
self.refresh_snapshot()
|
||||
except Exception as exc:
|
||||
error = self._log.exception_summary(exc)
|
||||
finally:
|
||||
call.finish(error)
|
||||
|
||||
# The web thread reads only replaced snapshots; all widget reads happen below.
|
||||
def status(self) -> dict:
|
||||
return dict(self._status)
|
||||
|
||||
def current_live_settings(self) -> list[dict]:
|
||||
return list(self._live_settings)
|
||||
|
||||
def peek_frame(self) -> dict | None:
|
||||
return self._frame
|
||||
|
||||
def list_configs(self) -> list[str]:
|
||||
return sorted(path.name for path in self._run_configs_dir.glob("*.json") if path.is_file())
|
||||
|
||||
def start(self) -> None:
|
||||
self._dispatch(self._pipeline.start)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._dispatch(self._pipeline.stop)
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self._dispatch(lambda: self._pipeline.start(single_capture=True))
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self._dispatch(self._preprocess.capture_tmp_reference)
|
||||
|
||||
def remove_last_measurement(self) -> None:
|
||||
self._dispatch(lambda: self._snapshots.delete_history(remove_last=True))
|
||||
|
||||
def clear_history(self) -> None:
|
||||
self._dispatch(lambda: self._snapshots.delete_history(remove_last=False))
|
||||
|
||||
def start_recording(self, path: str, name: str, count: int) -> None:
|
||||
if type(count) is not int or count < 1:
|
||||
raise ValueError("Recording count must be a positive integer")
|
||||
self._dispatch(lambda: self._start_recording(path, name, count))
|
||||
|
||||
def _start_recording(self, path: str, name: str, count: int) -> None:
|
||||
low, high = self._record_count.minimum(), self._record_count.maximum()
|
||||
if not low <= count <= high:
|
||||
raise ValueError(f"Recording count must be an integer from {low} to {high}")
|
||||
self._set_destination(path, name)
|
||||
self._record_count.setValue(count)
|
||||
self._snapshots.start_recording(count)
|
||||
|
||||
def _set_destination(self, path: str, name: str) -> None:
|
||||
if path.strip():
|
||||
self._save_path.setText(path)
|
||||
self._save_name.setText(name)
|
||||
|
||||
def save_dataset(self, path: str, name: str) -> None:
|
||||
self._dispatch(lambda: self._save_dataset(path, name))
|
||||
|
||||
def _save_dataset(self, path: str, name: str) -> None:
|
||||
self._set_destination(path, name)
|
||||
self._snapshots.save()
|
||||
|
||||
def load_config(self, name: str) -> None:
|
||||
def load() -> None:
|
||||
path = self._run_configs_dir / name
|
||||
if not name or name != Path(name).name or path.suffix.lower() != ".json" or not path.is_file():
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self._profile.load(path)
|
||||
self._dispatch(load)
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> list[dict]:
|
||||
fields = dict(fields)
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self._dispatch(lambda: self._live.apply_web(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
def select_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
if any(type(pos) is not int or pos < 0 for pos in (input_pos, output_pos)):
|
||||
raise ValueError("Channel positions must be nonnegative integers")
|
||||
self._dispatch(lambda: self._pipeline.plots.select_combo(input_pos, output_pos))
|
||||
|
||||
def next_combo(self) -> None:
|
||||
self._dispatch(self._pipeline.plots.select_next_combo)
|
||||
|
||||
def refresh_snapshot(self) -> None:
|
||||
if self._closed or self._pipeline.closing:
|
||||
return
|
||||
try:
|
||||
pipeline, plots = self._pipeline, self._pipeline.plots
|
||||
input_pos, output_pos = plots.display_key or (0, 0)
|
||||
mode = self._live.active_mode
|
||||
self._status = {
|
||||
"running": pipeline.running, "processor_running": pipeline.processor_running,
|
||||
"ring_name": self._profile.state.config.rings.results.name,
|
||||
"active_config": self._profile.state.path.name,
|
||||
"save_path": self._save_path.text(), "save_name": self._save_name.text(),
|
||||
"record_count": self._record_count.value(), "recording": pipeline.recording.status(),
|
||||
"raw_count": len(pipeline.history.raw), "preprocessed_count": len(pipeline.history.preprocessed),
|
||||
"result_count": len(pipeline.history.results),
|
||||
"sar3d_status": plots.sar3d.status_label.text() if mode == "sar3d" else "",
|
||||
"display_combo": {"enabled": mode == "bscan", "input": input_pos, "output": output_pos,
|
||||
"options": sorted(plots.bscan.history_by_combo)},
|
||||
}
|
||||
self._live_settings = self._live.config.web_schema()
|
||||
frame = self._grab_frame_if_due()
|
||||
if frame is not None:
|
||||
self._frame = frame
|
||||
except Exception:
|
||||
self._snapshot_errors += 1
|
||||
if self._snapshot_errors % 200 == 1:
|
||||
logger.warning("Web UI snapshot refresh failed", exc_info=True)
|
||||
|
||||
def set_frame_streaming(self, enabled: bool) -> None:
|
||||
if not self._closed:
|
||||
self._streaming_changed.emit(enabled)
|
||||
|
||||
def _set_streaming(self, enabled: bool) -> None:
|
||||
if not self._closed:
|
||||
self._streaming = enabled
|
||||
if enabled:
|
||||
self._dirty = True
|
||||
|
||||
def _mark_dirty(self, *_args: object) -> None:
|
||||
if not self._closed and not self._grabbing:
|
||||
self._dirty = True
|
||||
|
||||
def _grab_frame_if_due(self) -> dict | None:
|
||||
if self._closed or not self._streaming or not self._dirty:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if now - self._last_grab_s < _GRAB_INTERVAL_S:
|
||||
return None
|
||||
self._last_grab_s, self._dirty, self._grabbing = now, False, True
|
||||
try:
|
||||
png = self._grab_png()
|
||||
except Exception:
|
||||
self._dirty = True
|
||||
raise
|
||||
finally:
|
||||
self._grabbing = False
|
||||
if png is None or png == self._last_png:
|
||||
return None
|
||||
self._last_png = png
|
||||
self._frame_seq += 1
|
||||
return {"type": "frame", "seq": self._frame_seq, "mode": self._live.active_mode, "png_b64": png}
|
||||
|
||||
def _grab_png(self) -> str | None:
|
||||
widget = self._pipeline.plots.capture_widget
|
||||
if widget is None or widget.width() <= 0 or widget.height() <= 0:
|
||||
return None
|
||||
pixmap = widget.grab()
|
||||
if pixmap.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "PNG")
|
||||
return base64.b64encode(buffer.data().data()).decode()
|
||||
Reference in New Issue
Block a user