some fixes
This commit is contained in:
+81
-7
@@ -5,6 +5,7 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <complex>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <random>
|
||||
#include <string>
|
||||
@@ -21,7 +22,26 @@ namespace detail = radar::drivers::librevna::detail;
|
||||
namespace {
|
||||
|
||||
constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
|
||||
constexpr auto kNativeSweepResponseTimeout = std::chrono::milliseconds(1500);
|
||||
|
||||
// Derive the budget the whole multi-point sweep is allowed to take before the
|
||||
// first datapoint must arrive (#7): a fixed setup cost plus the expected dwell
|
||||
// (points / IFBW) with a generous margin, clamped to the overall hard cap. The
|
||||
// per-gap stall timeout then governs progress once datapoints start flowing.
|
||||
[[nodiscard]] auto native_initial_sweep_budget(const config::RadarSweepSettings& sweep)
|
||||
-> std::chrono::milliseconds {
|
||||
const float points = std::max(1.0F, static_cast<float>(sweep.points));
|
||||
const float if_bw = std::max(1.0F, sweep.if_bandwidth_hz);
|
||||
const double dwell_ms =
|
||||
(static_cast<double>(points) / static_cast<double>(if_bw)) * 1000.0 *
|
||||
static_cast<double>(detail::kNativeSweepDwellMargin);
|
||||
const double total_ms = static_cast<double>(detail::kNativeSweepSetupBudgetMs) + dwell_ms;
|
||||
const auto budget = std::chrono::milliseconds(static_cast<std::int64_t>(total_ms));
|
||||
return std::clamp<std::chrono::milliseconds>(
|
||||
budget,
|
||||
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs),
|
||||
std::chrono::milliseconds(detail::kNativeSweepHardCapMs)
|
||||
);
|
||||
}
|
||||
|
||||
// One synthetic GPR reflector. `range_m` is its physical depth, `reflection`
|
||||
// is the dimensionless complex reflection coefficient (|Γ| ≤ 1).
|
||||
@@ -78,12 +98,24 @@ constexpr auto kMockMinimumSweepDuration = std::chrono::microseconds(50);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
|
||||
constexpr std::array<std::string_view, 5> kRetryableSubstrings = {
|
||||
// A stop request must not be retried: drain to the caller immediately (#26).
|
||||
if (message.find("aborted by stop request") != std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr std::array<std::string_view, 8> kRetryableSubstrings = {
|
||||
"Timeout waiting for expected LibreVNA packet type",
|
||||
"Timeout waiting for LibreVNA ACK",
|
||||
"LibreVNA returned NACK",
|
||||
"Failed to read LibreVNA USB bulk packet",
|
||||
"Failed to write LibreVNA USB bulk packet",
|
||||
// #31: a transient USB error often triggers a brief device re-enumeration,
|
||||
// so the device may be momentarily absent or its handle invalidated when
|
||||
// we reconnect. Treat these as retryable; the reconnect path absorbs the
|
||||
// re-enumeration with a short bounded discovery backoff.
|
||||
"No compatible LibreVNA USB device found",
|
||||
"LibreVNA native handle is not open",
|
||||
"LibreVNA USB handle is not open",
|
||||
};
|
||||
|
||||
return std::any_of(
|
||||
@@ -153,10 +185,28 @@ auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
|
||||
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
|
||||
break;
|
||||
}
|
||||
if (detail::native_stop_requested()) {
|
||||
break; // #26: do not waste reconnect attempts while shutting down.
|
||||
}
|
||||
|
||||
// Recover from transient USB/protocol stalls by reconnecting the device.
|
||||
close_native();
|
||||
open_native();
|
||||
// #31: recover from transient USB/protocol stalls without
|
||||
// destroying the libusb_context. Release only the interface
|
||||
// and handle, then reopen against the surviving context so
|
||||
// we ride out a brief device re-enumeration instead of
|
||||
// re-initialising libusb from scratch on every retry.
|
||||
rx_buffer_.clear();
|
||||
packet_queue_.clear();
|
||||
if (usb_handle_ != nullptr && interface_claimed_) {
|
||||
libusb_release_interface(usb_handle_, detail::kUsbInterface);
|
||||
interface_claimed_ = false;
|
||||
}
|
||||
if (usb_handle_ != nullptr) {
|
||||
libusb_close(usb_handle_);
|
||||
usb_handle_ = nullptr;
|
||||
}
|
||||
protocol_version_ = 0;
|
||||
device_num_ports_ = 0;
|
||||
open_native(); // Reuses the surviving usb_context_ and retries discovery.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,12 +332,32 @@ auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
|
||||
std::vector<std::uint8_t> received(settings_.sweep.points, 0U);
|
||||
std::uint32_t received_count = 0;
|
||||
|
||||
const auto deadline = std::chrono::steady_clock::now() + kNativeSweepResponseTimeout;
|
||||
// #7: the per-datapoint deadline is rebased every time a fresh point lands
|
||||
// (or the first point must arrive within the size-derived budget), so a
|
||||
// large sweep is not killed by one fixed ~1.5s timeout. An independent hard
|
||||
// cap bounds the whole sweep against a device that streams forever.
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto hard_cap_deadline = now + std::chrono::milliseconds(detail::kNativeSweepHardCapMs);
|
||||
auto gap_deadline = std::min(now + native_initial_sweep_budget(settings_.sweep), hard_cap_deadline);
|
||||
|
||||
while (received_count < settings_.sweep.points) {
|
||||
// #26: honour a stop request observed during the (chunked) USB wait so
|
||||
// SIGTERM aborts the sweep promptly instead of after the full budget.
|
||||
if (detail::native_stop_requested()) {
|
||||
throw std::runtime_error("Native sweep aborted by stop request");
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= hard_cap_deadline) {
|
||||
throw std::runtime_error(
|
||||
"Native sweep exceeded hard cap (" + std::to_string(received_count) + "/" +
|
||||
std::to_string(settings_.sweep.points) + " points received)"
|
||||
);
|
||||
}
|
||||
|
||||
const auto wait_deadline = std::min(gap_deadline, hard_cap_deadline);
|
||||
|
||||
NativePacket packet{};
|
||||
try {
|
||||
packet = wait_for_packet(detail::kPacketVnaDatapoint, deadline);
|
||||
packet = wait_for_packet(detail::kPacketVnaDatapoint, wait_deadline);
|
||||
} catch (const std::exception& exception) {
|
||||
throw std::runtime_error(
|
||||
"Timeout while collecting VNADatapoints (" + std::to_string(received_count) + "/" +
|
||||
@@ -311,6 +381,10 @@ auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
|
||||
trace.frequency_hz[datapoint.point_number] = datapoint.frequency_hz;
|
||||
trace.s11[datapoint.point_number] = datapoint.s11;
|
||||
trace.s21[datapoint.point_number] = datapoint.s21;
|
||||
|
||||
// Extend the per-gap stall timeout now that progress was made.
|
||||
gap_deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs);
|
||||
}
|
||||
|
||||
return trace;
|
||||
|
||||
+41
-6
@@ -3,10 +3,12 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -104,18 +106,40 @@ namespace {
|
||||
} // namespace
|
||||
|
||||
void LibreVnaMinimalDriver::open_native() {
|
||||
if (usb_context_ != nullptr || usb_handle_ != nullptr) {
|
||||
if (usb_handle_ != nullptr) {
|
||||
throw std::runtime_error("LibreVNA native state is already initialized");
|
||||
}
|
||||
|
||||
const auto init_status = libusb_init(&usb_context_);
|
||||
if (init_status != LIBUSB_SUCCESS) {
|
||||
usb_context_ = nullptr;
|
||||
throw std::runtime_error(libusb_error_message("Failed to initialize libusb", init_status));
|
||||
// #26: observe SIGINT/SIGTERM (chaining to main()'s handler) so the blocking
|
||||
// USB loops can bail out promptly during shutdown. Safe to call repeatedly.
|
||||
detail::install_native_stop_observer();
|
||||
|
||||
// #31: keep the libusb_context alive across reconnect retries. Only create a
|
||||
// fresh context the first time; reconnect paths reuse the surviving context
|
||||
// and merely reopen the handle, avoiding a full libusb teardown/re-init.
|
||||
if (usb_context_ == nullptr) {
|
||||
const auto init_status = libusb_init(&usb_context_);
|
||||
if (init_status != LIBUSB_SUCCESS) {
|
||||
usb_context_ = nullptr;
|
||||
throw std::runtime_error(libusb_error_message("Failed to initialize libusb", init_status));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
auto* selected_handle = find_matching_device_handle(usb_context_, settings_.serial);
|
||||
// #31: retry discovery with a short bounded backoff so a brief device
|
||||
// re-enumeration (common right after a transient USB error) is absorbed
|
||||
// instead of surfacing as a hard "device not found".
|
||||
libusb_device_handle* selected_handle = nullptr;
|
||||
for (int attempt = 0; attempt < detail::kNativeDiscoveryRetryAttempts; ++attempt) {
|
||||
if (detail::native_stop_requested()) {
|
||||
break; // #26: abandon discovery promptly during shutdown.
|
||||
}
|
||||
selected_handle = find_matching_device_handle(usb_context_, settings_.serial);
|
||||
if (selected_handle != nullptr) {
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(detail::kNativeDiscoveryRetryDelayMs));
|
||||
}
|
||||
|
||||
if (selected_handle == nullptr) {
|
||||
const auto serial_hint = settings_.serial.empty() ? std::string() : " for serial '" + settings_.serial + "'";
|
||||
@@ -215,6 +239,12 @@ void LibreVnaMinimalDriver::send_packet_no_payload(std::uint8_t packet_type, boo
|
||||
|
||||
void LibreVnaMinimalDriver::wait_for_ack(std::chrono::steady_clock::time_point deadline) {
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
// #26: bail out promptly on SIGTERM/SIGINT rather than blocking until the
|
||||
// ACK deadline expires.
|
||||
if (detail::native_stop_requested()) {
|
||||
throw std::runtime_error("LibreVNA ACK wait aborted by stop request");
|
||||
}
|
||||
|
||||
std::optional<NativePacket> ack_or_nack{};
|
||||
for (auto iter = packet_queue_.begin(); iter != packet_queue_.end(); ++iter) {
|
||||
if (iter->packet_type != detail::kPacketAck && iter->packet_type != detail::kPacketNack) {
|
||||
@@ -246,6 +276,11 @@ auto LibreVnaMinimalDriver::wait_for_packet(
|
||||
if (auto packet = pop_packet(expected_type); packet.has_value()) {
|
||||
return *packet;
|
||||
}
|
||||
// #26: re-check the stop flag between short USB polls so a shutdown is
|
||||
// honoured within a few hundred ms even on a long sweep deadline.
|
||||
if (detail::native_stop_requested()) {
|
||||
throw std::runtime_error("LibreVNA packet wait aborted by stop request");
|
||||
}
|
||||
pump_usb(deadline);
|
||||
}
|
||||
|
||||
|
||||
+85
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <csignal>
|
||||
#include <signal.h> // POSIX sigaction/NSIG for the chaining stop observer (#26).
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -51,6 +53,21 @@ constexpr std::size_t kUsbReadChunkBytes = 16 * 1024;
|
||||
constexpr int kUsbReadPollMinTimeoutMs = 1;
|
||||
constexpr int kUsbReadPollMaxTimeoutMs = 50;
|
||||
|
||||
// Native sweep timing budget (#7). The native deadline is no longer a single
|
||||
// fixed value for the whole sweep: instead it is derived from the sweep size
|
||||
// (fixed setup cost + per-point dwell ≈ points/IFBW) and is extended every
|
||||
// time a fresh datapoint arrives via a per-gap stall timeout, while an overall
|
||||
// hard cap bounds a wedged device.
|
||||
constexpr int kNativeSweepSetupBudgetMs = 500;
|
||||
constexpr float kNativeSweepDwellMargin = 3.0F;
|
||||
constexpr int kNativeSweepPerGapTimeoutMs = 1500;
|
||||
constexpr int kNativeSweepHardCapMs = 60'000;
|
||||
|
||||
// Reconnect backoff (#31). After a transient error the device may re-enumerate;
|
||||
// retry discovery a few times with a short bounded delay before giving up.
|
||||
constexpr int kNativeDiscoveryRetryAttempts = 10;
|
||||
constexpr int kNativeDiscoveryRetryDelayMs = 150;
|
||||
|
||||
[[nodiscard]] inline auto read_u16_le(std::span<const std::uint8_t> data, std::size_t offset) -> std::uint16_t {
|
||||
if ((offset + sizeof(std::uint16_t)) > data.size()) {
|
||||
throw std::runtime_error("Failed to decode uint16 from payload");
|
||||
@@ -108,5 +125,73 @@ inline void write_u32_le(std::span<std::uint8_t> data, std::size_t offset, std::
|
||||
data[offset + 3] = static_cast<std::uint8_t>((value >> 24U) & 0xFFU);
|
||||
}
|
||||
|
||||
// Stop responsiveness (#26). The blocking USB loops keep their per-call
|
||||
// timeouts short and re-poll this flag so a SIGTERM/SIGINT delivered mid-sweep
|
||||
// is honoured within a few hundred ms instead of only after the whole sweep
|
||||
// budget has elapsed.
|
||||
//
|
||||
// `main()` installs its own SIGINT/SIGTERM handlers before any driver is
|
||||
// opened, so we chain to (and preserve) whatever handler is already installed
|
||||
// rather than clobbering it. All state lives in single inline-static slots so
|
||||
// every translation unit observes the same flag and chain table.
|
||||
[[nodiscard]] inline auto native_stop_flag() -> volatile std::sig_atomic_t& {
|
||||
static volatile std::sig_atomic_t flag = 0;
|
||||
return flag;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto native_chained_handlers() -> std::array<struct sigaction, NSIG>& {
|
||||
static std::array<struct sigaction, NSIG> handlers{};
|
||||
return handlers;
|
||||
}
|
||||
|
||||
inline void native_stop_signal_handler(int signal_number) {
|
||||
native_stop_flag() = 1;
|
||||
|
||||
// Chain to the previously installed disposition (e.g. main()'s handler) so
|
||||
// process-level stop semantics are unchanged.
|
||||
if (signal_number < 0 || signal_number >= NSIG) {
|
||||
return;
|
||||
}
|
||||
const auto& prior = native_chained_handlers()[static_cast<std::size_t>(signal_number)];
|
||||
if ((prior.sa_flags & SA_SIGINFO) == 0 && prior.sa_handler != nullptr &&
|
||||
prior.sa_handler != SIG_DFL && prior.sa_handler != SIG_IGN &&
|
||||
prior.sa_handler != native_stop_signal_handler) {
|
||||
prior.sa_handler(signal_number);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily install the chaining stop observer for one signal. Must be called
|
||||
// after main() has installed its handlers (i.e. from open_native()).
|
||||
inline void install_native_stop_observer_for(int signal_number) {
|
||||
if (signal_number < 0 || signal_number >= NSIG) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct sigaction current{};
|
||||
if (sigaction(signal_number, nullptr, ¤t) != 0) {
|
||||
return;
|
||||
}
|
||||
if (current.sa_handler == native_stop_signal_handler) {
|
||||
return; // Already installed; do not chain to ourselves.
|
||||
}
|
||||
|
||||
native_chained_handlers()[static_cast<std::size_t>(signal_number)] = current;
|
||||
|
||||
struct sigaction action{};
|
||||
action.sa_handler = native_stop_signal_handler;
|
||||
sigemptyset(&action.sa_mask);
|
||||
action.sa_flags = current.sa_flags & ~SA_SIGINFO; // Keep flags such as SA_RESTART.
|
||||
sigaction(signal_number, &action, nullptr);
|
||||
}
|
||||
|
||||
inline void install_native_stop_observer() {
|
||||
install_native_stop_observer_for(SIGINT);
|
||||
install_native_stop_observer_for(SIGTERM);
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto native_stop_requested() -> bool {
|
||||
return native_stop_flag() != 0;
|
||||
}
|
||||
|
||||
} // namespace radar::drivers::librevna::detail
|
||||
|
||||
|
||||
+31
-2
@@ -14,6 +14,10 @@
|
||||
namespace radar::drivers {
|
||||
namespace {
|
||||
|
||||
// A transient ioctl failure (e.g. EINTR/EAGAIN/EBUSY under load) should not be
|
||||
// immediately fatal; retry a small number of times before giving up.
|
||||
constexpr int kSwitchIoctlRetries = 3;
|
||||
|
||||
// Position mapping for H7992 control pins:
|
||||
// position -> (A, B)
|
||||
constexpr std::array<std::array<std::uint8_t, 2>, 4> kPositionToAB = {
|
||||
@@ -44,6 +48,15 @@ void validate_open_settings(const H7992MinimalDriverSettings& settings) {
|
||||
|
||||
H7992MinimalDriver::H7992MinimalDriver(H7992MinimalDriverSettings settings) : settings_(std::move(settings)) {}
|
||||
|
||||
H7992MinimalDriver::~H7992MinimalDriver() {
|
||||
// Safety net: if the owner never called close() (e.g. on crash/unwind),
|
||||
// still drive the RF path to a known-safe state and release the GPIO lines.
|
||||
try {
|
||||
close();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
void H7992MinimalDriver::open() {
|
||||
if (is_open_) {
|
||||
return;
|
||||
@@ -134,6 +147,15 @@ void H7992MinimalDriver::open_native() {
|
||||
}
|
||||
|
||||
void H7992MinimalDriver::close_native() {
|
||||
// Leave the RF path in its known-safe default position before releasing the
|
||||
// lines, so shutdown/crash never strands the switch in an arbitrary state.
|
||||
if (line_fd_ >= 0 && settings_.default_position < settings_.positions) {
|
||||
const auto [pin_a_state, pin_b_state] = kPositionToAB[settings_.default_position];
|
||||
auto values = make_line_values(pin_a_state, pin_b_state);
|
||||
// Best-effort: do not throw out of the teardown path.
|
||||
::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values);
|
||||
}
|
||||
|
||||
if (line_fd_ >= 0) {
|
||||
::close(line_fd_);
|
||||
line_fd_ = -1;
|
||||
@@ -152,9 +174,16 @@ void H7992MinimalDriver::switch_native(std::uint32_t position) {
|
||||
const auto [pin_a_state, pin_b_state] = kPositionToAB[position];
|
||||
auto values = make_line_values(pin_a_state, pin_b_state);
|
||||
|
||||
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) != 0) {
|
||||
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(errno));
|
||||
// Retry transient ioctl failures so a momentary hiccup is recoverable
|
||||
// instead of aborting the whole sweep; only the final attempt is fatal.
|
||||
int last_errno = 0;
|
||||
for (int attempt = 0; attempt < kSwitchIoctlRetries; ++attempt) {
|
||||
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) == 0) {
|
||||
return;
|
||||
}
|
||||
last_errno = errno;
|
||||
}
|
||||
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(last_errno));
|
||||
}
|
||||
|
||||
} // namespace radar::drivers
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ struct H7992MinimalDriverSettings {
|
||||
class H7992MinimalDriver final : public SwitchDriver {
|
||||
public:
|
||||
explicit H7992MinimalDriver(H7992MinimalDriverSettings settings);
|
||||
~H7992MinimalDriver() override; // Leaves RF path safe even if close() was skipped.
|
||||
|
||||
void open() override;
|
||||
void close() override;
|
||||
|
||||
+44
-8
@@ -13,6 +13,23 @@
|
||||
namespace radar::drivers {
|
||||
namespace {
|
||||
|
||||
// A transient ioctl failure (e.g. EINTR/EAGAIN/EBUSY under load) should not be
|
||||
// immediately fatal; retry a small number of times before giving up.
|
||||
constexpr int kSwitchIoctlRetries = 3;
|
||||
|
||||
// Compute the GPIO line values for a given logical position, honouring inverted
|
||||
// control logic. Shared by switch_native() and the teardown default-drive path.
|
||||
[[nodiscard]] auto make_line_values(const HMC349AMinimalDriverSettings& settings, std::uint32_t position)
|
||||
-> gpio_v2_line_values {
|
||||
const std::uint8_t requested_state = static_cast<std::uint8_t>(position & 0x01U);
|
||||
const std::uint8_t control_state = settings.invert_logic ? (requested_state ^ 0x01U) : requested_state;
|
||||
|
||||
gpio_v2_line_values values{};
|
||||
values.mask = (1ULL << 0U);
|
||||
values.bits = static_cast<std::uint64_t>(control_state);
|
||||
return values;
|
||||
}
|
||||
|
||||
void validate_open_settings(const HMC349AMinimalDriverSettings& settings) {
|
||||
if (settings.positions == 0U || settings.positions > 2U) {
|
||||
throw std::runtime_error("HMC349A switch positions must be in range [1, 2] for " + settings.name);
|
||||
@@ -27,6 +44,15 @@ void validate_open_settings(const HMC349AMinimalDriverSettings& settings) {
|
||||
HMC349AMinimalDriver::HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings)
|
||||
: settings_(std::move(settings)) {}
|
||||
|
||||
HMC349AMinimalDriver::~HMC349AMinimalDriver() {
|
||||
// Safety net: if the owner never called close() (e.g. on crash/unwind),
|
||||
// still drive the RF path to a known-safe state and release the GPIO lines.
|
||||
try {
|
||||
close();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
void HMC349AMinimalDriver::open() {
|
||||
if (is_open_) {
|
||||
return;
|
||||
@@ -117,6 +143,14 @@ void HMC349AMinimalDriver::open_native() {
|
||||
}
|
||||
|
||||
void HMC349AMinimalDriver::close_native() {
|
||||
// Leave the RF path in its known-safe default position before releasing the
|
||||
// line, so shutdown/crash never strands the switch in an arbitrary state.
|
||||
if (line_fd_ >= 0 && settings_.default_position < settings_.positions) {
|
||||
auto values = make_line_values(settings_, settings_.default_position);
|
||||
// Best-effort: do not throw out of the teardown path.
|
||||
::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values);
|
||||
}
|
||||
|
||||
if (line_fd_ >= 0) {
|
||||
::close(line_fd_);
|
||||
line_fd_ = -1;
|
||||
@@ -132,16 +166,18 @@ void HMC349AMinimalDriver::switch_native(std::uint32_t position) {
|
||||
throw std::runtime_error("Native GPIO line fd is not open for " + settings_.name);
|
||||
}
|
||||
|
||||
const std::uint8_t requested_state = static_cast<std::uint8_t>(position & 0x01U);
|
||||
const std::uint8_t control_state = settings_.invert_logic ? (requested_state ^ 0x01U) : requested_state;
|
||||
auto values = make_line_values(settings_, position);
|
||||
|
||||
gpio_v2_line_values values{};
|
||||
values.mask = (1ULL << 0U);
|
||||
values.bits = static_cast<std::uint64_t>(control_state);
|
||||
|
||||
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) != 0) {
|
||||
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(errno));
|
||||
// Retry transient ioctl failures so a momentary hiccup is recoverable
|
||||
// instead of aborting the whole sweep; only the final attempt is fatal.
|
||||
int last_errno = 0;
|
||||
for (int attempt = 0; attempt < kSwitchIoctlRetries; ++attempt) {
|
||||
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) == 0) {
|
||||
return;
|
||||
}
|
||||
last_errno = errno;
|
||||
}
|
||||
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(last_errno));
|
||||
}
|
||||
|
||||
} // namespace radar::drivers
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ struct HMC349AMinimalDriverSettings {
|
||||
class HMC349AMinimalDriver final : public SwitchDriver {
|
||||
public:
|
||||
explicit HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings);
|
||||
~HMC349AMinimalDriver() override; // Leaves RF path safe even if close() was skipped.
|
||||
|
||||
void open() override;
|
||||
void close() override;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "sweep_orchestrator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
@@ -9,6 +12,16 @@
|
||||
namespace radar::acq {
|
||||
namespace {
|
||||
|
||||
// Wait-for-device retry tuning (mirror of python matrix_raw_producer._open_radar_with_retry):
|
||||
// a device that is absent at boot or disappears mid-run must never kill the orchestrator,
|
||||
// only make it wait. Backoff is capped so a long absence does not busy-spin, and every wait
|
||||
// is interruptible by stop_requested for a prompt clean exit.
|
||||
constexpr std::uint32_t kOpenRetryMinMs = 1'000U;
|
||||
constexpr std::uint32_t kOpenRetryMaxMs = 10'000U;
|
||||
// Throttle open-failure logging during a long wait so a permanently absent device does not
|
||||
// flood the process log: log the first failure, then every Nth attempt.
|
||||
constexpr std::uint64_t kOpenRetryLogEvery = 30U;
|
||||
|
||||
[[nodiscard]] inline auto should_stop(const std::atomic<bool>& stop_requested) -> bool {
|
||||
return stop_requested.load(std::memory_order_relaxed);
|
||||
}
|
||||
@@ -20,6 +33,21 @@ void sleep_if_needed_ms(std::uint32_t delay_ms) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
|
||||
}
|
||||
|
||||
// Interruptible sleep: returns true if a stop was requested while waiting.
|
||||
[[nodiscard]] auto interruptible_sleep_ms(std::uint32_t delay_ms, const std::atomic<bool>& stop_requested) -> bool {
|
||||
constexpr std::uint32_t kPollMs = 50U;
|
||||
std::uint32_t waited = 0U;
|
||||
while (waited < delay_ms) {
|
||||
if (should_stop(stop_requested)) {
|
||||
return true;
|
||||
}
|
||||
const std::uint32_t chunk = std::min(kPollMs, delay_ms - waited);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(chunk));
|
||||
waited += chunk;
|
||||
}
|
||||
return should_stop(stop_requested);
|
||||
}
|
||||
|
||||
void validate_sweep(const drivers::SweepTrace& sweep) {
|
||||
if (sweep.frequency_hz.size() != sweep.s11.size() || sweep.frequency_hz.size() != sweep.s21.size()) {
|
||||
throw std::runtime_error("Radar driver returned inconsistent sweep vectors");
|
||||
@@ -64,6 +92,41 @@ class DriverLifecycleGuard {
|
||||
active_ = true;
|
||||
}
|
||||
|
||||
// Open all devices, retrying forever with capped exponential backoff until success or
|
||||
// stop. Used for both the initial open and every in-loop reopen, so a device that is
|
||||
// absent at boot or disappears mid-run never kills the orchestrator — it just waits.
|
||||
// Returns false only if a stop was requested before any device became available.
|
||||
[[nodiscard]] auto open_all_with_retry(const std::atomic<bool>& stop_requested) -> bool {
|
||||
// Drop any partial state from a previous open before retrying.
|
||||
close_all();
|
||||
|
||||
std::uint64_t attempt = 0;
|
||||
std::uint32_t delay_ms = kOpenRetryMinMs;
|
||||
while (!should_stop(stop_requested)) {
|
||||
try {
|
||||
open_all();
|
||||
if (attempt > 0) {
|
||||
std::cerr << "sweep_orchestrator: devices opened after " << (attempt + 1) << " attempt(s)\n";
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception& exc) {
|
||||
// Best-effort: drop any partial open before the next attempt.
|
||||
close_all();
|
||||
++attempt;
|
||||
if (attempt == 1 || attempt % kOpenRetryLogEvery == 0) {
|
||||
std::cerr << "sweep_orchestrator: devices not available (attempt " << attempt
|
||||
<< "); retrying up to every " << (kOpenRetryMaxMs / 1'000U)
|
||||
<< "s until present: " << exc.what() << '\n';
|
||||
}
|
||||
if (interruptible_sleep_ms(delay_ms, stop_requested)) {
|
||||
return false;
|
||||
}
|
||||
delay_ms = std::min(delay_ms * 2U, kOpenRetryMaxMs);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void close_all() {
|
||||
if (!active_) {
|
||||
return;
|
||||
@@ -81,14 +144,47 @@ class DriverLifecycleGuard {
|
||||
bool active_ = false;
|
||||
};
|
||||
|
||||
void publish_collection(ipc::ShmRing& raw_ring, ipc::ShmRing* raw_tap_ring, const ipc::RawSweepCollection& collection) {
|
||||
// Worst-case serialized size of a collection given the configured combo count and sweep
|
||||
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
|
||||
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
|
||||
// + capture_start_ns(8) + capture_end_ns(8) = 40 bytes
|
||||
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
|
||||
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
|
||||
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
|
||||
constexpr std::size_t kCollectionHeaderBytes = 40U;
|
||||
constexpr std::size_t kTraceHeaderBytes = 12U;
|
||||
constexpr std::size_t kBytesPerPoint = 20U;
|
||||
const std::size_t per_trace = kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint);
|
||||
return kCollectionHeaderBytes + (combo_count * per_trace);
|
||||
}
|
||||
|
||||
// Publish a collection on the primary raw ring; tap pushes are strictly best-effort.
|
||||
// Never throws: an oversize payload is logged-and-dropped (the slot-size budget is also
|
||||
// validated up front at startup, so this guards only against unexpected runtime growth).
|
||||
// Returns true if the primary push succeeded.
|
||||
[[nodiscard]] auto publish_collection(
|
||||
ipc::ShmRing& raw_ring,
|
||||
ipc::ShmRing* raw_tap_ring,
|
||||
const ipc::RawSweepCollection& collection,
|
||||
std::uint64_t& oversize_drop_count
|
||||
) -> bool {
|
||||
const auto serialized_collection = ipc::serialize_raw_collection(collection);
|
||||
if (!raw_ring.push(serialized_collection)) {
|
||||
throw std::runtime_error("Raw ring slot is too small for serialized collection");
|
||||
// Log-and-drop an oversize payload rather than aborting the long-running loop.
|
||||
// Throttle so a persistently oversize payload cannot flood the log.
|
||||
if (oversize_drop_count % 100 == 0) {
|
||||
std::cerr << "sweep_orchestrator: dropped oversize raw payload (" << serialized_collection.size()
|
||||
<< " bytes > slot " << raw_ring.slot_size_bytes() << "; drop count="
|
||||
<< (oversize_drop_count + 1) << ")\n";
|
||||
}
|
||||
++oversize_drop_count;
|
||||
return false;
|
||||
}
|
||||
if (raw_tap_ring != nullptr && !raw_tap_ring->push(serialized_collection)) {
|
||||
throw std::runtime_error("Raw tap ring slot is too small for serialized collection");
|
||||
// Tap ring is for GUI/debug observers only: a tap failure must never abort the primary path.
|
||||
if (raw_tap_ring != nullptr) {
|
||||
(void)raw_tap_ring->push(serialized_collection);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -109,25 +205,59 @@ SweepOrchestrator::SweepOrchestrator(
|
||||
raw_tap_ring_(raw_tap_ring) {}
|
||||
|
||||
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
|
||||
// Fail fast on a config error: a slot that is too small for the worst-case payload can
|
||||
// never carry a full collection, so report it clearly at startup instead of dropping
|
||||
// every collection at runtime (fix #27).
|
||||
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
|
||||
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
|
||||
throw std::runtime_error(
|
||||
"Raw ring slot_size_bytes (" + std::to_string(raw_ring_.slot_size_bytes())
|
||||
+ ") is too small for the worst-case serialized collection (" + std::to_string(worst_case_bytes)
|
||||
+ " bytes for " + std::to_string(config_.run_combos.size()) + " combos x "
|
||||
+ std::to_string(config_.radar.sweep.points) + " points)"
|
||||
);
|
||||
}
|
||||
|
||||
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
|
||||
// Open devices once and keep them active for the whole acquisition loop.
|
||||
lifecycle_guard.open_all();
|
||||
// Wait for the devices to become available before starting (fix #8/#9): an absent device
|
||||
// makes the orchestrator wait, not exit.
|
||||
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
||||
return; // stop requested before any device became available
|
||||
}
|
||||
|
||||
std::uint64_t collection_id = 0;
|
||||
std::uint64_t oversize_drop_count = 0;
|
||||
while (!should_stop(stop_requested)) {
|
||||
auto raw_collection = acquire_one_collection(++collection_id, stop_requested);
|
||||
if (raw_collection.traces.empty()) {
|
||||
try {
|
||||
auto raw_collection = acquire_one_collection(++collection_id, stop_requested);
|
||||
if (raw_collection.traces.empty()) {
|
||||
if (!config_.runtime.continuous) {
|
||||
break;
|
||||
}
|
||||
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
|
||||
continue;
|
||||
}
|
||||
|
||||
(void)publish_collection(raw_ring_, raw_tap_ring_, raw_collection, oversize_drop_count);
|
||||
|
||||
if (!config_.runtime.continuous) {
|
||||
break;
|
||||
}
|
||||
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
|
||||
continue;
|
||||
}
|
||||
|
||||
publish_collection(raw_ring_, raw_tap_ring_, raw_collection);
|
||||
|
||||
if (!config_.runtime.continuous) {
|
||||
break;
|
||||
} catch (const std::exception& exc) {
|
||||
// Treat any in-loop device error (timeout/NACK/transient USB or socket glitch/
|
||||
// device-not-found-after-glitch) as recoverable: close, wait for the device to
|
||||
// come back, and continue. This mirrors the python producer's reconnect-forever
|
||||
// policy so a long-running headless appliance survives transient hardware hiccups
|
||||
// instead of exiting. Genuinely fatal config errors are caught at startup above
|
||||
// (and in main()), reserving non-zero exit for those.
|
||||
std::cerr << "sweep_orchestrator: acquisition failed; reopening and waiting for the device: "
|
||||
<< exc.what() << '\n';
|
||||
if (!config_.runtime.continuous) {
|
||||
throw; // single-run mode has no recovery path; surface the failure
|
||||
}
|
||||
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
|
||||
break; // stop requested while waiting to reopen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user