web UI added and refactoring done

This commit is contained in:
Ayzen
2026-06-06 00:06:30 +03:00
parent 3c30a12d4a
commit af6005d68f
65 changed files with 3630 additions and 4720 deletions
+15
View File
@@ -0,0 +1,15 @@
"""Isolated, Qt-free web frontend for the radar_system Pi appliance.
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
forwarding to the AppWindow's existing buttons, so no control flow is duplicated.
- :mod:`controller` — the Qt-free control/read contract.
- :mod:`streaming` — latest-wins fan-out of plot frames/status/settings to clients.
- :mod:`routes` / :mod:`app` — FastAPI surface and application factory.
- :mod:`server` — run the app on a background thread inside the host process.
"""
from __future__ import annotations
+43
View File
@@ -0,0 +1,43 @@
"""FastAPI application factory for the embedded radar web UI.
The controller (the Qt bridge that forwards to the AppWindow) is created and
owned by the host process and injected here. The app's only owned resource is the
:class:`RingBroadcaster` polling task, created and torn down by the lifespan. The
static single-page frontend is mounted at ``/`` and the JSON/WS API under
``/api`` and ``/ws``.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from python_app.webui.controller import WebController
from python_app.webui.routes import router
from python_app.webui.streaming import RingBroadcaster
_STATIC_DIR = Path(__file__).resolve().parent / "static"
def create_app(controller: WebController) -> FastAPI:
"""Build the FastAPI app that serves and streams for ``controller``."""
@asynccontextmanager
async def lifespan(app: FastAPI):
broadcaster = RingBroadcaster(controller)
app.state.controller = controller
app.state.broadcaster = broadcaster
broadcaster.start()
try:
yield
finally:
await broadcaster.stop()
app = FastAPI(title="Radar Web UI", lifespan=lifespan)
app.include_router(router)
# Mount the SPA last so the API routes above always take precedence.
app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="static")
return app
+42
View File
@@ -0,0 +1,42 @@
"""The Qt-free contract the web layer depends on.
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.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class WebController(Protocol):
"""Control + read surface the web layer needs; implemented by the Qt bridge."""
def start(self) -> None:
"""Start a continuous run (the desktop "Start" button)."""
def single_capture(self) -> None:
"""Run a single-capture acquisition (the desktop "Single Capture" button)."""
def stop(self) -> None:
"""Stop the running pipeline (the desktop "Stop" button)."""
def capture_tmp_reference(self) -> None:
"""Capture and select a temporary reference (the desktop button)."""
def apply_live_settings(self, fields: dict) -> list:
"""Apply live processor settings; returns the current settings schema."""
def current_live_settings(self) -> list:
"""Return the live-settings schema (built from the Qt widgets)."""
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``."""
+87
View File
@@ -0,0 +1,87 @@
"""HTTP and WebSocket routes for the embedded radar web UI.
Every handler is a thin shell over the :class:`WebController` (which forwards to
the AppWindow's existing buttons) and the :class:`RingBroadcaster` (the single
frame source). There is no ownership gating: the web UI lives inside the process
that already owns the hardware, so its controls are simply that process's buttons.
"""
from __future__ import annotations
import asyncio
import contextlib
from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect
from python_app.webui.controller import WebController
from python_app.webui.streaming import RingBroadcaster
router = APIRouter()
def _controller(request: Request) -> WebController:
return request.app.state.controller
@router.get("/api/status")
async def get_status(request: Request) -> dict:
return _controller(request).status()
@router.post("/api/start")
async def post_start(request: Request) -> dict:
controller = _controller(request)
controller.start()
return controller.status()
@router.post("/api/single_capture")
async def post_single_capture(request: Request) -> dict:
controller = _controller(request)
controller.single_capture()
return controller.status()
@router.post("/api/stop")
async def post_stop(request: Request) -> dict:
controller = _controller(request)
controller.stop()
return controller.status()
@router.post("/api/tmp_reference")
async def post_tmp_reference(request: Request) -> dict:
controller = _controller(request)
controller.capture_tmp_reference()
return controller.status()
@router.get("/api/live_settings")
async def get_live_settings(request: Request) -> list:
return _controller(request).current_live_settings()
@router.post("/api/live_settings")
async def post_live_settings(request: Request, fields: dict = Body(default={})) -> list:
try:
return _controller(request).apply_live_settings(fields)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.websocket("/ws")
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)
try:
while True:
await websocket.send_json(await queue.get())
except WebSocketDisconnect:
pass
finally:
broadcaster.unregister(queue)
with contextlib.suppress(Exception):
await websocket.close()
+53
View File
@@ -0,0 +1,53 @@
"""Run the web UI's FastAPI app on a background thread.
The radar app already owns the hardware and the Qt event loop, so the web server
lives in a daemon thread inside that process (uvicorn brings its own asyncio loop
for the thread). Control requests hop back to the Qt main thread via the bridge's
queued signals — the web thread never touches Qt directly.
"""
from __future__ import annotations
import logging
import threading
import uvicorn
from python_app.webui.app import create_app
from python_app.webui.controller import WebController
logger = logging.getLogger(__name__)
class WebUiServer:
"""Owns a uvicorn server bound to a controller, run on a daemon thread."""
def __init__(self, controller: WebController, *, host: str = "0.0.0.0", port: int = 8080) -> None:
"""Build the server for ``controller`` (not started until :meth:`start`)."""
config = uvicorn.Config(create_app(controller), host=host, port=port, log_level="warning")
self._server = uvicorn.Server(config)
self._thread = threading.Thread(target=self._serve, name="radar-webui", daemon=True)
def start(self) -> None:
"""Start serving on the background thread."""
self._thread.start()
def is_alive(self) -> bool:
"""Return whether the server thread is still running."""
return self._thread.is_alive()
def _serve(self) -> None:
"""Run uvicorn, surfacing a startup/runtime failure instead of dying silently.
The bind happens on this thread after ``start()`` has already returned, so a
failure (e.g. the port is taken) would otherwise be invisible.
"""
try:
self._server.run()
except Exception: # noqa: BLE001 - log, never crash the host process
logger.exception("Web UI server thread exited with an error")
def stop(self) -> None:
"""Ask uvicorn to exit and wait briefly for the thread to unwind."""
self._server.should_exit = True
self._thread.join(timeout=5.0)
+298
View File
@@ -0,0 +1,298 @@
"use strict";
/* ------------------------------------------------------------------ *
* Radar System web client.
* Streams the live Qt plot as an image (latest-wins) + REST controls +
* the processor live-settings panel, synced both ways with the desktop.
* ------------------------------------------------------------------ */
/* ---- DOM handles ------------------------------------------------- */
const plotImg = document.getElementById("plot");
const btnStart = document.getElementById("btn-start");
const btnSingle = document.getElementById("btn-single");
const btnStop = document.getElementById("btn-stop");
const btnTmpRef = document.getElementById("btn-tmp-ref");
const btnApply = document.getElementById("btn-apply");
const btnResetHistory = document.getElementById("btn-reset-history");
const settingsToggle = document.getElementById("settings-toggle");
const sidePanel = document.querySelector(".side-panel");
const settingsFields = document.getElementById("settings-fields");
const settingsNote = document.getElementById("settings-note");
const runningEl = document.getElementById("stat-running");
const processorEl = document.getElementById("stat-processor");
const ringEl = document.getElementById("stat-ring");
const staleEl = document.getElementById("stat-stale");
const toastEl = document.getElementById("toast");
/* ---- state ------------------------------------------------------- */
let pendingPng = null; // newest PNG (base64), shown on the next animation frame
let lastFrameTs = 0; // performance.now() of the last received frame
/* ---- helpers ----------------------------------------------------- */
function toast(message, isError) {
toastEl.textContent = message;
toastEl.classList.toggle("error", !!isError);
toastEl.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600);
}
async function api(path, body) {
const opts = { method: body === undefined ? "GET" : "POST" };
if (body !== undefined) {
opts.headers = { "Content-Type": "application/json" };
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
let data = null;
try { data = await res.json(); } catch (_) { /* empty body */ }
if (!res.ok) {
const detail = (data && data.detail) || `HTTP ${res.status}`;
throw new Error(detail);
}
return data;
}
/* ---- controls ---------------------------------------------------- */
function bindControl(button, path, body) {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await api(path, body);
} catch (err) {
toast(err.message, true);
} finally {
button.disabled = false;
}
});
}
bindControl(btnStart, "/api/start", {});
bindControl(btnSingle, "/api/single_capture", {});
bindControl(btnStop, "/api/stop", {});
bindControl(btnTmpRef, "/api/tmp_reference", {});
/* ---- settings panel --------------------------------------------- */
settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed"));
// The form is built ENTIRELY from the schema the server derives from the Qt widgets
// (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing
// and shows only the active mode's fields, so it always mirrors the desktop.
const fieldInputs = {}; // field name -> { el, kind, dirty }
let formSignature = ""; // field names currently in the form (detect mode/structure change)
function makeFieldRow(entry) {
const row = document.createElement("div");
row.className = "field";
const label = document.createElement("label");
label.textContent = entry.name;
label.htmlFor = "f_" + entry.name;
row.appendChild(label);
let el;
if (entry.kind === "bool") {
el = document.createElement("input");
el.type = "checkbox";
el.checked = !!entry.value;
} else if (entry.kind === "select") {
el = document.createElement("select");
for (const opt of entry.options || []) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
el.appendChild(o);
}
el.value = String(entry.value);
} else if (entry.kind === "int" || entry.kind === "float") {
el = document.createElement("input");
el.type = "number";
if (entry.min != null) el.min = entry.min;
if (entry.max != null) el.max = entry.max;
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
el.value = entry.value;
} else {
el = document.createElement("input");
el.type = "text";
el.value = entry.value == null ? "" : String(entry.value);
}
el.id = "f_" + entry.name;
if (entry.enabled === false) el.disabled = true;
// Mark dirty while edited so a live push never overwrites a half-entered value.
const markDirty = () => { fieldInputs[entry.name].dirty = true; };
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
// Switching mode changes which settings are shown — apply it immediately.
if (entry.name === "processor_mode") {
el.addEventListener("change", () => applyOne("processor_mode", el.value));
}
row.appendChild(el);
fieldInputs[entry.name] = { el, kind: entry.kind, dirty: false };
return row;
}
function buildSettingsForm(schema) {
settingsFields.innerHTML = "";
for (const key in fieldInputs) delete fieldInputs[key];
let lastGroup = null;
for (const entry of schema) {
if (entry.group !== lastGroup) {
lastGroup = entry.group;
const heading = document.createElement("div");
heading.className = "group-title";
heading.textContent = entry.group;
settingsFields.appendChild(heading);
}
settingsFields.appendChild(makeFieldRow(entry));
}
formSignature = schema.map((e) => e.name).join(",");
}
function collectFields() {
const out = {};
for (const key in fieldInputs) {
const { el, kind } = fieldInputs[key];
if (kind === "bool") out[key] = el.checked;
else if (kind === "int") out[key] = parseInt(el.value, 10);
else if (kind === "float") out[key] = parseFloat(el.value);
else out[key] = el.value; // select + text (positions are sent as CSV text)
}
return out;
}
function setFieldValue(input, value) {
const { el, kind } = input;
if (kind === "bool") el.checked = !!value;
else if (kind === "select") el.value = String(value);
else el.value = value == null ? "" : value;
}
function clearDirty() {
for (const key in fieldInputs) fieldInputs[key].dirty = false;
}
async function applyOne(field, value) {
try {
await api("/api/live_settings", { [field]: value });
} catch (err) {
toast(err.message, true);
}
}
// Update the form from a schema pushed by the desktop. Rebuild if the field set
// changed (e.g. the mode switched); otherwise update values in place without
// clobbering a field the operator is editing here.
function applySettings(schema) {
if (schema.map((e) => e.name).join(",") !== formSignature) {
buildSettingsForm(schema);
return;
}
for (const entry of schema) {
const input = fieldInputs[entry.name];
if (!input || input.el === document.activeElement || input.dirty) continue;
setFieldValue(input, entry.value);
if (entry.enabled !== undefined) input.el.disabled = entry.enabled === false;
}
}
btnApply.addEventListener("click", async () => {
btnApply.disabled = true;
try {
await api("/api/live_settings", collectFields());
clearDirty(); // applied; let live pushes update the form again
settingsNote.textContent = "Applied.";
} catch (err) {
settingsNote.textContent = err.message;
toast(err.message, true);
} finally {
btnApply.disabled = false;
}
});
btnResetHistory.addEventListener("click", async () => {
btnResetHistory.disabled = true;
try {
await api("/api/live_settings", { history_command: "clear_all" });
settingsNote.textContent = "History reset requested.";
} catch (err) {
settingsNote.textContent = err.message;
toast(err.message, true);
} finally {
btnResetHistory.disabled = false;
}
});
async function loadSettings() {
try {
const cfg = await api("/api/live_settings");
buildSettingsForm(cfg);
} catch (err) {
settingsNote.textContent = "Could not load settings: " + err.message;
}
}
/* ---- status ------------------------------------------------------ */
function setStat(el, label, value, cls) {
el.className = "stat" + (cls ? " " + cls : "");
el.innerHTML = label + ": <b></b>";
el.querySelector("b").textContent = value;
}
function applyStatus(s) {
if ("running" in s)
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
if ("processor_running" in s)
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
s.processor_running ? "ok" : "off");
if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—",
s.ring_name ? "" : "off");
}
/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */
function renderLoop() {
if (pendingPng !== null) {
// Show exactly what the desktop draws; the browser scales it to fit (CSS).
plotImg.src = "data:image/png;base64," + pendingPng;
pendingPng = null;
}
// Stale indicator (>2s without a frame).
const stale = performance.now() - lastFrameTs > 2000;
staleEl.classList.toggle("stale", stale && lastFrameTs > 0);
staleEl.textContent = lastFrameTs === 0 ? "no data" : stale ? "stale" : "live";
requestAnimationFrame(renderLoop);
}
/* ---- WebSocket --------------------------------------------------- */
function connectWs() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (msg.type === "frame") {
pendingPng = msg.png_b64; // latest-wins; rAF swaps the image
lastFrameTs = performance.now();
} else if (msg.type === "status") {
applyStatus(msg);
} else if (msg.type === "settings") {
applySettings(msg.schema); // live desktop schema mirrors into the form
}
};
ws.onclose = () => setTimeout(connectWs, 1500);
ws.onerror = () => ws.close();
}
/* ---- boot -------------------------------------------------------- */
async function init() {
requestAnimationFrame(renderLoop);
try {
applyStatus(await api("/api/status"));
} catch (err) {
settingsNote.textContent = "Status unavailable: " + err.message;
}
await loadSettings();
connectWs();
}
init();
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radar System</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">Radar System</div>
<div class="controls">
<button id="btn-start" class="btn">Start</button>
<button id="btn-single" class="btn">Single Capture</button>
<button id="btn-stop" class="btn">Stop</button>
<button id="btn-tmp-ref" class="btn">Tmp Reference</button>
</div>
</header>
<main class="layout">
<section class="plot-panel">
<div class="plot-head">
<span class="plot-title">Processor output</span>
</div>
<div class="canvas-wrap">
<img id="plot" class="plot-img" alt="Live processor plot" />
</div>
</section>
<aside class="side-panel">
<div class="panel-head" id="settings-toggle">
<span class="panel-title">Processor settings</span>
<span class="chevron" id="settings-chevron"></span>
</div>
<div class="panel-body" id="settings-body">
<div id="settings-fields" class="settings-fields"></div>
<div class="settings-actions">
<button id="btn-apply" class="btn primary">Apply</button>
<button id="btn-reset-history" class="btn">Reset history</button>
</div>
<div id="settings-note" class="note"></div>
</div>
</aside>
</main>
<footer class="statusbar">
<span class="stat" id="stat-running">running: —</span>
<span class="stat" id="stat-processor">processor: —</span>
<span class="stat" id="stat-ring">ring: —</span>
<span class="stat" id="stat-stale">live</span>
</footer>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
/* Theme mirrors python_app/gui/theme.py (light Fusion palette). */
:root {
--bg: #f3f6fb;
--panel: #ffffff;
--panel-alt: #fbfdff;
--border: #c9d4e1;
--border-soft: #d7dee8;
--text: #1f2937;
--muted: #6c7b8d;
--status: #35507a;
--accent: #2f7ee6;
--accent-hover: #3b8bf4;
--btn-bg: #f8fafc;
--btn-hover: #eef3f9;
--btn-press: #e4ebf4;
--disabled-text: #98a4b3;
--disabled-bg: #f3f5f8;
--danger: #f94144;
--mono: "DejaVu Sans Mono", ui-monospace, monospace;
--sans: "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* Top bar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border-soft);
gap: 12px;
}
.brand {
font-weight: 700;
font-size: 15px;
color: var(--status);
letter-spacing: 0.2px;
}
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
/* Buttons */
.btn {
background: var(--btn-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
color: var(--text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.btn:hover:not(:disabled) { background: var(--btn-hover); }
.btn:active:not(:disabled) { background: var(--btn-press); }
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn:disabled {
color: var(--disabled-text);
background: var(--disabled-bg);
border-color: var(--border-soft);
cursor: not-allowed;
}
/* Layout */
.layout {
flex: 1;
display: flex;
min-height: 0;
padding: 14px;
gap: 14px;
}
/* Plot */
.plot-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: 10px;
padding: 10px;
}
.plot-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.plot-title { font-weight: 600; color: var(--status); }
.axes-label { color: var(--muted); font-family: var(--mono); font-size: 12px; }
.canvas-wrap {
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: #0f141c; /* matches the pyqtgraph plot background behind letterboxing */
overflow: hidden;
}
#plot { display: block; width: 100%; height: 100%; object-fit: contain; }
/* Side panel */
.side-panel {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: 10px;
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--border-soft);
}
.panel-title { font-weight: 600; color: var(--status); }
.chevron { color: var(--muted); transition: transform 0.15s ease; }
.side-panel.collapsed .chevron { transform: rotate(-90deg); }
.panel-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.side-panel.collapsed .panel-body { display: none; }
.settings-fields {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 12px;
}
.group-title {
margin: 12px 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
font-weight: 700;
}
.group-title:first-child { margin-top: 4px; }
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 3px 0;
}
.field label {
flex: 1;
font-size: 12px;
color: var(--text);
word-break: break-word;
}
.field input[type="text"],
.field input[type="number"],
.field select {
width: 140px;
flex-shrink: 0;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 7px;
padding: 4px 7px;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
}
.field input[type="checkbox"] {
width: 16px;
height: 16px;
flex-shrink: 0;
accent-color: var(--accent);
}
.settings-actions {
display: flex;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border-soft);
}
.settings-actions .btn { flex: 1; }
.note {
padding: 0 12px 10px;
color: var(--muted);
font-size: 11px;
min-height: 14px;
}
/* Status bar */
.statusbar {
display: flex;
gap: 18px;
align-items: center;
padding: 7px 16px;
background: var(--panel);
border-top: 1px solid var(--border-soft);
font-family: var(--mono);
font-size: 12px;
color: var(--status);
flex-wrap: wrap;
}
.stat b { color: var(--text); font-weight: 600; }
.stat.ok b { color: #1b7a3d; }
.stat.off b { color: var(--muted); }
#stat-stale {
margin-left: auto;
padding: 2px 9px;
border-radius: 999px;
background: #e4f0e6;
color: #1b7a3d;
font-weight: 600;
}
#stat-stale.stale {
background: #fde2e3;
color: var(--danger);
}
/* Toast */
.toast {
position: fixed;
bottom: 56px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--status);
color: #ffffff;
padding: 9px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
max-width: 70vw;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.error { background: var(--danger); }
/* Narrow screens / phones: stack the plot above the settings, full-width controls. */
@media (max-width: 760px) {
.topbar { flex-wrap: wrap; }
.controls { width: 100%; }
.controls .btn { flex: 1 1 auto; }
.layout { flex-direction: column; padding: 10px; gap: 10px; }
.plot-panel { flex: none; height: 45vh; }
.side-panel { width: auto; flex: 1; min-height: 0; }
.field input[type="text"],
.field input[type="number"],
.field select { width: 130px; }
.statusbar { gap: 12px; }
}
+105
View File
@@ -0,0 +1,105 @@
"""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
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.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from python_app.webui.controller import WebController
logger = logging.getLogger(__name__)
# Poll the controller this often; the C++ pipeline publishes well below this rate,
# so this is a comfortable latest-wins cadence without busy-spinning the loop.
_FRAME_INTERVAL_S = 0.05
# Status is cheap but rarely changes; emit it about once a second.
_STATUS_INTERVAL_S = 1.0
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._task: asyncio.Task[None] | None = None
self._last_frame_seq: int | None = None
self._last_settings: dict | 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 unregister(self, queue: asyncio.Queue[dict]) -> None:
"""Remove a client queue; safe to call more than once."""
self._clients.discard(queue)
def start(self) -> None:
"""Launch the single polling task (idempotent)."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run(), name="ring-broadcaster")
self._task.add_done_callback(self._on_task_done)
@staticmethod
def _on_task_done(task: "asyncio.Task[None]") -> None:
"""Surface an unexpected broadcaster death (the loop should never exit)."""
if not task.cancelled() and task.exception() is not None:
logger.error("ring broadcaster task exited unexpectedly: %r", task.exception())
async def stop(self) -> None:
"""Cancel the polling task and wait for it to unwind."""
if self._task is None:
return
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
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)
def _status_message(self) -> dict:
"""Build a status broadcast from the controller's current state."""
return {"type": "status", **self._controller.status()}
async def _run(self) -> None:
"""Poll on a fixed cadence; never block the loop or die on a bad frame."""
loop = asyncio.get_running_loop()
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:
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})
next_status = now + _STATUS_INTERVAL_S
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - one bad frame must not stop streaming
logger.warning("ring broadcaster iteration failed; continuing", exc_info=True)
await asyncio.sleep(_FRAME_INTERVAL_S)