some fixes again
This commit is contained in:
@@ -149,6 +149,12 @@ void DataPreprocessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
const auto preprocessed_collection = preprocess_collection(raw_collection);
|
const auto preprocessed_collection = preprocess_collection(raw_collection);
|
||||||
publish_preprocessed_collection(preprocessed_collection);
|
publish_preprocessed_collection(preprocessed_collection);
|
||||||
} catch (const std::exception& exc) {
|
} 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
|
// A single malformed or transiently-bad collection must not kill the
|
||||||
// long-running preprocessor: drop it and keep serving the next sweep.
|
// long-running preprocessor: drop it and keep serving the next sweep.
|
||||||
// Logging is throttled so a persistent error cannot flood the log.
|
// 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));
|
std::this_thread::sleep_for(std::chrono::milliseconds(config_.runtime.idle_sleep_ms));
|
||||||
} catch (const std::exception& exc) {
|
} 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
|
// A single bad collection (torn ring slot, decode/processing error) must
|
||||||
// not kill the long-running processor: drop it and keep going. Throttle
|
// not kill the long-running processor: drop it and keep going. Throttle
|
||||||
// logging and pause briefly so a persistent error cannot busy-spin/flood.
|
// logging and pause briefly so a persistent error cannot busy-spin/flood.
|
||||||
|
|||||||
@@ -18,14 +18,15 @@
|
|||||||
namespace radar::locator {
|
namespace radar::locator {
|
||||||
|
|
||||||
// Bounded, in-memory packet queue used by the per-client writer thread.
|
// Bounded, in-memory packet queue used by the per-client writer thread.
|
||||||
// Marking the queue as full closes the client (back-pressure by disconnect),
|
// Latest-wins: when full, the oldest queued packet(s) are dropped so a slow
|
||||||
// matching the semantics of the previous Python implementation.
|
// client always advances toward the freshest result. A slow client is never
|
||||||
|
// disconnected (freshness over completeness; bounded memory).
|
||||||
class ClientQueue {
|
class ClientQueue {
|
||||||
public:
|
public:
|
||||||
explicit ClientQueue(std::size_t capacity);
|
explicit ClientQueue(std::size_t capacity);
|
||||||
|
|
||||||
// Push a packet onto the queue. Returns false if the queue is full or has
|
// Push a packet onto the queue, dropping the oldest entry first if full.
|
||||||
// been closed; the caller is expected to disconnect the client in that case.
|
// Returns false ONLY when the queue is closed (the session is shutting down).
|
||||||
[[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool;
|
[[nodiscard]] auto try_push(std::vector<std::uint8_t> packet) -> bool;
|
||||||
|
|
||||||
// Block until a packet is available or the queue is closed.
|
// 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.
|
// 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);
|
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
|
// Enqueue one outbound packet. Never disconnects a slow client: a full queue
|
||||||
// already full or the writer has stopped.
|
// drops its oldest entry (latest-wins). A no-op once the session has stopped.
|
||||||
void enqueue(std::vector<std::uint8_t> packet);
|
void enqueue(std::vector<std::uint8_t> packet);
|
||||||
|
|
||||||
// Initiate teardown of this client (idempotent): closes the queue and
|
// 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
|
// * Threading: one acceptor thread + two threads per connected client. The
|
||||||
// producer (data_processor) calls publish() synchronously; that call is
|
// producer (data_processor) calls publish() synchronously; that call is
|
||||||
// non-blocking and never throws for typical operation.
|
// non-blocking and never throws for typical operation.
|
||||||
// * Back-pressure: each client has its own bounded outbound queue. If a
|
// * Back-pressure: each client has its own bounded outbound queue. A client
|
||||||
// client is too slow to drain, the next publish() drops it (matches the
|
// too slow to drain is never disconnected; its queue drops the oldest
|
||||||
// prior Python service). Other clients are unaffected.
|
// packet(s) (latest-wins) so it keeps the freshest data. Clients are isolated.
|
||||||
// * Latest-snapshot: the most recently published packet is cached and sent
|
// * Latest-snapshot: the most recently published packet is cached and sent
|
||||||
// to every newly connected client before any new packets are forwarded.
|
// to every newly connected client before any new packets are forwarded.
|
||||||
// * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent
|
// * Lifetime: `start()` may throw on listen failure. `stop()` is idempotent
|
||||||
|
|||||||
@@ -140,8 +140,17 @@ class AppWindow(
|
|||||||
try:
|
try:
|
||||||
value = int(raw)
|
value = int(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 50
|
value = 0
|
||||||
return value if value >= 1 else 50
|
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:
|
def _init_config_profile_state(self) -> None:
|
||||||
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
config.gpr,
|
config.gpr,
|
||||||
input_switch_positions=config.input_switch.positions,
|
input_switch_positions=config.input_switch.positions,
|
||||||
output_switch_positions=config.output_switch.positions,
|
output_switch_positions=config.output_switch.positions,
|
||||||
|
sweep=config.radar.sweep,
|
||||||
)
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -72,17 +72,11 @@ class AppWindowControlButtonMixin:
|
|||||||
Delivered as a queued signal from the watcher thread, so this executes
|
Delivered as a queued signal from the watcher thread, so this executes
|
||||||
on the GUI thread exactly like a click on "Capture Tmp Reference".
|
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/
|
# The shared re-entrancy guard lives in _capture_tmp_reference, so this GPIO
|
||||||
# start run, dialogs), so a second queued press must not start a nested capture.
|
# trigger and the GUI "Capture Tmp Reference" button are protected by one
|
||||||
if self._control_button_busy:
|
# mechanism (a press during an in-progress capture is ignored there).
|
||||||
self._log("GPIO control button press ignored: capture already in progress.")
|
self._log("GPIO control button pressed: capturing tmp reference.")
|
||||||
return
|
self._capture_tmp_reference()
|
||||||
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
|
|
||||||
|
|
||||||
def _on_control_button_failed(self, message: str) -> None:
|
def _on_control_button_failed(self, message: str) -> None:
|
||||||
"""Log an unrecoverable watcher error reported from the background thread."""
|
"""Log an unrecoverable watcher error reported from the background thread."""
|
||||||
|
|||||||
@@ -469,8 +469,8 @@ class AppWindowPipelineMixin:
|
|||||||
if collection is None:
|
if collection is None:
|
||||||
break
|
break
|
||||||
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
||||||
if record_result_history(self._result_history, collection):
|
record_result_history(self._result_history, collection)
|
||||||
latest = collection
|
latest = collection
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
def _pump_events_during_drain(self, pause_s: float) -> None:
|
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",
|
details=f"{exc}\nExpected format: input:output,input:output",
|
||||||
once_key=f"pass_through_combo_filter_invalid_{text}",
|
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:
|
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
|
||||||
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
|
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class AppWindowPreprocessMixin:
|
|||||||
details=self._capture_state_details(),
|
details=self._capture_state_details(),
|
||||||
)
|
)
|
||||||
return
|
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_running = self._supervisor.is_running()
|
||||||
pipeline_was_paused = False
|
pipeline_was_paused = False
|
||||||
@@ -178,6 +185,7 @@ class AppWindowPreprocessMixin:
|
|||||||
finally:
|
finally:
|
||||||
if pipeline_was_paused:
|
if pipeline_was_paused:
|
||||||
self._start_run()
|
self._start_run()
|
||||||
|
self._control_button_busy = False
|
||||||
|
|
||||||
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
|
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
|
||||||
"""Create preprocessing dialog lazily and wire its signals once."""
|
"""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 = QLabel("Status: idle", self._settings_panel)
|
||||||
self._status_label.setObjectName("statusLabel")
|
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 = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
|
||||||
self._history_label.setObjectName("hintLabel")
|
self._history_label.setObjectName("hintLabel")
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ def build_data_actions_group(owner) -> QGroupBox:
|
|||||||
save_button = QPushButton("Save Dataset")
|
save_button = QPushButton("Save Dataset")
|
||||||
save_button.clicked.connect(owner._save_snapshot)
|
save_button.clicked.connect(owner._save_snapshot)
|
||||||
save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
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.clicked.connect(owner._save_vna_history_json)
|
||||||
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
remove_last_button = QPushButton("Remove Last Measurement")
|
remove_last_button = QPushButton("Remove Last Measurement")
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollec
|
|||||||
def record_result_history(
|
def record_result_history(
|
||||||
result_history: deque[ResultCollection],
|
result_history: deque[ResultCollection],
|
||||||
collection: ResultCollection,
|
collection: ResultCollection,
|
||||||
) -> bool:
|
) -> None:
|
||||||
"""Append new result or replace existing entry by stable collection key."""
|
"""Append a new result, or replace the existing entry with the same stable key."""
|
||||||
for index in range(len(result_history) - 1, -1, -1):
|
for index in range(len(result_history) - 1, -1, -1):
|
||||||
existing = result_history[index]
|
existing = result_history[index]
|
||||||
if (
|
if (
|
||||||
@@ -24,10 +24,9 @@ def record_result_history(
|
|||||||
and existing.monotonic_ns == collection.monotonic_ns
|
and existing.monotonic_ns == collection.monotonic_ns
|
||||||
):
|
):
|
||||||
result_history[index] = collection
|
result_history[index] = collection
|
||||||
return True
|
return
|
||||||
|
|
||||||
result_history.append(collection)
|
result_history.append(collection)
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def remove_last_aligned_histories(
|
def remove_last_aligned_histories(
|
||||||
|
|||||||
@@ -361,7 +361,16 @@ class LaserController:
|
|||||||
raw = self._protocol.receive_raw(2)
|
raw = self._protocol.receive_raw(2)
|
||||||
if raw and len(raw) == 2:
|
if raw and len(raw) == 2:
|
||||||
state = Protocol.decode_state(raw)
|
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 state
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -173,12 +173,21 @@ class USBTransport:
|
|||||||
self._rx_thread = None
|
self._rx_thread = None
|
||||||
|
|
||||||
if self._handle is not None:
|
if self._handle is not None:
|
||||||
self._handle.releaseInterface(self.INTERFACE)
|
# Always release AND close the handle, even if releaseInterface raises on
|
||||||
self._handle.close()
|
# 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
|
self._handle = None
|
||||||
|
|
||||||
if self._ctx is not None:
|
if self._ctx is not None:
|
||||||
self._ctx.close()
|
with suppress(usb1.USBError):
|
||||||
|
self._ctx.close()
|
||||||
self._ctx = None
|
self._ctx = None
|
||||||
|
|
||||||
logger.info("USB disconnected (serial=%s)", self.connected_serial)
|
logger.info("USB disconnected (serial=%s)", self.connected_serial)
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
|
||||||
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
|
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
|
||||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||||
from python_app.models.run_config_model import RadarSweepModel
|
from python_app.models.run_config_model import RadarSweepModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LibreVnaService:
|
class LibreVnaService:
|
||||||
@@ -41,9 +44,16 @@ class LibreVnaService:
|
|||||||
strict_protocol_version=self.strict_protocol_version,
|
strict_protocol_version=self.strict_protocol_version,
|
||||||
)
|
)
|
||||||
self._driver_available = True
|
self._driver_available = True
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
# 'native' demands real hardware — never substitute synthetic data.
|
||||||
if mode == "native":
|
if mode == "native":
|
||||||
raise
|
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._backend = MockLibreVnaBackend()
|
||||||
self._using_mock_backend = True
|
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
|
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
|
||||||
if model == 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 model == RunConfigModel.COMPACT_M_K209_MODEL:
|
||||||
if config.radar.driver_mode != "native":
|
if config.radar.driver_mode != "native":
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
|||||||
return value
|
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:
|
def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
|
||||||
"""Return payload string, treating an explicit JSON `null` as missing.
|
"""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)
|
value = payload.get(key, default)
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
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")
|
raise ValueError(f"{key} must be a JSON integer")
|
||||||
try:
|
return value
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError(f"{key} must be a JSON integer") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
|
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)
|
value = payload.get(key, default)
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
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")
|
raise ValueError(f"{key} must be a JSON number")
|
||||||
try:
|
result = float(value)
|
||||||
result = float(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError(f"{key} must be a JSON number") from exc
|
|
||||||
if not math.isfinite(result):
|
if not math.isfinite(result):
|
||||||
raise ValueError(f"{key} must be a finite number")
|
raise ValueError(f"{key} must be a finite number")
|
||||||
return result
|
return result
|
||||||
@@ -191,6 +203,10 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
for value in slave_serials_payload.split(",")
|
for value in slave_serials_payload.split(",")
|
||||||
if value.strip()
|
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(
|
model.radar.multi_device.force_external_reference = _read_bool(
|
||||||
multi_device_payload,
|
multi_device_payload,
|
||||||
"force_external_reference",
|
"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)
|
load_control_button_payload(control_button_payload, model.control_button)
|
||||||
model.apply_device_model_constraints()
|
model.apply_device_model_constraints()
|
||||||
|
|
||||||
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms))
|
runtime = model.runtime
|
||||||
model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms))
|
locator = runtime.locator_server
|
||||||
model.runtime.continuous = bool(run_payload.get("continuous", model.runtime.continuous))
|
runtime.settling_ms = _read_int(run_payload, "settling_ms", runtime.settling_ms)
|
||||||
model.runtime.processing_live_config_path = str(
|
runtime.idle_sleep_ms = _read_int(run_payload, "idle_sleep_ms", runtime.idle_sleep_ms)
|
||||||
run_payload.get("processing_live_config_path", model.runtime.processing_live_config_path)
|
runtime.continuous = _read_bool(run_payload, "continuous", runtime.continuous)
|
||||||
)
|
runtime.processing_live_config_path = _read_str(
|
||||||
model.runtime.locator_server.device_id = int(
|
run_payload, "processing_live_config_path", runtime.processing_live_config_path
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
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")
|
s21_preprocess_payload = _as_dict(preprocess_payload.get("s21"), "preprocess.s21")
|
||||||
_load_preprocess_asset(
|
_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")
|
notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
|
||||||
model.preprocess.notch = PreprocessNotchModel(
|
model.preprocess.notch = PreprocessNotchModel(
|
||||||
enabled=bool(notch_payload.get("enabled", model.preprocess.notch.enabled)),
|
enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled),
|
||||||
taper_width_hz=float(notch_payload.get("taper_width_hz", model.preprocess.notch.taper_width_hz)),
|
taper_width_hz=_read_float(notch_payload, "taper_width_hz", model.preprocess.notch.taper_width_hz),
|
||||||
taper_type=str(notch_payload.get("taper_type", model.preprocess.notch.taper_type)),
|
taper_type=_read_str(notch_payload, "taper_type", model.preprocess.notch.taper_type),
|
||||||
bands_hz=[],
|
bands_hz=[],
|
||||||
)
|
)
|
||||||
bands_payload = notch_payload.get("bands_hz", [])
|
bands_payload = _as_list(notch_payload.get("bands_hz"), "preprocess.notch.bands_hz")
|
||||||
if isinstance(bands_payload, list):
|
for band in bands_payload:
|
||||||
for band in bands_payload:
|
if not (isinstance(band, (list, tuple)) and len(band) == 2):
|
||||||
if 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.preprocess.notch.bands_hz.append((float(band[0]), float(band[1])))
|
||||||
|
|
||||||
model.gpr.relative_permittivity = float(
|
model.gpr.relative_permittivity = _read_float(
|
||||||
gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity)
|
gpr_payload, "relative_permittivity", model.gpr.relative_permittivity
|
||||||
)
|
)
|
||||||
model.gpr.tx_geometry = []
|
model.gpr.tx_geometry = []
|
||||||
tx_geometry_payload = gpr_payload.get("tx_geometry", [])
|
for entry in _as_list(gpr_payload.get("tx_geometry"), "gpr.tx_geometry"):
|
||||||
if isinstance(tx_geometry_payload, list):
|
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
|
||||||
for entry in tx_geometry_payload:
|
model.gpr.tx_geometry.append(
|
||||||
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
|
GprTxGeometryModel(
|
||||||
model.gpr.tx_geometry.append(
|
output_pos=_read_int(entry_payload, "output_pos", 0),
|
||||||
GprTxGeometryModel(
|
x_m=_read_float(entry_payload, "x_m", 0.0),
|
||||||
output_pos=int(entry_payload.get("output_pos", 0)),
|
y_m=_read_float(entry_payload, "y_m", 0.0),
|
||||||
x_m=float(entry_payload.get("x_m", 0.0)),
|
z_m=_read_float(entry_payload, "z_m", 0.0),
|
||||||
y_m=float(entry_payload.get("y_m", 0.0)),
|
|
||||||
z_m=float(entry_payload.get("z_m", 0.0)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
model.gpr.rx_geometry = []
|
model.gpr.rx_geometry = []
|
||||||
rx_geometry_payload = gpr_payload.get("rx_geometry", [])
|
for entry in _as_list(gpr_payload.get("rx_geometry"), "gpr.rx_geometry"):
|
||||||
if isinstance(rx_geometry_payload, list):
|
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
|
||||||
for entry in rx_geometry_payload:
|
model.gpr.rx_geometry.append(
|
||||||
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
|
GprRxGeometryModel(
|
||||||
model.gpr.rx_geometry.append(
|
input_pos=_read_int(entry_payload, "input_pos", 0),
|
||||||
GprRxGeometryModel(
|
x_m=_read_float(entry_payload, "x_m", 0.0),
|
||||||
input_pos=int(entry_payload.get("input_pos", 0)),
|
y_m=_read_float(entry_payload, "y_m", 0.0),
|
||||||
x_m=float(entry_payload.get("x_m", 0.0)),
|
z_m=_read_float(entry_payload, "z_m", 0.0),
|
||||||
y_m=float(entry_payload.get("y_m", 0.0)),
|
|
||||||
z_m=float(entry_payload.get("z_m", 0.0)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
model.apply_device_model_constraints()
|
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(
|
validate_gpr_model(
|
||||||
model.gpr,
|
model.gpr,
|
||||||
input_switch_positions=model.input_switch.positions,
|
input_switch_positions=model.input_switch.positions,
|
||||||
output_switch_positions=model.output_switch.positions,
|
output_switch_positions=model.output_switch.positions,
|
||||||
|
sweep=model.radar.sweep,
|
||||||
)
|
)
|
||||||
|
|
||||||
load_ring_payload(raw_ring_payload, model.rings.raw)
|
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(pre_tap_ring_payload, model.rings.preprocessed_tap)
|
||||||
load_ring_payload(result_ring_payload, model.rings.results)
|
load_ring_payload(result_ring_payload, model.rings.results)
|
||||||
|
|
||||||
combos_payload = run_payload.get("combos", [])
|
|
||||||
model.combos = []
|
model.combos = []
|
||||||
if isinstance(combos_payload, list):
|
for combo in _as_list(run_payload.get("combos"), "run.combos"):
|
||||||
for combo in combos_payload:
|
combo_payload = _as_dict(combo, "run.combos[]")
|
||||||
combo_payload = _as_dict(combo, "run.combos[]")
|
model.combos.append(
|
||||||
model.combos.append(
|
ComboModel(
|
||||||
ComboModel(
|
input=_read_int(combo_payload, "input", 0),
|
||||||
input=int(combo_payload.get("input", 0)),
|
output=_read_int(combo_payload, "output", 0),
|
||||||
output=int(combo_payload.get("output", 0)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
model.ensure_combos()
|
model.ensure_combos()
|
||||||
return model
|
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.
|
config-error contract; surface it as a ValueError naming the field instead.
|
||||||
"""
|
"""
|
||||||
value = payload.get(key, default)
|
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)):
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||||
raise ValueError(f"{key} must be a JSON integer")
|
raise ValueError(f"{key} must be a JSON integer")
|
||||||
try:
|
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:
|
def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
|
||||||
"""Read a string field, rejecting JSON arrays/objects with a named ValueError."""
|
"""Read a string field, rejecting JSON arrays/objects with a named ValueError."""
|
||||||
value = payload.get(key, default)
|
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)):
|
if isinstance(value, (dict, list)):
|
||||||
raise ValueError(f"{key} must be a JSON string")
|
raise ValueError(f"{key} must be a JSON string")
|
||||||
return str(value)
|
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:
|
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."""
|
"""Read a boolean field, rejecting non-boolean JSON types with a named ValueError."""
|
||||||
value = payload.get(key, default)
|
value = payload.get(key, default)
|
||||||
|
if value is None: # explicit JSON null -> use the default
|
||||||
|
return default
|
||||||
if not isinstance(value, bool):
|
if not isinstance(value, bool):
|
||||||
raise ValueError(f"{key} must be a JSON boolean")
|
raise ValueError(f"{key} must be a JSON boolean")
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -42,13 +43,12 @@ class ConfigWriter:
|
|||||||
asset.bundle_path = str(bundle_path)
|
asset.bundle_path = str(bundle_path)
|
||||||
|
|
||||||
def write(self, config: RunConfigModel, output_path: Path) -> 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
|
Dump to a sibling ``.tmp``, flush + ``fsync`` to durably commit the bytes on
|
||||||
durably commit the bytes, then os.replace() onto the destination. The replace
|
the SD-card-backed Pi, then ``os.replace()`` atomically onto the destination.
|
||||||
is atomic, so a C++ consumer can never observe a half-written config (which
|
A C++ consumer can therefore never observe a half-written config (which would
|
||||||
would abort it with an opaque JSON parse error), even across a crash or power
|
abort it with an opaque JSON parse error), even across a crash or power loss.
|
||||||
loss mid-write on the SD-card-backed Pi.
|
|
||||||
"""
|
"""
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# allow_nan=False: a stray NaN/Infinity must fail loudly here in Python
|
# 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.
|
# consumer at startup with an opaque JSON parse error.
|
||||||
serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False)
|
serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False)
|
||||||
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
|
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
|
||||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
try:
|
||||||
handle.write(serialized)
|
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||||
handle.flush()
|
handle.write(serialized)
|
||||||
os.fsync(handle.fileno())
|
handle.flush()
|
||||||
os.replace(tmp_path, output_path)
|
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
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ class ShmRingWriter:
|
|||||||
self._mmap.close()
|
self._mmap.close()
|
||||||
self._file.close()
|
self._file.close()
|
||||||
self._unlink_if_present()
|
self._unlink_if_present()
|
||||||
created = not self._path.exists()
|
|
||||||
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
|
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
|
||||||
self._file = os.fdopen(fd, "r+b", buffering=0)
|
self._file = os.fdopen(fd, "r+b", buffering=0)
|
||||||
self._file.truncate(self._mapped_size)
|
self._file.truncate(self._mapped_size)
|
||||||
@@ -135,23 +134,11 @@ class ShmRingWriter:
|
|||||||
self._write_u64(32, 0)
|
self._write_u64(32, 0)
|
||||||
self._write_u64(40, 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:
|
def _header_matches(self) -> bool:
|
||||||
"""Return whether the existing segment's header matches this ring's geometry.
|
"""Return whether the existing segment's header matches this ring's geometry.
|
||||||
|
|
||||||
Non-throwing counterpart of `_validate_header` used by the owner to decide
|
Used by the owner to decide whether a same-sized pre-existing segment can be
|
||||||
whether a same-sized pre-existing segment can be reused or must be recreated.
|
reused as-is or must be unlinked and recreated.
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
self._mmap[:8] == _MAGIC
|
self._mmap[:8] == _MAGIC
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -162,7 +162,7 @@ class NpzStore(StoreApi):
|
|||||||
raise ValueError("last_n must be > 0")
|
raise ValueError("last_n must be > 0")
|
||||||
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
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 = output_dir / f"snapshot_{timestamp}"
|
||||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ class NpzStore(StoreApi):
|
|||||||
if last_n <= 0:
|
if last_n <= 0:
|
||||||
raise ValueError("last_n must be > 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)
|
snapshot_stem = sanitize_path_component(snapshot_stem)
|
||||||
|
|
||||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -237,7 +237,7 @@ class NpzStore(StoreApi):
|
|||||||
if last_n <= 0:
|
if last_n <= 0:
|
||||||
raise ValueError("last_n must be > 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_stem = sanitize_path_component(output_stem)
|
||||||
|
|
||||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -299,7 +299,7 @@ class NpzStore(StoreApi):
|
|||||||
if last_n <= 0:
|
if last_n <= 0:
|
||||||
raise ValueError("last_n must be > 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_stem = sanitize_path_component(output_stem)
|
||||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
|
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._input_switch = SwitchService(
|
self._input_switch = SwitchService(
|
||||||
name=base_config.input_switch.name,
|
name=base_config.input_switch.name,
|
||||||
positions=base_config.input_switch.positions,
|
positions=base_config.input_switch.positions,
|
||||||
|
default_position=base_config.input_switch.default_position,
|
||||||
mode=base_config.input_switch.driver_mode,
|
mode=base_config.input_switch.driver_mode,
|
||||||
driver=base_config.input_switch.driver,
|
driver=base_config.input_switch.driver,
|
||||||
gpio_chip=base_config.input_switch.gpio_chip,
|
gpio_chip=base_config.input_switch.gpio_chip,
|
||||||
@@ -116,6 +117,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._output_switch = SwitchService(
|
self._output_switch = SwitchService(
|
||||||
name=base_config.output_switch.name,
|
name=base_config.output_switch.name,
|
||||||
positions=base_config.output_switch.positions,
|
positions=base_config.output_switch.positions,
|
||||||
|
default_position=base_config.output_switch.default_position,
|
||||||
mode=base_config.output_switch.driver_mode,
|
mode=base_config.output_switch.driver_mode,
|
||||||
driver=base_config.output_switch.driver,
|
driver=base_config.output_switch.driver,
|
||||||
gpio_chip=base_config.output_switch.gpio_chip,
|
gpio_chip=base_config.output_switch.gpio_chip,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class SequentialCaptureSession:
|
|||||||
self._input_switch = SwitchService(
|
self._input_switch = SwitchService(
|
||||||
name=config.input_switch.name,
|
name=config.input_switch.name,
|
||||||
positions=config.input_switch.positions,
|
positions=config.input_switch.positions,
|
||||||
|
default_position=config.input_switch.default_position,
|
||||||
mode=config.input_switch.driver_mode,
|
mode=config.input_switch.driver_mode,
|
||||||
driver=config.input_switch.driver,
|
driver=config.input_switch.driver,
|
||||||
gpio_chip=config.input_switch.gpio_chip,
|
gpio_chip=config.input_switch.gpio_chip,
|
||||||
@@ -92,6 +93,7 @@ class SequentialCaptureSession:
|
|||||||
self._output_switch = SwitchService(
|
self._output_switch = SwitchService(
|
||||||
name=config.output_switch.name,
|
name=config.output_switch.name,
|
||||||
positions=config.output_switch.positions,
|
positions=config.output_switch.positions,
|
||||||
|
default_position=config.output_switch.default_position,
|
||||||
mode=config.output_switch.driver_mode,
|
mode=config.output_switch.driver_mode,
|
||||||
driver=config.output_switch.driver,
|
driver=config.output_switch.driver,
|
||||||
gpio_chip=config.output_switch.gpio_chip,
|
gpio_chip=config.output_switch.gpio_chip,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ numpy>=1.26,<3
|
|||||||
libusb1>=3.1
|
libusb1>=3.1
|
||||||
pyserial>=3.5,<4
|
pyserial>=3.5,<4
|
||||||
pyvisa>=1.14
|
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
|
PyQt6>=6.6
|
||||||
pyqtgraph>=0.13.7
|
pyqtgraph>=0.13.7
|
||||||
rpi-hardware-pwm>=0.2.2,<1
|
rpi-hardware-pwm>=0.2.2,<1
|
||||||
|
|||||||
Reference in New Issue
Block a user