added data saving feature

This commit is contained in:
Ayzen
2026-06-23 12:19:31 +03:00
parent 716fd0b07a
commit 8db14b9482
20 changed files with 1192 additions and 63 deletions
+26 -1
View File
@@ -13,9 +13,26 @@ from __future__ import annotations
from typing import Protocol, runtime_checkable
class WebActionError(Exception):
"""A web-triggered desktop action failed, carrying the operator-facing reason.
Control methods run the matching desktop action *synchronously* and raise this
when that action reports an error (the same message the desktop would show), so
the HTTP layer can return it instead of a misleading "ok". Distinct from a plain
``ValueError`` (rejected by web-side validation before the action even runs).
"""
@runtime_checkable
class WebController(Protocol):
"""Control + read surface the web layer needs; implemented by the Qt bridge."""
"""Control + read surface the web layer needs; implemented by the Qt bridge.
Control methods are *synchronous*: each runs the corresponding desktop action on
the GUI thread and only returns once it has completed, raising
:class:`WebActionError` if the action surfaced an error. This is what lets the
browser show real failures (e.g. a save into an existing directory) instead of a
blind success.
"""
def start(self) -> None:
"""Start a continuous run (the desktop "Start" button)."""
@@ -26,6 +43,14 @@ class WebController(Protocol):
def stop(self) -> None:
"""Stop the running pipeline (the desktop "Stop" button)."""
def start_recording(self, path: str, name: str, count: int) -> None:
"""Start a run (if stopped) and record the next ``count`` measurements to disk.
``path``/``name`` mirror the shared save destination fields (blank ``path``
keeps the configured one); ``count`` sets how many measurements are written.
Raises :class:`WebActionError` if the destination already exists.
"""
def capture_tmp_reference(self) -> None:
"""Capture and select a temporary reference (the desktop button)."""
+36 -12
View File
@@ -14,7 +14,7 @@ import logging
from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect
from python_app.webui.controller import WebController
from python_app.webui.controller import WebActionError, WebController
from python_app.webui.streaming import RingBroadcaster
logger = logging.getLogger(__name__)
@@ -26,6 +26,21 @@ def _controller(request: Request) -> WebController:
return request.app.state.controller
async def _run_action(func, *args) -> None:
"""Run a (blocking) controller control call off the event loop, mapping failures to 400.
The control methods run the desktop action synchronously and raise ``ValueError``
(rejected input) or ``WebActionError`` (the action itself failed) — both become a
400 the browser shows, instead of the old silent "ok". Running in a worker thread
keeps the async event loop free while the GUI thread does the work.
"""
try:
await asyncio.to_thread(func, *args)
except (ValueError, WebActionError) as exc:
logger.warning("Web UI action failed: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/api/status")
async def get_status(request: Request) -> dict:
return _controller(request).status()
@@ -35,7 +50,7 @@ async def get_status(request: Request) -> dict:
async def post_start(request: Request) -> dict:
logger.info("Web UI request: start")
controller = _controller(request)
controller.start()
await _run_action(controller.start)
return controller.status()
@@ -43,7 +58,7 @@ async def post_start(request: Request) -> dict:
async def post_single_capture(request: Request) -> dict:
logger.info("Web UI request: single capture")
controller = _controller(request)
controller.single_capture()
await _run_action(controller.single_capture)
return controller.status()
@@ -51,7 +66,20 @@ async def post_single_capture(request: Request) -> dict:
async def post_stop(request: Request) -> dict:
logger.info("Web UI request: stop")
controller = _controller(request)
controller.stop()
await _run_action(controller.stop)
return controller.status()
@router.post("/api/start_recording")
async def post_start_recording(
request: Request,
path: str = Body("", embed=True),
name: str = Body("", embed=True),
count: int = Body(..., embed=True),
) -> dict:
logger.info("Web UI request: start with disk recording (count=%s)", count)
controller = _controller(request)
await _run_action(controller.start_recording, path, name, count)
return controller.status()
@@ -59,7 +87,7 @@ async def post_stop(request: Request) -> dict:
async def post_tmp_reference(request: Request) -> dict:
logger.info("Web UI request: capture temporary reference")
controller = _controller(request)
controller.capture_tmp_reference()
await _run_action(controller.capture_tmp_reference)
return controller.status()
@@ -67,7 +95,7 @@ async def post_tmp_reference(request: Request) -> dict:
async def post_remove_last(request: Request) -> dict:
logger.info("Web UI request: remove last measurement")
controller = _controller(request)
controller.remove_last_measurement()
await _run_action(controller.remove_last_measurement)
return controller.status()
@@ -80,11 +108,7 @@ async def get_configs(request: Request) -> dict:
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
await _run_action(controller.load_config, name)
return controller.status()
@@ -96,7 +120,7 @@ async def post_save_dataset(
) -> dict:
logger.info("Web UI request: save dataset")
controller = _controller(request)
controller.save_dataset(path, name)
await _run_action(controller.save_dataset, path, name)
return controller.status()
+48 -2
View File
@@ -22,6 +22,9 @@ 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 recordCountInput = document.getElementById("record-count");
const btnStartRecording = document.getElementById("btn-start-recording");
const recordingStatusEl = document.getElementById("recording-status");
const settingsToggle = document.getElementById("settings-toggle");
const sidePanel = document.querySelector(".side-panel");
@@ -40,6 +43,7 @@ let pendingPng = null; // newest PNG (base64), shown on the next animation
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
let recordCountPrefilled = false; // seed the record-count field once from the desktop default
/* ---- helpers ----------------------------------------------------- */
function toast(message, isError) {
@@ -305,14 +309,44 @@ btnSaveDataset.addEventListener("click", async () => {
path: savePathInput.value.trim(),
name: saveNameInput.value.trim(),
});
toast("Save requested"); // a radar-config prefix is prepended to the name server-side
toast("Dataset saved"); // a radar-config prefix is prepended to the name server-side
} catch (err) {
toast(err.message, true);
toast(err.message, true); // e.g. the destination directory already exists
} finally {
btnSaveDataset.disabled = false;
}
});
// Start (if stopped) and record the next N measurements to disk, then stop writing.
// While a recording is in progress the button stays disabled (driven by status), so a
// second recording can't be armed over the first.
let recordingActive = false;
function updateRecordButtonState() {
btnStartRecording.disabled = recordingActive;
}
btnStartRecording.addEventListener("click", async () => {
const count = parseInt(recordCountInput.value, 10);
if (!Number.isFinite(count) || count < 1) {
toast("Enter how many sweeps to record (>= 1)", true);
return;
}
btnStartRecording.disabled = true; // optimistic; status keeps it disabled while recording
try {
await api("/api/start_recording", {
path: savePathInput.value.trim(),
name: saveNameInput.value.trim(),
count,
});
toast(`Recording armed: next ${count} sweep(s)`);
} catch (err) {
toast(err.message, true); // e.g. the destination directory already exists
} finally {
updateRecordButtonState(); // re-enable only if not actually recording
}
});
/* ---- status ------------------------------------------------------ */
function setStat(el, label, value, cls) {
el.className = "stat" + (cls ? " " + cls : "");
@@ -333,6 +367,18 @@ function applyStatus(s) {
saveNameInput.value = s.save_name || "";
saveFieldsPrefilled = true;
}
if ("record_count" in s && !recordCountPrefilled && document.activeElement !== recordCountInput) {
recordCountInput.value = s.record_count; // seed once; don't clobber later edits
recordCountPrefilled = true;
}
if (s.recording) {
const r = s.recording;
recordingActive = !!r.active;
updateRecordButtonState();
recordingStatusEl.textContent = r.active
? `recording ${r.collected} / ${r.target} sweep(s)…`
: "";
}
if ("processor_running" in s)
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
s.processor_running ? "ok" : "off");
+7 -1
View File
@@ -39,7 +39,7 @@
</div>
<div class="config-section">
<div class="config-title">Save dataset</div>
<div class="config-title">Save / record dataset</div>
<div class="config-row">
<input id="save-path" class="config-control" type="text" placeholder="Save path" aria-label="Save path" />
</div>
@@ -47,6 +47,12 @@
<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 class="config-row">
<input id="record-count" class="config-control" type="number" min="1" step="1"
placeholder="Sweeps to record" aria-label="Number of sweeps to record" />
<button id="btn-start-recording" class="btn">Start + Record</button>
</div>
<div class="config-active" id="recording-status"></div>
</div>
<div class="panel-head" id="settings-toggle">