Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
The web UI streams the live GPR view and exposes the pipeline controls of the
desktop app without any Qt dependency. It depends only on the small
:class:`~python_app.webui.controller.WebController` contract; the embedded Qt
bridge (``gui/controllers/app_window_web_mixin.py``) implements that contract by
bridge (``gui/controllers/web_controller.py``) implements that contract by
forwarding to the AppWindow's existing buttons, so no control flow is duplicated.
- :mod:`controller` — the Qt-free control/read contract.
+16 -7
View File
@@ -2,10 +2,9 @@
The web layer (``app``/``routes``/``streaming``) is deliberately free of any Qt
or hardware knowledge: it talks only to a :class:`WebController`. The embedded
bridge in ``gui/controllers/app_window_web_mixin.py`` implements this protocol by
forwarding control actions to the AppWindow's *existing* buttons and exposing
read-only snapshots — so the very same desktop logic backs the browser, with no
duplicated control flow.
bridge in ``gui/controllers/web_controller.py`` implements this protocol by
dispatching actions to the shared GUI controllers and exposing read-only
snapshots. The same controller methods serve desktop buttons and the browser.
"""
from __future__ import annotations
@@ -57,8 +56,12 @@ class WebController(Protocol):
def remove_last_measurement(self) -> None:
"""Remove the most recent measurement (the desktop button)."""
def clear_history(self) -> None:
"""Clear all runtime measurements (the desktop button)."""
def apply_live_settings(self, fields: dict) -> list:
"""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)."""
@@ -67,19 +70,25 @@ class WebController(Protocol):
"""Save up to 1000 recent measurements to ``path``/``name``."""
def select_combo(self, input_pos: int, output_pos: int) -> None:
"""Select the matrix B-scan input/output pair shown by the desktop."""
"""Select the B-scan input/output pair shown by the desktop."""
def next_combo(self) -> None:
"""Advance the matrix B-scan display to the next input/output pair."""
"""Advance the B-scan display to the next available input/output pair."""
def current_live_settings(self) -> list:
"""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:
"""Return a snapshot of pipeline/run state."""
...
def peek_frame(self) -> dict | None:
"""Return the latest rendered-plot frame (PNG of the Qt plot), or ``None``."""
"""Read a cached PNG frame without rendering or blocking; initially ``None``."""
def set_frame_streaming(self, enabled: bool) -> None:
"""Enable plot capture only while at least one WebSocket client is connected."""
+10 -7
View File
@@ -99,6 +99,13 @@ async def post_remove_last(request: Request) -> dict:
return controller.status()
@router.post("/api/clear_history")
async def post_clear_history(request: Request) -> dict:
controller = _controller(request)
await _run_action(controller.clear_history)
return controller.status()
@router.get("/api/configs")
async def get_configs(request: Request) -> dict:
return {"names": _controller(request).list_configs()}
@@ -157,18 +164,14 @@ async def ws(websocket: WebSocket) -> None:
"""Stream frames and status to one client until it disconnects."""
await websocket.accept()
broadcaster: RingBroadcaster = websocket.app.state.broadcaster
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1)
broadcaster.register(queue)
client = broadcaster.register()
logger.info("Web UI client connected")
try:
frame = await asyncio.to_thread(websocket.app.state.controller.peek_frame)
if frame is not None:
await websocket.send_json(frame)
while True:
await websocket.send_json(await queue.get())
await websocket.send_json(await client.get())
except WebSocketDisconnect:
logger.info("Web UI client disconnected")
finally:
broadcaster.unregister(queue)
broadcaster.unregister(client)
with contextlib.suppress(Exception):
await websocket.close()
+12 -5
View File
@@ -102,7 +102,8 @@ async function changeDisplayCombo(path, body) {
} catch (err) {
toast(err.message, true);
} finally {
displayComboSelect.disabled = btnNextCombo.disabled = false;
displayComboSelect.disabled = displayComboSelect.options.length === 0;
btnNextCombo.disabled = displayComboSelect.options.length < 2;
}
}
@@ -267,7 +268,8 @@ function buildSettingsForm(schema) {
function collectFields() {
const out = {};
for (const key in fieldInputs) {
const { el, kind } = fieldInputs[key];
const { el, kind, dirty } = fieldInputs[key];
if (!dirty) continue; // unchanged browser values must not overwrite newer desktop edits
if (kind === "geometry") out[key] = el.getRecords();
else if (kind === "bool") out[key] = el.checked;
else if (kind === "int" || kind === "float") {
@@ -296,7 +298,9 @@ function clearDirty() {
async function applyOne(field, value) {
try {
await api("/api/live_settings", { [field]: value });
const schema = await api("/api/live_settings", { [field]: value });
if (fieldInputs[field]) fieldInputs[field].dirty = false;
applySettings(schema);
} catch (err) {
toast(err.message, true);
}
@@ -321,8 +325,9 @@ function applySettings(schema) {
btnApply.addEventListener("click", async () => {
btnApply.disabled = true;
try {
await api("/api/live_settings", collectFields());
const schema = await api("/api/live_settings", collectFields());
clearDirty(); // applied; let live pushes update the form again
applySettings(schema);
settingsNote.textContent = "Applied.";
} catch (err) {
settingsNote.textContent = err.message;
@@ -335,7 +340,7 @@ btnApply.addEventListener("click", async () => {
btnResetHistory.addEventListener("click", async () => {
btnResetHistory.disabled = true;
try {
await api("/api/live_settings", { history_command: "clear_all" });
await api("/api/clear_history");
settingsNote.textContent = "History reset requested.";
} catch (err) {
settingsNote.textContent = err.message;
@@ -464,6 +469,8 @@ function applyStatus(s) {
comboOptionsSignature = signature;
}
displayComboSelect.value = `${combo.input}:${combo.output}`;
displayComboSelect.disabled = options.length === 0;
btnNextCombo.disabled = options.length < 2;
}
if ("running" in s) {
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
+53 -26
View File
@@ -1,10 +1,9 @@
"""Fan-out of pipeline result frames and status to connected web clients.
A single broadcaster task polls the :class:`WebController` off the event loop and
A single broadcaster task reads cached :class:`WebController` snapshots and
pushes the freshest frame (latest-wins) plus a slower status heartbeat to every
registered client. Each client is a bounded ``asyncio.Queue`` with a drop-oldest
policy, so a slow socket can never stall the producer or the loop — if a client
falls behind it simply skips intermediate frames and always gets the newest.
registered client. Each client keeps one latest message per type, so a slow
socket skips intermediate frames without losing status or settings updates.
"""
from __future__ import annotations
@@ -24,24 +23,55 @@ _FRAME_INTERVAL_S = 0.05
_STATUS_INTERVAL_S = 1.0
class ClientUpdates:
"""At most one pending frame, status and settings update for one socket."""
def __init__(self) -> None:
self._pending: dict[str, dict] = {}
self._ready = asyncio.Event()
def put(self, message: dict) -> None:
self._pending[message["type"]] = message
self._ready.set()
async def get(self) -> dict:
await self._ready.wait()
message = self._pending.pop(next(iter(self._pending)))
if not self._pending:
self._ready.clear()
return message
class RingBroadcaster:
"""Polls the controller and fans frames/status out to all WebSocket clients."""
def __init__(self, controller: WebController) -> None:
self._controller = controller
self._clients: set[asyncio.Queue[dict]] = set()
self._clients: set[ClientUpdates] = set()
self._task: asyncio.Task[None] | None = None
self._last_frame_seq: int | None = None
self._last_settings: dict | None = None
self._last_settings: list | None = None
def register(self, queue: asyncio.Queue[dict]) -> None:
"""Add a client queue to receive subsequent frames and status."""
self._clients.add(queue)
def register(self) -> ClientUpdates:
"""Seed every connection with current snapshots, then stream updates."""
client = ClientUpdates()
frame = self._controller.peek_frame()
if frame is not None:
client.put(frame)
client.put(self._status_message())
client.put({"type": "settings", "schema": self._controller.current_live_settings()})
if not self._clients:
self._controller.set_frame_streaming(True)
self._clients.add(client)
logger.debug("Registered web client queue (clients=%d)", len(self._clients))
return client
def unregister(self, queue: asyncio.Queue[dict]) -> None:
def unregister(self, client: ClientUpdates) -> None:
"""Remove a client queue; safe to call more than once."""
self._clients.discard(queue)
was_connected = bool(self._clients)
self._clients.discard(client)
if was_connected and not self._clients:
self._controller.set_frame_streaming(False)
logger.debug("Unregistered web client queue (clients=%d)", len(self._clients))
def start(self) -> None:
@@ -59,6 +89,8 @@ class RingBroadcaster:
async def stop(self) -> None:
"""Cancel the polling task and wait for it to unwind."""
self._controller.set_frame_streaming(False)
self._clients.clear()
if self._task is None:
return
self._task.cancel()
@@ -68,13 +100,9 @@ class RingBroadcaster:
logger.info("Ring broadcaster stopped")
def _publish(self, message: dict) -> None:
"""Push a message to every client, dropping the oldest on a full queue."""
for queue in self._clients:
if queue.full():
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(message)
"""Replace only older messages of the same type."""
for client in self._clients:
client.put(message)
def _status_message(self) -> dict:
"""Build a status broadcast from the controller's current state."""
@@ -86,22 +114,21 @@ class RingBroadcaster:
next_status = loop.time()
while True:
try:
# peek_frame may touch shared memory / NumPy, so keep it off the loop.
frame = await loop.run_in_executor(None, self._controller.peek_frame)
if frame is not None and frame["seq"] != self._last_frame_seq:
self._last_frame_seq = frame["seq"]
self._publish(frame)
now = loop.time()
if now >= next_status:
if self._clients and now >= next_status:
self._publish(self._status_message())
# Push live settings (Qt -> web) only when they change, so the
# browser form mirrors desktop edits in real time without churn.
settings = self._controller.current_live_settings()
if settings != self._last_settings:
self._last_settings = settings
self._publish({"type": "settings", "schema": settings})
self._last_settings = settings
next_status = now + _STATUS_INTERVAL_S
if self._clients:
frame = self._controller.peek_frame()
if frame is not None and frame["seq"] != self._last_frame_seq:
self._publish(frame)
self._last_frame_seq = frame["seq"]
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - one bad frame must not stop streaming