some fixes again

This commit is contained in:
Ayzen
2026-06-05 17:50:59 +03:00
parent bbea744459
commit 3c30a12d4a
24 changed files with 209 additions and 157 deletions
@@ -149,6 +149,12 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
const auto preprocessed_collection = preprocess_collection(raw_collection);
publish_preprocessed_collection(preprocessed_collection);
} catch (const std::exception& exc) {
// A ring-slot-too-small failure is a permanent config error (the slot
// cannot hold a serialized collection): swallowing it would silently
// drop every collection forever, so fail fast and let the operator fix it.
if (std::string(exc.what()).find("slot is too small") != std::string::npos) {
throw;
}
// A single malformed or transiently-bad collection must not kill the
// long-running preprocessor: drop it and keep serving the next sweep.
// Logging is throttled so a persistent error cannot flood the log.
@@ -148,6 +148,12 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms));
} catch (const std::exception& exc) {
// A ring-slot-too-small failure is a permanent config error (the slot
// cannot hold a serialized collection): swallowing it would silently
// drop every collection forever, so fail fast and let the operator fix it.
if (std::string(exc.what()).find("slot is too small") != std::string::npos) {
throw;
}
// A single bad collection (torn ring slot, decode/processing error) must
// not kill the long-running processor: drop it and keep going. Throttle
// logging and pause briefly so a persistent error cannot busy-spin/flood.
@@ -18,14 +18,15 @@
namespace radar::locator {
// Bounded, in-memory packet queue used by the per-client writer thread.
// Marking the queue as full closes the client (back-pressure by disconnect),
// matching the semantics of the previous Python implementation.
// Latest-wins: when full, the oldest queued packet(s) are dropped so a slow
// client always advances toward the freshest result. A slow client is never
// disconnected (freshness over completeness; bounded memory).
class ClientQueue {
public:
explicit ClientQueue(std::size_t capacity);
// Push a packet onto the queue. Returns false if the queue is full or has
// been closed; the caller is expected to disconnect the client in that case.
// Push a packet onto the queue, dropping the oldest entry first if full.
// Returns false ONLY when the queue is closed (the session is shutting down).
[[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool;
// Block until a packet is available or the queue is closed.
@@ -68,8 +69,8 @@ class ClientSession {
// timestamp slot records when, so the server can expire stale speeds.
void start(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns);
// Enqueue one outbound packet. Disconnects this session if the queue is
// already full or the writer has stopped.
// Enqueue one outbound packet. Never disconnects a slow client: a full queue
// drops its oldest entry (latest-wins). A no-op once the session has stopped.
void enqueue(std::vector<std::uint8_t> packet);
// Initiate teardown of this client (idempotent): closes the queue and
@@ -107,9 +108,9 @@ class ClientSession {
// * Threading: one acceptor thread + two threads per connected client. The
// producer (data_processor) calls publish() synchronously; that call is
// non-blocking and never throws for typical operation.
// * Back-pressure: each client has its own bounded outbound queue. If a
// client is too slow to drain, the next publish() drops it (matches the
// prior Python service). Other clients are unaffected.
// * Back-pressure: each client has its own bounded outbound queue. A client
// too slow to drain is never disconnected; its queue drops the oldest
// packet(s) (latest-wins) so it keeps the freshest data. Clients are isolated.
// * Latest-snapshot: the most recently published packet is cached and sent
// to every newly connected client before any new packets are forwarded.
// * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent
+11 -2
View File
@@ -140,8 +140,17 @@ class AppWindow(
try:
value = int(raw)
except ValueError:
return 50
return value if value >= 1 else 50
value = 0
if value >= 1:
return value
# A non-empty but invalid value is an operator mistake — say so instead of
# silently swallowing it (stderr is captured by journald in headless mode).
print(
f"[radar] Ignoring invalid RADAR_SYSTEM_METRICS_REPORT_EVERY={raw!r}; using default 50.",
file=sys.stderr,
flush=True,
)
return 50
def _init_config_profile_state(self) -> None:
"""Resolve startup profile path, load active profile, and queue fallback notices."""
@@ -453,6 +453,7 @@ class AppWindowConfigStateBuildersMixin:
config.gpr,
input_switch_positions=config.input_switch.positions,
output_switch_positions=config.output_switch.positions,
sweep=config.radar.sweep,
)
return config
@@ -72,17 +72,11 @@ class AppWindowControlButtonMixin:
Delivered as a queued signal from the watcher thread, so this executes
on the GUI thread exactly like a click on "Capture Tmp Reference".
"""
# Ignore a re-entrant press: the capture flow spins the event loop (stop/
# start run, dialogs), so a second queued press must not start a nested capture.
if self._control_button_busy:
self._log("GPIO control button press ignored: capture already in progress.")
return
self._control_button_busy = True
try:
self._log("GPIO control button pressed: capturing tmp reference.")
self._capture_tmp_reference()
finally:
self._control_button_busy = False
# The shared re-entrancy guard lives in _capture_tmp_reference, so this GPIO
# trigger and the GUI "Capture Tmp Reference" button are protected by one
# mechanism (a press during an in-progress capture is ignored there).
self._log("GPIO control button pressed: capturing tmp reference.")
self._capture_tmp_reference()
def _on_control_button_failed(self, message: str) -> None:
"""Log an unrecoverable watcher error reported from the background thread."""
@@ -469,8 +469,8 @@ class AppWindowPipelineMixin:
if collection is None:
break
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
if record_result_history(self._result_history, collection):
latest = collection
record_result_history(self._result_history, collection)
latest = collection
return latest
def _pump_events_during_drain(self, pause_s: float) -> None:
@@ -43,7 +43,9 @@ class AppWindowTracePlotMixin:
details=f"{exc}\nExpected format: input:output,input:output",
once_key=f"pass_through_combo_filter_invalid_{text}",
)
return set()
# Fail open: an unparseable filter is ignored (all traces stay visible),
# not turned into an empty allow-set that silently hides every trace.
return None
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
@@ -117,6 +117,13 @@ class AppWindowPreprocessMixin:
details=self._capture_state_details(),
)
return
# One shared re-entrancy guard for BOTH the GUI button and the GPIO trigger:
# this capture spins the Qt event loop (stop/start run, dialogs), so a second
# request from either source must not start a nested capture.
if self._control_button_busy:
self._log("Tmp reference capture already in progress; ignoring duplicate request.")
return
self._control_button_busy = True
pipeline_was_running = self._supervisor.is_running()
pipeline_was_paused = False
@@ -178,6 +185,7 @@ class AppWindowPreprocessMixin:
finally:
if pipeline_was_paused:
self._start_run()
self._control_button_busy = False
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
"""Create preprocessing dialog lazily and wire its signals once."""
@@ -160,7 +160,7 @@ class AppWindowUiMixin:
self._status_label = QLabel("Status: idle", self._settings_panel)
self._status_label.setObjectName("statusLabel")
self._status_label.hide()
right_layout.addWidget(self._status_label)
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
self._history_label.setObjectName("hintLabel")
@@ -25,7 +25,10 @@ def build_data_actions_group(owner) -> QGroupBox:
save_button = QPushButton("Save Dataset")
save_button.clicked.connect(owner._save_snapshot)
save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
save_vna_json_button = QPushButton("Save JSON")
save_vna_json_button = QPushButton("Save S21 JSON")
save_vna_json_button.setToolTip(
"Export one S21 VNA-history JSON per combo from the preprocessed stage (S11 is not included)."
)
save_vna_json_button.clicked.connect(owner._save_vna_history_json)
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
remove_last_button = QPushButton("Remove Last Measurement")
+3 -4
View File
@@ -15,8 +15,8 @@ THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollec
def record_result_history(
result_history: deque[ResultCollection],
collection: ResultCollection,
) -> bool:
"""Append new result or replace existing entry by stable collection key."""
) -> None:
"""Append a new result, or replace the existing entry with the same stable key."""
for index in range(len(result_history) - 1, -1, -1):
existing = result_history[index]
if (
@@ -24,10 +24,9 @@ def record_result_history(
and existing.monotonic_ns == collection.monotonic_ns
):
result_history[index] = collection
return True
return
result_history.append(collection)
return True
def remove_last_aligned_histories(
@@ -361,7 +361,16 @@ class LaserController:
raw = self._protocol.receive_raw(2)
if raw and len(raw) == 2:
state = Protocol.decode_state(raw)
logger.debug("STATE response after command: 0x%04x", state)
if state != 0:
# Surface a device-reported non-OK STATE instead of silently treating
# a board-rejected command as success. (Returned to the caller too.)
logger.warning(
"Device returned non-OK STATE 0x%04x after command: %s",
state,
Protocol.state_to_description(f"{state:04x}"),
)
else:
logger.debug("STATE response after command: 0x%04x", state)
return state
return 0
@@ -173,12 +173,21 @@ class USBTransport:
self._rx_thread = None
if self._handle is not None:
self._handle.releaseInterface(self.INTERFACE)
self._handle.close()
# Always release AND close the handle, even if releaseInterface raises on
# a vanished/changed device. Otherwise the handle leaks with the interface
# still claimed, and every subsequent connect() fails forever with
# "Failed to claim USB interface" (LIBUSB_ERROR_BUSY) — the device never
# recovers after a hot-unplug/replug. close() releases the claim at the OS
# level regardless, so reconnect can claim it again.
with suppress(usb1.USBError):
self._handle.releaseInterface(self.INTERFACE)
with suppress(usb1.USBError):
self._handle.close()
self._handle = None
if self._ctx is not None:
self._ctx.close()
with suppress(usb1.USBError):
self._ctx.close()
self._ctx = None
logger.info("USB disconnected (serial=%s)", self.connected_serial)
+11 -1
View File
@@ -3,11 +3,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LibreVnaService:
@@ -41,9 +44,16 @@ class LibreVnaService:
strict_protocol_version=self.strict_protocol_version,
)
self._driver_available = True
except Exception:
except Exception as exc:
# 'native' demands real hardware — never substitute synthetic data.
if mode == "native":
raise
# 'auto' falls back to the mock backend ONLY when the native driver
# library itself is unavailable (a dev host without the USB stack) —
# this is not device-absence (that surfaces later from open()). Log it
# loudly so it is never a silent surprise; a deployed appliance should
# set driver_mode='native' to forbid the fallback entirely.
logger.warning("LibreVNA native backend unavailable; falling back to mock (mode=auto): %s", exc)
self._backend = MockLibreVnaBackend()
self._using_mock_backend = True
@@ -40,7 +40,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
if model == RunConfigModel.LIBREVNA_MODEL:
return LibreVnaService(serial=config.radar.serial or None)
# Forward driver_mode (mirrors the matrix path): 'native' must require real
# hardware and 'mock' must use the synthetic backend — never silently the wrong one.
return LibreVnaService(serial=config.radar.serial or None, backend_mode=config.radar.driver_mode)
if model == RunConfigModel.COMPACT_M_K209_MODEL:
if config.radar.driver_mode != "native":
+78 -89
View File
@@ -30,6 +30,19 @@ def _as_dict(value: Any, context: str) -> dict[str, Any]:
return value
def _as_list(value: Any, context: str) -> list[Any]:
"""Validate payload node is array-like, treating missing values as an empty list.
A present-but-non-array value is rejected (rather than silently dropped) so a
malformed config section fails loudly instead of quietly emptying out.
"""
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"{context} must be a JSON array")
return value
def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Return payload string, treating an explicit JSON `null` as missing.
@@ -56,12 +69,12 @@ def _read_int(payload: dict[str, Any], key: str, default: int) -> int:
value = payload.get(key, default)
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
# Accept only a genuine JSON integer (not bool, not float, not numeric string):
# silently truncating 5.7 or parsing "5" would hide a malformed config. Mirrors
# gui_profile_codec._optional_int so the two codecs agree.
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} must be a JSON integer")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON integer") from exc
return value
def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
@@ -74,12 +87,11 @@ def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
value = payload.get(key, default)
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
# Accept only a genuine JSON number (int/float, not bool, not numeric string):
# parsing "1e9" would hide a malformed config. Mirrors gui_profile_codec.
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{key} must be a JSON number")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} must be a JSON number") from exc
result = float(value)
if not math.isfinite(result):
raise ValueError(f"{key} must be a finite number")
return result
@@ -191,6 +203,10 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
for value in slave_serials_payload.split(",")
if value.strip()
]
else:
raise ValueError(
"radar.multi_device.slave_serials must be a JSON array or comma-separated string"
)
model.radar.multi_device.force_external_reference = _read_bool(
multi_device_payload,
"force_external_reference",
@@ -306,45 +322,21 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
load_control_button_payload(control_button_payload, model.control_button)
model.apply_device_model_constraints()
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms))
model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms))
model.runtime.continuous = bool(run_payload.get("continuous", model.runtime.continuous))
model.runtime.processing_live_config_path = str(
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path)
)
model.runtime.locator_server.device_id = int(
locator_server_payload.get("device_id", model.runtime.locator_server.device_id)
)
model.runtime.locator_server.protocol_version = int(
locator_server_payload.get(
"protocol_version",
model.runtime.locator_server.protocol_version,
)
)
model.runtime.locator_server.host = str(
locator_server_payload.get("host", model.runtime.locator_server.host)
)
model.runtime.locator_server.port = int(
locator_server_payload.get("port", model.runtime.locator_server.port)
)
model.runtime.locator_server.max_payload_bytes = int(
locator_server_payload.get(
"max_payload_bytes",
model.runtime.locator_server.max_payload_bytes,
)
)
model.runtime.locator_server.client_queue_size = int(
locator_server_payload.get(
"client_queue_size",
model.runtime.locator_server.client_queue_size,
)
)
model.runtime.locator_server.logger_name = str(
locator_server_payload.get(
"logger_name",
model.runtime.locator_server.logger_name,
)
runtime = model.runtime
locator = runtime.locator_server
runtime.settling_ms = _read_int(run_payload, "settling_ms", runtime.settling_ms)
runtime.idle_sleep_ms = _read_int(run_payload, "idle_sleep_ms", runtime.idle_sleep_ms)
runtime.continuous = _read_bool(run_payload, "continuous", runtime.continuous)
runtime.processing_live_config_path = _read_str(
run_payload, "processing_live_config_path", runtime.processing_live_config_path
)
locator.device_id = _read_int(locator_server_payload, "device_id", locator.device_id)
locator.protocol_version = _read_int(locator_server_payload, "protocol_version", locator.protocol_version)
locator.host = _read_str(locator_server_payload, "host", locator.host)
locator.port = _read_int(locator_server_payload, "port", locator.port)
locator.max_payload_bytes = _read_int(locator_server_payload, "max_payload_bytes", locator.max_payload_bytes)
locator.client_queue_size = _read_int(locator_server_payload, "client_queue_size", locator.client_queue_size)
locator.logger_name = _read_str(locator_server_payload, "logger_name", locator.logger_name)
s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
_load_preprocess_asset(
@@ -376,51 +368,50 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
)
notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
model.preprocess.notch = PreprocessNotchModel(
enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)),
taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)),
taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)),
enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled),
taper_width_hz=_read_float(notch_payload, "taper_width_hz", model.preprocess.notch.taper_width_hz),
taper_type=_read_str(notch_payload, "taper_type", model.preprocess.notch.taper_type),
bands_hz=[],
)
bands_payload = notch_payload.get("bands_hz", [])
if isinstance(bands_payload, list):
for band in bands_payload:
if isinstance(band, (list, tuple)) and len(band) == 2:
model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
bands_payload = _as_list(notch_payload.get("bands_hz"), "preprocess.notch.bands_hz")
for band in bands_payload:
if not (isinstance(band, (list, tuple)) and len(band) == 2):
raise ValueError("preprocess.notch.bands_hz entries must be [low_hz, high_hz] pairs")
model.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
model.gpr.relative_permittivity = float(
gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity)
model.gpr.relative_permittivity = _read_float(
gpr_payload, "relative_permittivity", model.gpr.relative_permittivity
)
model.gpr.tx_geometry = []
tx_geometry_payload = gpr_payload.get("tx_geometry", [])
if isinstance(tx_geometry_payload, list):
for entry in tx_geometry_payload:
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
model.gpr.tx_geometry.append(
GprTxGeometryModel(
output_pos=int(entry_payload.get("output_pos", 0)),
x_m=float(entry_payload.get("x_m", 0.0)),
y_m=float(entry_payload.get("y_m", 0.0)),
z_m=float(entry_payload.get("z_m", 0.0)),
)
for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"):
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
model.gpr.tx_geometry.append(
GprTxGeometryModel(
output_pos=_read_int(entry_payload, "output_pos", 0),
x_m=_read_float(entry_payload, "x_m", 0.0),
y_m=_read_float(entry_payload, "y_m", 0.0),
z_m=_read_float(entry_payload, "z_m", 0.0),
)
)
model.gpr.rx_geometry = []
rx_geometry_payload = gpr_payload.get("rx_geometry", [])
if isinstance(rx_geometry_payload, list):
for entry in rx_geometry_payload:
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
model.gpr.rx_geometry.append(
GprRxGeometryModel(
input_pos=int(entry_payload.get("input_pos", 0)),
x_m=float(entry_payload.get("x_m", 0.0)),
y_m=float(entry_payload.get("y_m", 0.0)),
z_m=float(entry_payload.get("z_m", 0.0)),
)
for entry in _as_list(gpr_payload.get("rx_geometry"), "gpr.rx_geometry"):
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
model.gpr.rx_geometry.append(
GprRxGeometryModel(
input_pos=_read_int(entry_payload, "input_pos", 0),
x_m=_read_float(entry_payload, "x_m", 0.0),
y_m=_read_float(entry_payload, "y_m", 0.0),
z_m=_read_float(entry_payload, "z_m", 0.0),
)
)
model.apply_device_model_constraints()
# Pass sweep= so #36 sweep bounds (points > 0, stop_hz >= start_hz) are validated
# on the config-load path instead of crashing the C++ acquisition process at boot.
validate_gpr_model(
model.gpr,
input_switch_positions=model.input_switch.positions,
output_switch_positions=model.output_switch.positions,
sweep=model.radar.sweep,
)
load_ring_payload(raw_ring_payload, model.rings.raw)
@@ -429,17 +420,15 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
load_ring_payload(pre_tap_ring_payload, model.rings.preprocessed_tap)
load_ring_payload(result_ring_payload, model.rings.results)
combos_payload = run_payload.get("combos", [])
model.combos = []
if isinstance(combos_payload, list):
for combo in combos_payload:
combo_payload = _as_dict(combo, "run.combos[]")
model.combos.append(
ComboModel(
input=int(combo_payload.get("input", 0)),
output=int(combo_payload.get("output", 0)),
)
for combo in _as_list(run_payload.get("combos"), "run.combos"):
combo_payload = _as_dict(combo, "run.combos[]")
model.combos.append(
ComboModel(
input=_read_int(combo_payload, "input", 0),
output=_read_int(combo_payload, "output", 0),
)
)
model.ensure_combos()
return model
@@ -30,6 +30,8 @@ def _require_int(payload: dict[str, Any], key: str, default: int) -> int:
config-error contract; surface it as a ValueError naming the field instead.
"""
value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default, never coerce
return default
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{key} must be a JSON integer")
try:
@@ -41,6 +43,8 @@ def _require_int(payload: dict[str, Any], key: str, default: int) -> int:
def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Read a string field, rejecting JSON arrays/objects with a named ValueError."""
value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default, never coerce to "None"
return default
if isinstance(value, (dict, list)):
raise ValueError(f"{key} must be a JSON string")
return str(value)
@@ -49,6 +53,8 @@ def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
def _require_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
"""Read a boolean field, rejecting non-boolean JSON types with a named ValueError."""
value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default
return default
if not isinstance(value, bool):
raise ValueError(f"{key} must be a JSON boolean")
return value
+17 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import suppress
import json
import os
from pathlib import Path
@@ -42,13 +43,12 @@ class ConfigWriter:
asset.bundle_path = str(bundle_path)
def write(self, config: RunConfigModel, output_path: Path) -> Path:
"""Atomically write run configuration JSON file.
"""Atomically write the run configuration JSON file.
Mirrors ProcessingLiveConfigWriter: dump to a sibling .tmp, flush+fsync to
durably commit the bytes, then os.replace() onto the destination. The replace
is atomic, so a C++ consumer can never observe a half-written config (which
would abort it with an opaque JSON parse error), even across a crash or power
loss mid-write on the SD-card-backed Pi.
Dump to a sibling ``.tmp``, flush + ``fsync`` to durably commit the bytes on
the SD-card-backed Pi, then ``os.replace()`` atomically onto the destination.
A C++ consumer can therefore never observe a half-written config (which would
abort it with an opaque JSON parse error), even across a crash or power loss.
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
# allow_nan=False: a stray NaN/Infinity must fail loudly here in Python
@@ -56,11 +56,17 @@ class ConfigWriter:
# consumer at startup with an opaque JSON parse error.
serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False)
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(serialized)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, output_path)
try:
with open(tmp_path, "w", encoding="utf-8") as handle:
handle.write(serialized)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, output_path)
except Exception:
# Never leave a half-written .tmp behind on a write/fsync failure.
with suppress(OSError):
tmp_path.unlink()
raise
return output_path
+2 -15
View File
@@ -72,7 +72,6 @@ class ShmRingWriter:
self._mmap.close()
self._file.close()
self._unlink_if_present()
created = not self._path.exists()
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
self._file = os.fdopen(fd, "r+b", buffering=0)
self._file.truncate(self._mapped_size)
@@ -135,23 +134,11 @@ class ShmRingWriter:
self._write_u64(32, 0)
self._write_u64(40, 0)
def _validate_header(self) -> None:
magic = self._mmap[:8]
version = self._read_u32(8)
capacity = self._read_u32(12)
slot_size_bytes = self._read_u32(16)
if magic != _MAGIC:
raise RuntimeError(f"Shared memory ring magic mismatch for {self._ring_name}")
if version != _VERSION:
raise RuntimeError(f"Shared memory ring version mismatch for {self._ring_name}")
if capacity != self._capacity or slot_size_bytes != self._slot_size_bytes:
raise RuntimeError(f"Shared memory ring geometry mismatch for {self._ring_name}")
def _header_matches(self) -> bool:
"""Return whether the existing segment's header matches this ring's geometry.
Non-throwing counterpart of `_validate_header` used by the owner to decide
whether a same-sized pre-existing segment can be reused or must be recreated.
Used by the owner to decide whether a same-sized pre-existing segment can be
reused as-is or must be unlinked and recreated.
"""
return (
self._mmap[:8] == _MAGIC
+5 -5
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from contextlib import suppress
from datetime import datetime
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
@@ -162,7 +162,7 @@ class NpzStore(StoreApi):
raise ValueError("last_n must be > 0")
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
snapshot_dir = output_dir / f"snapshot_{timestamp}"
snapshot_dir.mkdir(parents=True, exist_ok=True)
@@ -184,7 +184,7 @@ class NpzStore(StoreApi):
if last_n <= 0:
raise ValueError("last_n must be > 0")
snapshot_stem = snapshot_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S")
snapshot_stem = snapshot_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
snapshot_stem = sanitize_path_component(snapshot_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
@@ -237,7 +237,7 @@ class NpzStore(StoreApi):
if last_n <= 0:
raise ValueError("last_n must be > 0")
output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
@@ -299,7 +299,7 @@ class NpzStore(StoreApi):
if last_n <= 0:
raise ValueError("last_n must be > 0")
output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
@@ -106,6 +106,7 @@ class MultiRadarSequentialCaptureSession:
self._input_switch = SwitchService(
name=base_config.input_switch.name,
positions=base_config.input_switch.positions,
default_position=base_config.input_switch.default_position,
mode=base_config.input_switch.driver_mode,
driver=base_config.input_switch.driver,
gpio_chip=base_config.input_switch.gpio_chip,
@@ -116,6 +117,7 @@ class MultiRadarSequentialCaptureSession:
self._output_switch = SwitchService(
name=base_config.output_switch.name,
positions=base_config.output_switch.positions,
default_position=base_config.output_switch.default_position,
mode=base_config.output_switch.driver_mode,
driver=base_config.output_switch.driver,
gpio_chip=base_config.output_switch.gpio_chip,
@@ -82,6 +82,7 @@ class SequentialCaptureSession:
self._input_switch = SwitchService(
name=config.input_switch.name,
positions=config.input_switch.positions,
default_position=config.input_switch.default_position,
mode=config.input_switch.driver_mode,
driver=config.input_switch.driver,
gpio_chip=config.input_switch.gpio_chip,
@@ -92,6 +93,7 @@ class SequentialCaptureSession:
self._output_switch = SwitchService(
name=config.output_switch.name,
positions=config.output_switch.positions,
default_position=config.output_switch.default_position,
mode=config.output_switch.driver_mode,
driver=config.output_switch.driver,
gpio_chip=config.output_switch.gpio_chip,
+1
View File
@@ -2,6 +2,7 @@ numpy>=1.26,<3
libusb1>=3.1
pyserial>=3.5,<4
pyvisa>=1.14
PyVISA-py>=0.7 # pure-Python VISA backend ("@py"), required by the SN9000 (and any visa_library="@py") path
PyQt6>=6.6
pyqtgraph>=0.13.7
rpi-hardware-pwm>=0.2.2,<1