added data saving feature
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user