Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -0,0 +1,53 @@
#pragma once
#include <atomic>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include "run_config.hpp"
namespace radar::runtime {
inline std::atomic<bool> stop_requested{false};
inline auto initialize(int argc, char** argv) -> config::RunConfig {
const auto stop = [](int) { stop_requested.store(true, std::memory_order_relaxed); };
std::signal(SIGINT, stop);
std::signal(SIGTERM, stop);
std::signal(SIGPIPE, SIG_IGN);
std::string path = "run_config.json";
for (int index = 1; index + 1 < argc; ++index) {
if (std::string(argv[index]) == "--config") { path = argv[++index]; }
}
return config::load_run_config(path);
}
class FatalPipelineError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Drop isolated malformed collections; make persistent failures conspicuous.
class CollectionErrors {
public:
void progress() { consecutive_ = 0; }
void report(const char* worker, const std::exception& error) {
++total_;
if (++consecutive_ % 500 == 0) {
std::cerr << worker << ": " << consecutive_
<< " consecutive failures — likely a permanent configuration error: "
<< error.what() << '\n';
} else if (total_ % 100 == 1) {
std::cerr << worker << ": dropped collection after error (count="
<< total_ << "): " << error.what() << '\n';
}
}
private:
std::uint64_t total_ = 0;
std::uint64_t consecutive_ = 0;
};
} // namespace radar::runtime
@@ -14,40 +14,15 @@ enum class DriverMode {
Native,
};
enum class SwitchDriverKind {
H7992,
HMC349A,
};
struct RadarSweepSettings {
// Frequency sweep bounds and acquisition settings.
float start_hz = 1'000'000.0F;
float stop_hz = 6'000'000'000.0F;
std::uint32_t points = 501;
float if_bandwidth_hz = 1'000.0F;
float power_dbm = -10.0F;
};
struct RadarConfig {
// Radar device identity and runtime mode.
std::string model = "librevna";
std::string serial{};
DriverMode driver_mode = DriverMode::Mock;
RadarSweepSettings sweep{};
};
struct SwitchConfig {
// One RF switch attached to a specific radar port.
std::string name{};
DriverMode driver_mode = DriverMode::Mock;
SwitchDriverKind driver_kind = SwitchDriverKind::H7992;
std::uint32_t radar_port = 0;
std::uint32_t positions = 4;
std::uint32_t default_position = 0;
std::string gpio_chip = "/dev/gpiochip0";
std::int32_t pin_a = 17;
std::int32_t pin_b = 27;
bool invert_logic = false;
};
struct RingEndpointConfig {
@@ -59,22 +34,18 @@ struct RingEndpointConfig {
struct RingsConfig {
RingEndpointConfig raw{.name = "/radar_raw"};
RingEndpointConfig raw_tap{.name = "/radar_raw_tap"};
RingEndpointConfig preprocessed{.name = "/radar_preprocessed"};
RingEndpointConfig preprocessed_tap{.name = "/radar_preprocessed_tap"};
RingEndpointConfig results{.name = "/radar_results"};
};
struct RuntimeConfig {
// Runtime pacing and behavior flags for C++ workers.
std::uint32_t settling_ms = 0;
// Runtime pacing and live processing configuration.
std::uint32_t idle_sleep_ms = 2;
bool continuous = true;
std::string processing_live_config_path = "python_app/runtime/processing_live.json";
};
struct PreprocessAssetConfig {
std::string set_name{};
std::string bundle_path{};
};
@@ -84,17 +55,6 @@ struct S21PreprocessConfig {
PreprocessAssetConfig object_calibration{};
};
struct S11CalibrationPreprocessConfig {
PreprocessAssetConfig open{};
PreprocessAssetConfig short_standard{};
PreprocessAssetConfig load{};
};
struct S11PreprocessConfig {
S11CalibrationPreprocessConfig calibration{};
PreprocessAssetConfig reference{};
};
struct PreprocessNotchBandConfig {
float low_hz = 0.0F;
float high_hz = 0.0F;
@@ -110,7 +70,6 @@ struct PreprocessNotchConfig {
struct PreprocessConfig {
// Channel-specific preprocessing assets selected by Python GUI layer.
S21PreprocessConfig s21{};
S11PreprocessConfig s11{};
PreprocessNotchConfig notch{};
};
@@ -140,7 +99,7 @@ struct GprConfig {
struct RunConfig {
// Single configuration object shared by all C++ processes.
RadarConfig radar{};
std::string radar_model = "librevna";
SwitchConfig input_switch{};
SwitchConfig output_switch{};
RingsConfig rings{};
@@ -160,7 +160,6 @@ using Json = nlohmann::json;
const auto* asset_obj = as_object(required_field(object, key), asset_context);
PreprocessAssetConfig asset{};
asset.set_name = optional_string(*asset_obj, "set_name", "");
asset.bundle_path = optional_string(*asset_obj, "bundle_path", "");
return asset;
}
@@ -242,33 +241,7 @@ using Json = nlohmann::json;
throw std::runtime_error("Unsupported driver_mode value: " + value);
}
[[nodiscard]] auto parse_switch_driver_kind(const std::string& value) -> SwitchDriverKind {
if (value == "h7992") {
return SwitchDriverKind::H7992;
}
if (value == "hmc349a") {
return SwitchDriverKind::HMC349A;
}
throw std::runtime_error("Unsupported switch driver value: " + value);
}
void validate_radar_sweep(const RadarSweepSettings& sweep) {
if (sweep.start_hz < 0.0F || sweep.stop_hz < 0.0F || !(sweep.if_bandwidth_hz > 0.0F)) {
throw std::runtime_error("radar.sweep frequencies must be non-negative and IF bandwidth must be > 0");
}
if (sweep.points == 0U) {
throw std::runtime_error("radar.sweep.points must be > 0");
}
if (sweep.stop_hz < sweep.start_hz) {
throw std::runtime_error("radar.sweep.stop_hz must be >= start_hz");
}
}
void validate_switch_config(const SwitchConfig& config) {
if (config.radar_port != 1U && config.radar_port != 2U) {
throw std::runtime_error("Switch radar_port must be 1 or 2 for " + config.name);
}
if (config.positions == 0U) {
throw std::runtime_error("Switch positions must be > 0 for " + config.name);
}
@@ -284,21 +257,11 @@ void validate_switch_config(const SwitchConfig& config) {
throw std::runtime_error("gpio_chip is required for native switch mode: " + config.name);
}
if (config.driver_kind == SwitchDriverKind::H7992) {
if (config.positions > 4U) {
throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
}
if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
}
return;
if (config.positions > 4U) {
throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
}
if (config.positions > 2U) {
throw std::runtime_error("Native HMC349A switch driver supports positions <= 2 for " + config.name);
}
if (config.pin_a < 0) {
throw std::runtime_error("pin_a (control) is required for native HMC349A mode: " + config.name);
if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
}
}
@@ -350,22 +313,24 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
[[nodiscard]] auto parse_switch_config(
const Json& object,
const std::string& default_name,
std::uint32_t default_radar_port
const std::string& default_name
) -> SwitchConfig {
const auto* switch_obj = as_object(object, default_name);
SwitchConfig config{};
config.name = optional_string(*switch_obj, "name", default_name);
config.driver_mode = parse_driver_mode(optional_string(*switch_obj, "driver_mode", "mock"));
config.driver_kind = parse_switch_driver_kind(optional_string(*switch_obj, "driver", "h7992"));
config.radar_port = optional_u32(*switch_obj, "radar_port", default_radar_port);
if (optional_string(*switch_obj, "driver", "h7992") != "h7992") {
throw std::runtime_error("Only H7992 switches are supported; check GPIO wiring before updating " + default_name);
}
if (optional_bool(*switch_obj, "invert_logic", false)) {
throw std::runtime_error("Switch invert_logic is unsupported for H7992: " + default_name);
}
config.positions = optional_u32(*switch_obj, "positions", 4);
config.default_position = optional_u32(*switch_obj, "default_position", 0);
config.gpio_chip = optional_string(*switch_obj, "gpio_chip", "/dev/gpiochip0");
config.pin_a = optional_i32(*switch_obj, "pin_a", 17);
config.pin_b = optional_i32(*switch_obj, "pin_b", 27);
config.invert_logic = optional_bool(*switch_obj, "invert_logic", false);
validate_switch_config(config);
@@ -397,6 +362,9 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
if (endpoint.slot_size_bytes % 8U != 0U) {
throw std::runtime_error("Ring slot_size_bytes must be a multiple of 8 for " + key);
}
if (endpoint.name.empty() && key == "preprocessed_tap") {
return endpoint;
}
if (endpoint.name.empty() || endpoint.name.front() != '/') {
throw std::runtime_error("Ring name must start with '/' for " + key);
}
@@ -462,59 +430,18 @@ auto load_run_config(const std::string& path) -> RunConfig {
{
const auto* radar_obj = as_object(required_field(*root_obj, "radar"), "radar");
config.radar.model = optional_string(*radar_obj, "model", "librevna");
config.radar.serial = optional_string(*radar_obj, "serial", "");
config.radar.driver_mode = parse_driver_mode(optional_string(*radar_obj, "driver_mode", "mock"));
const auto* sweep_obj = as_object(required_field(*radar_obj, "sweep"), "radar.sweep");
config.radar.sweep.start_hz =
number_to_f32(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz"), "radar.sweep.start_hz");
config.radar.sweep.stop_hz =
number_to_f32(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz"), "radar.sweep.stop_hz");
if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) {
config.radar.sweep.points =
number_to_u32(as_number(*points_value, "radar.sweep.points"), "radar.sweep.points");
} else if (config.radar.model != "kamil_adc") {
throw std::runtime_error("Missing required config field: points");
}
config.radar.sweep.if_bandwidth_hz = number_to_f32(
as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"
);
config.radar.sweep.power_dbm = optional_f32(*sweep_obj, "stimulus_power_dbm", -10.0F);
validate_radar_sweep(config.radar.sweep);
config.radar_model = optional_string(*radar_obj, "model", "librevna");
}
{
const auto* switches_obj = as_object(required_field(*root_obj, "switches"), "switches");
if (const auto* port1_value = optional_field(*switches_obj, "port1"); port1_value != nullptr) {
const auto* port1_obj = as_object(*port1_value, "switches.port1");
const auto* port2_obj = as_object(required_field(*switches_obj, "port2"), "switches.port2");
config.output_switch = parse_switch_config(*port1_obj, "port1", 1);
config.input_switch = parse_switch_config(*port2_obj, "port2", 2);
} else {
config.input_switch = parse_switch_config(
*as_object(required_field(*switches_obj, "input"), "switches.input"),
"input",
2
);
config.output_switch = parse_switch_config(
*as_object(required_field(*switches_obj, "output"), "switches.output"),
"output",
1
);
}
if (config.input_switch.radar_port == config.output_switch.radar_port) {
throw std::runtime_error("Switch radar_port mapping must be unique for two switches");
}
config.output_switch = parse_switch_config(required_field(*switches_obj, "port1"), "port1");
config.input_switch = parse_switch_config(required_field(*switches_obj, "port2"), "port2");
}
{
const auto* run_obj = as_object(required_field(*root_obj, "run"), "run");
config.runtime.settling_ms = optional_u32(*run_obj, "settling_ms", 0);
config.runtime.idle_sleep_ms = optional_u32(*run_obj, "idle_sleep_ms", 2);
config.runtime.continuous = optional_bool(*run_obj, "continuous", true);
config.runtime.processing_live_config_path = optional_string(
*run_obj,
"processing_live_config_path",
@@ -560,17 +487,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
parse_preprocess_asset(*s21_obj, "object_calibration", "preprocess.s21");
}
const auto* s11_obj = as_object(required_field(*preprocess_obj, "s11"), "preprocess.s11");
const auto* s11_calibration_obj =
as_object(required_field(*s11_obj, "calibration"), "preprocess.s11.calibration");
config.preprocess.s11.calibration.open =
parse_preprocess_asset(*s11_calibration_obj, "open", "preprocess.s11.calibration");
config.preprocess.s11.calibration.short_standard =
parse_preprocess_asset(*s11_calibration_obj, "short", "preprocess.s11.calibration");
config.preprocess.s11.calibration.load =
parse_preprocess_asset(*s11_calibration_obj, "load", "preprocess.s11.calibration");
config.preprocess.s11.reference = parse_preprocess_asset(*s11_obj, "reference", "preprocess.s11");
if (const auto* notch_value = optional_field(*preprocess_obj, "notch"); notch_value != nullptr) {
config.preprocess.notch = parse_preprocess_notch(*as_object(*notch_value, "preprocess.notch"), "preprocess.notch");
}
@@ -585,7 +501,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
{
const auto* rings_obj = as_object(required_field(*root_obj, "rings"), "rings");
config.rings.raw = parse_ring_endpoint(*rings_obj, "raw", config.rings.raw);
config.rings.raw_tap = parse_ring_endpoint(*rings_obj, "raw_tap", config.rings.raw_tap);
config.rings.preprocessed = parse_ring_endpoint(*rings_obj, "preprocessed", config.rings.preprocessed);
config.rings.preprocessed_tap = parse_ring_endpoint(
*rings_obj,
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <string>
#include "run_config.hpp"
namespace radar::drivers {
/**
* @brief Runtime settings for the H7992 GPIO switch driver.
*/
struct H7992MinimalDriverSettings {
std::string name{};
config::DriverMode mode = config::DriverMode::Native;
std::uint32_t positions = 4;
std::uint32_t default_position = 0;
std::string gpio_chip = "/dev/gpiochip0";
std::int32_t pin_a = 17;
std::int32_t pin_b = 27;
};
/**
* @brief Minimal H7992 implementation for fast position switching.
*
* Native mode uses Linux GPIO character-device API (uAPI v2).
* Mock mode keeps state in memory without touching hardware.
*/
class H7992MinimalDriver {
public:
explicit H7992MinimalDriver(H7992MinimalDriverSettings settings);
~H7992MinimalDriver(); // Leaves RF path safe even if close() was skipped.
void open();
void close();
void switch_to(std::uint32_t position);
private:
void open_native();
void close_native();
void switch_native(std::uint32_t position);
H7992MinimalDriverSettings settings_{};
bool is_open_ = false;
std::uint32_t current_position_ = 0;
int chip_fd_ = -1;
int line_fd_ = -1;
};
} // namespace radar::drivers
@@ -0,0 +1,185 @@
#include "h7992_minimal_driver.hpp"
#include <array>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <linux/gpio.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <unistd.h>
#include <utility>
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 = {
std::array<std::uint8_t, 2>{0, 0},
std::array<std::uint8_t, 2>{0, 1},
std::array<std::uint8_t, 2>{1, 0},
std::array<std::uint8_t, 2>{1, 1},
};
void validate_open_settings(const H7992MinimalDriverSettings& settings) {
if (settings.positions == 0U || settings.positions > 4U) {
throw std::runtime_error("H7992 switch positions must be in range [1, 4] for " + settings.name);
}
if (settings.default_position >= settings.positions) {
throw std::runtime_error("Switch default_position out of range for " + settings.name);
}
}
[[nodiscard]] auto make_line_values(std::uint8_t pin_a_state, std::uint8_t pin_b_state) -> gpio_v2_line_values {
gpio_v2_line_values values{};
values.mask = (1ULL << 0U) | (1ULL << 1U);
values.bits =
(static_cast<std::uint64_t>(pin_a_state) << 0U) | (static_cast<std::uint64_t>(pin_b_state) << 1U);
return values;
}
} // namespace
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;
}
validate_open_settings(settings_);
if (settings_.mode == config::DriverMode::Native) {
open_native();
}
is_open_ = true;
switch_to(settings_.default_position);
}
void H7992MinimalDriver::close() {
if (!is_open_) {
return;
}
if (settings_.mode == config::DriverMode::Native) {
close_native();
}
is_open_ = false;
}
void H7992MinimalDriver::switch_to(std::uint32_t position) {
if (!is_open_) {
throw std::runtime_error("Switch driver is not open for " + settings_.name);
}
if (position >= settings_.positions) {
throw std::runtime_error("Switch position out of range for " + settings_.name);
}
switch (settings_.mode) {
case config::DriverMode::Mock:
break;
case config::DriverMode::Native:
switch_native(position);
break;
default:
throw std::runtime_error("Unsupported switch driver mode for " + settings_.name);
}
current_position_ = position;
}
void H7992MinimalDriver::open_native() {
if (settings_.gpio_chip.empty()) {
throw std::runtime_error("gpio_chip is empty for native switch " + settings_.name);
}
if (settings_.pin_a < 0 || settings_.pin_b < 0 || settings_.pin_a == settings_.pin_b) {
throw std::runtime_error("Invalid pin_a/pin_b for native switch " + settings_.name);
}
chip_fd_ = ::open(settings_.gpio_chip.c_str(), O_RDONLY | O_CLOEXEC);
if (chip_fd_ < 0) {
throw std::runtime_error(
"Failed to open gpio chip '" + settings_.gpio_chip + "' for " + settings_.name + ": " + std::strerror(errno)
);
}
gpio_v2_line_request request{};
// The first requested line maps to logical bit 0, the second to bit 1.
request.offsets[0] = static_cast<std::uint32_t>(settings_.pin_a);
request.offsets[1] = static_cast<std::uint32_t>(settings_.pin_b);
request.num_lines = 2;
request.config.flags = GPIO_V2_LINE_FLAG_OUTPUT;
std::snprintf(request.consumer, sizeof(request.consumer), "radar_%s", settings_.name.c_str());
if (::ioctl(chip_fd_, GPIO_V2_GET_LINE_IOCTL, &request) != 0) {
const auto error = std::strerror(errno);
::close(chip_fd_);
chip_fd_ = -1;
throw std::runtime_error("Failed to request GPIO lines for " + settings_.name + ": " + error);
}
line_fd_ = request.fd;
if (line_fd_ < 0) {
::close(chip_fd_);
chip_fd_ = -1;
throw std::runtime_error("GPIO line request returned invalid fd for " + settings_.name);
}
}
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;
}
if (chip_fd_ >= 0) {
::close(chip_fd_);
chip_fd_ = -1;
}
}
void H7992MinimalDriver::switch_native(std::uint32_t position) {
if (line_fd_ < 0) {
throw std::runtime_error("Native GPIO line fd is not open for " + settings_.name);
}
const auto [pin_a_state, pin_b_state] = kPositionToAB[position];
auto values = make_line_values(pin_a_state, pin_b_state);
// 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
@@ -34,10 +34,8 @@ struct ComboKeyHash {
struct SweepTraceBlock {
ComboKey combo{};
// Frequency axis in Hz. Must have the same size as `s11` and `s21`.
// Frequency axis in Hz. Must have the same size as `s21`.
std::vector<float> frequency_hz{};
// Complex S11 samples for matching frequency points.
std::vector<Complex32> s11{};
// Complex S21 samples for matching frequency points.
std::vector<Complex32> s21{};
// Monotonic window in which THIS trace's sweep was measured, excluding the
@@ -92,11 +90,12 @@ struct ResultBlock {
struct ResultCollection {
std::uint64_t collection_id = 0;
std::uint64_t monotonic_ns = 0;
// Wall-clock duration of `process_collection()` for this collection,
// measured on the data_processor side with a monotonic clock. Used by the
// Python pipeline to report processing-time metrics. Zero is a valid
// "unmeasured" sentinel for legacy producers.
// Processing duration and acquisition identity, independent of stage completion time.
std::uint64_t processing_duration_ns = 0;
std::uint64_t settings_revision = 0;
std::uint64_t capture_start_ns = 0;
std::uint64_t capture_end_ns = 0;
float speed_m_s = 0.0F;
std::vector<ResultPayload> collection_payloads{};
std::vector<ResultBlock> blocks{};
};
@@ -111,7 +110,6 @@ struct ResultCollection {
-> PreprocessedCollection;
[[nodiscard]] auto serialize_result_collection(const ResultCollection& collection) -> std::vector<std::uint8_t>;
[[nodiscard]] auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> ResultCollection;
// Monotonic timestamp helper used for produced collections.
[[nodiscard]] auto current_monotonic_ns() -> std::uint64_t;
@@ -25,15 +25,7 @@ class ShmRing {
std::uint32_t slot_size_bytes
) -> ShmRing;
// Opens ring by name and validates wire-format header fields.
[[nodiscard]] static auto open_existing(const std::string& name) -> ShmRing;
// Removes shared memory object by name (safe if it does not exist).
static void unlink_ring(const std::string& name);
[[nodiscard]] auto is_open() const -> bool;
[[nodiscard]] auto capacity() const -> std::uint32_t;
[[nodiscard]] auto slot_size_bytes() const -> std::uint32_t;
[[nodiscard]] auto dropped_count() const -> std::uint64_t;
// Pushes payload with overwrite-oldest policy on overflow.
[[nodiscard]] auto push(std::span<const std::uint8_t> payload) -> bool;
@@ -57,8 +49,10 @@ class ShmRing {
void close();
static void validate_name(const std::string& name);
// Validates that mapped_size covers the geometry declared in the header
// (fix #50: reject undersized/corrupt mappings to prevent OOB slot access).
[[nodiscard]] static auto checked_mapped_size(
std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
) -> std::size_t;
// Reject undersized/corrupt mappings before accessing any slot.
static void validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name);
};
@@ -1,5 +1,6 @@
#include "shared_types.hpp"
#include <bit>
#include <chrono>
#include <cstring>
#include <limits>
@@ -12,9 +13,11 @@
namespace radar::ipc {
namespace {
constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2
constexpr std::uint32_t kResultCollectionMagic = 0x324C5352U; // RSL2
static_assert(std::endian::native == std::endian::little, "IPC requires a little-endian host");
constexpr std::uint32_t kRawCollectionMagic = 0x33574152U; // RAW3
constexpr std::uint32_t kPreprocessedCollectionMagic = 0x33505250U; // PRP3
constexpr std::uint32_t kResultCollectionMagic = 0x334C5352U; // RSL3
template <typename T>
concept TriviallySerializable = std::is_trivially_copyable_v<T>;
@@ -75,16 +78,6 @@ class BinaryReader {
return value;
}
[[nodiscard]] auto read_string() -> std::string {
const auto size = read<std::uint16_t>();
ensure_available(size);
const auto* begin = reinterpret_cast<const char*>(bytes_.data() + offset_);
std::string value(begin, begin + size);
offset_ += size;
return value;
}
[[nodiscard]] auto is_consumed() const -> bool {
return offset_ == bytes_.size();
}
@@ -106,7 +99,6 @@ class BinaryReader {
void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
ensure_equal_sizes(trace.frequency_hz.size(), trace.s21.size(), "Sweep trace");
ensure_equal_sizes(trace.frequency_hz.size(), trace.s11.size(), "Sweep trace S11");
writer.write(trace.combo.input_pos);
writer.write(trace.combo.output_pos);
@@ -116,11 +108,6 @@ void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
writer.write(frequency_hz);
}
for (const auto& point : trace.s11) {
writer.write(point.re);
writer.write(point.im);
}
for (const auto& point : trace.s21) {
writer.write(point.re);
writer.write(point.im);
@@ -144,23 +131,15 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
trace.combo.output_pos = reader.read<std::uint32_t>();
const auto point_count = reader.read<std::uint32_t>();
// Each point encodes frequency (4B) + s11 (8B) + s21 (8B) = 20 bytes.
require_count_fits(point_count, 20U, reader);
// Each point encodes frequency (4B) + s21 (8B) = 12 bytes.
require_count_fits(point_count, 12U, reader);
trace.frequency_hz.reserve(point_count);
trace.s11.reserve(point_count);
trace.s21.reserve(point_count);
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.frequency_hz.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.s11.push_back(Complex32{
.re = reader.read<float>(),
.im = reader.read<float>(),
});
}
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.s21.push_back(Complex32{
.re = reader.read<float>(),
@@ -171,10 +150,7 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
return trace;
}
// The capture windows live in a TRAILER after the trace blocks rather than inside
// them, so a reader built before they existed still decodes every trace and simply
// stops early. The trailer grows the same way: collection window first, then the
// per-trace window table (one pair per trace, in trace order).
// Capture trailer: collection window, then one per-trace window in trace order.
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
writer.write(magic);
writer.write(collection.collection_id);
@@ -213,22 +189,9 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
collection.traces.push_back(read_trace_block(reader));
}
// Each trailer stage is optional: a payload from an older producer stops after
// the trace blocks (or after the collection window) and leaves the rest zeroed.
if (reader.remaining_bytes() == 0U) {
return collection;
}
if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
throw std::runtime_error("Truncated capture window in trace collection");
}
collection.capture_start_ns = reader.read<std::uint64_t>();
collection.capture_end_ns = reader.read<std::uint64_t>();
if (reader.remaining_bytes() == 0U) {
return collection;
}
const auto trace_time_count = reader.read<std::uint32_t>();
if (trace_time_count != collection.traces.size()) {
throw std::runtime_error("Per-trace capture window count does not match trace count");
@@ -254,24 +217,6 @@ void write_trace_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_trace_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto point_count = reader.read<std::uint32_t>();
require_count_fits(point_count, 12U, reader);
payload->frequency_hz.reserve(point_count);
payload->trace.reserve(point_count);
for (std::uint32_t index = 0; index < point_count; ++index) {
payload->frequency_hz.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < point_count; ++index) {
payload->trace.push_back(Complex32{
.re = reader.read<float>(),
.im = reader.read<float>(),
});
}
}
void write_image_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
const auto x_count = checked_count_to_u32(payload.image_x_axis.size(), "Result image x axis point count");
const auto y_count = checked_count_to_u32(payload.image_y_axis.size(), "Result image y axis point count");
@@ -293,27 +238,6 @@ void write_image_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_image_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto x_count = reader.read<std::uint32_t>();
const auto y_count = reader.read<std::uint32_t>();
const auto value_count = static_cast<std::uint64_t>(x_count) * y_count;
require_count_fits(value_count + x_count + y_count, sizeof(float), reader);
payload->image_x_axis.reserve(x_count);
payload->image_y_axis.reserve(y_count);
payload->image_values.reserve(static_cast<std::size_t>(value_count));
for (std::uint32_t index = 0; index < x_count; ++index) {
payload->image_x_axis.push_back(reader.read<float>());
}
for (std::uint32_t index = 0; index < y_count; ++index) {
payload->image_y_axis.push_back(reader.read<float>());
}
for (std::size_t index = 0; index < value_count; ++index) {
payload->image_values.push_back(reader.read<float>());
}
}
void write_table_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
const auto column_count = payload.table_columns;
if (column_count == 0U && !payload.table_values.empty()) {
@@ -333,21 +257,6 @@ void write_table_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
auto read_table_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
const auto column_count = reader.read<std::uint32_t>();
const auto row_count = reader.read<std::uint32_t>();
if (column_count == 0U && row_count != 0U) {
throw std::runtime_error("Result table cannot have rows without columns");
}
const auto value_count = static_cast<std::uint64_t>(column_count) * row_count;
require_count_fits(value_count, sizeof(float), reader);
payload->table_columns = column_count;
payload->table_values.reserve(static_cast<std::size_t>(value_count));
for (std::size_t index = 0; index < value_count; ++index) {
payload->table_values.push_back(reader.read<float>());
}
}
void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
writer.write(static_cast<std::uint8_t>(payload.kind));
writer.write_string(payload.processing_name);
@@ -370,29 +279,6 @@ void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
}
}
[[nodiscard]] auto read_result_payload(BinaryReader& reader) -> ResultPayload {
ResultPayload payload{};
payload.kind = static_cast<ResultKind>(reader.read<std::uint8_t>());
payload.processing_name = reader.read_string();
switch (payload.kind) {
case ResultKind::TraceComplex:
read_trace_result_payload(reader, &payload);
return payload;
case ResultKind::ScalarF32:
payload.scalar_value = reader.read<float>();
return payload;
case ResultKind::ImageF32:
read_image_result_payload(reader, &payload);
return payload;
case ResultKind::TableF32:
read_table_result_payload(reader, &payload);
return payload;
default:
throw std::runtime_error("Unsupported result payload kind in stream");
}
}
void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
writer.write(block.combo.input_pos);
writer.write(block.combo.output_pos);
@@ -403,21 +289,6 @@ void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
}
}
[[nodiscard]] auto read_result_block(BinaryReader& reader) -> ResultBlock {
ResultBlock block{};
block.combo.input_pos = reader.read<std::uint32_t>();
block.combo.output_pos = reader.read<std::uint32_t>();
const auto payload_count = reader.read<std::uint32_t>();
require_count_fits(payload_count, 3U, reader);
block.payloads.reserve(payload_count);
for (std::uint32_t index = 0; index < payload_count; ++index) {
block.payloads.push_back(read_result_payload(reader));
}
return block;
}
void ensure_reader_consumed(const BinaryReader& reader, const std::string& payload_label) {
if (!reader.is_consumed()) {
throw std::runtime_error("Unexpected trailing bytes in " + payload_label);
@@ -458,6 +329,10 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
writer.write(collection.collection_id);
writer.write(collection.monotonic_ns);
writer.write(collection.processing_duration_ns);
writer.write(collection.settings_revision);
writer.write(collection.capture_start_ns);
writer.write(collection.capture_end_ns);
writer.write(collection.speed_m_s);
writer.write(checked_count_to_u32(collection.collection_payloads.size(), "Collection payload count"));
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block count"));
@@ -471,37 +346,6 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
return std::move(writer).finish();
}
auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> ResultCollection {
BinaryReader reader(bytes);
const auto magic = reader.read<std::uint32_t>();
if (magic != kResultCollectionMagic) {
throw std::runtime_error("Unexpected result collection magic");
}
ResultCollection collection{};
collection.collection_id = reader.read<std::uint64_t>();
collection.monotonic_ns = reader.read<std::uint64_t>();
collection.processing_duration_ns = reader.read<std::uint64_t>();
const auto collection_payload_count = reader.read<std::uint32_t>();
const auto block_count = reader.read<std::uint32_t>();
require_count_fits(collection_payload_count, 3U, reader);
collection.collection_payloads.reserve(collection_payload_count);
for (std::uint32_t index = 0; index < collection_payload_count; ++index) {
collection.collection_payloads.push_back(read_result_payload(reader));
}
require_count_fits(block_count, 12U, reader);
collection.blocks.reserve(block_count);
for (std::uint32_t index = 0; index < block_count; ++index) {
collection.blocks.push_back(read_result_block(reader));
}
ensure_reader_consumed(reader, "result collection");
return collection;
}
auto current_monotonic_ns() -> std::uint64_t {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(now).count();
@@ -44,63 +44,10 @@ constexpr auto kHeaderInitWaitTimeout = std::chrono::milliseconds(1000);
constexpr auto kHeaderInitPollInterval = std::chrono::milliseconds(2);
constexpr std::uint64_t kMaxMappedSize = 1ULL << 40U;
[[nodiscard]] auto checked_u64_diff(std::uint64_t left, std::uint64_t right) -> std::uint64_t {
return left >= right ? left - right : 0;
}
[[nodiscard]] auto errno_message(const std::string& action, const std::string& name) -> std::runtime_error {
return std::runtime_error(action + " " + name + ": " + std::strerror(errno));
}
class ScopedFd {
public:
explicit ScopedFd(int fd) : fd_(fd) {}
~ScopedFd() {
if (fd_ >= 0) {
::close(fd_);
}
}
ScopedFd(const ScopedFd&) = delete;
auto operator=(const ScopedFd&) -> ScopedFd& = delete;
ScopedFd(ScopedFd&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
auto release() -> int {
return std::exchange(fd_, -1);
}
private:
int fd_ = -1;
};
class ScopedMmap {
public:
ScopedMmap(void* address, std::size_t size) : address_(address), size_(size) {}
~ScopedMmap() {
if (address_ != nullptr && address_ != MAP_FAILED) {
munmap(address_, size_);
}
}
ScopedMmap(const ScopedMmap&) = delete;
auto operator=(const ScopedMmap&) -> ScopedMmap& = delete;
ScopedMmap(ScopedMmap&& other) noexcept
: address_(std::exchange(other.address_, nullptr)),
size_(std::exchange(other.size_, 0U)) {}
auto release() -> std::pair<void*, std::size_t> {
return {std::exchange(address_, nullptr), std::exchange(size_, 0U)};
}
private:
void* address_ = nullptr;
std::size_t size_ = 0;
};
} // namespace
ShmRing::~ShmRing() {
@@ -129,28 +76,7 @@ auto ShmRing::open_or_create(
std::uint32_t slot_size_bytes
) -> ShmRing {
validate_name(name);
if (capacity == 0U) {
throw std::runtime_error("Ring capacity must be > 0");
}
if (slot_size_bytes == 0U) {
throw std::runtime_error("Ring slot size must be > 0");
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
throw std::runtime_error("Ring slot size must be a multiple of 8");
}
const auto stride = sizeof(SlotHeader) + static_cast<std::uint64_t>(slot_size_bytes);
if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
}
const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
if (mapped_size_u64 > kMaxMappedSize
|| mapped_size_u64 > std::numeric_limits<std::size_t>::max()
|| mapped_size_u64 > static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
const auto mapped_size = static_cast<std::size_t>(mapped_size_u64);
const auto mapped_size = checked_mapped_size(capacity, slot_size_bytes, name);
bool created = false;
int fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0660);
@@ -164,8 +90,8 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to open shared memory ring", name);
}
ScopedFd scoped_fd(fd);
ShmRing ring{};
ring.fd_ = fd;
if (created) {
if (ftruncate(fd, static_cast<off_t>(mapped_size)) != 0) {
@@ -195,30 +121,29 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to mmap shared memory ring", name);
}
ScopedMmap scoped_mmap(mapped, mapped_size);
auto* header = static_cast<Header*>(mapped);
ring.mapped_ = mapped;
ring.mapped_size_ = mapped_size;
auto* header = ring.header_ = static_cast<Header*>(mapped);
if (created) {
// ftruncate already supplies zero-filled slots; leave them demand-paged
// instead of touching the full ring before any payload is published.
std::memset(mapped, 0, sizeof(Header));
std::memcpy(header->magic, kRingMagic.data(), kRingMagic.size());
header->version = kRingVersion;
header->capacity = capacity;
header->slot_size_bytes = slot_size_bytes;
new (&header->write_seq) std::atomic<std::uint64_t>(0);
new (&header->read_seq) std::atomic<std::uint64_t>(0);
new (&header->dropped) std::atomic<std::uint64_t>(0);
std::atomic_thread_fence(std::memory_order_release);
// Publish readiness only after geometry and cursors have been initialized.
std::atomic_ref(header->version).store(kRingVersion, std::memory_order_release);
} else {
const auto deadline = std::chrono::steady_clock::now() + kHeaderInitWaitTimeout;
while (true) {
std::atomic_thread_fence(std::memory_order_acquire);
const bool version_ok = std::atomic_ref(header->version).load(std::memory_order_acquire) == kRingVersion;
const bool magic_ok = std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) == 0;
const bool version_ok = header->version == kRingVersion;
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
if (magic_ok && version_ok && geometry_ok) {
@@ -247,89 +172,13 @@ auto ShmRing::open_or_create(
}
}
ShmRing ring{};
ring.fd_ = scoped_fd.release();
const auto [released_mapped, released_size] = scoped_mmap.release();
ring.mapped_ = released_mapped;
ring.mapped_size_ = released_size;
ring.header_ = static_cast<Header*>(released_mapped);
return ring;
}
auto ShmRing::open_existing(const std::string& name) -> ShmRing {
validate_name(name);
const int fd = shm_open(name.c_str(), O_RDWR, 0660);
if (fd < 0) {
throw errno_message("Failed to open shared memory ring", name);
}
ScopedFd scoped_fd(fd);
struct stat info {};
if (fstat(fd, &info) != 0) {
throw errno_message("Failed to stat shared memory ring", name);
}
if (info.st_size < static_cast<off_t>(sizeof(Header))) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
const auto mapped_size = static_cast<std::size_t>(info.st_size);
void* mapped = mmap(nullptr, mapped_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
throw errno_message("Failed to mmap shared memory ring", name);
}
ScopedMmap scoped_mmap(mapped, mapped_size);
auto* header = static_cast<Header*>(mapped);
if (std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) != 0) {
throw std::runtime_error("Shared memory ring magic mismatch for " + name);
}
if (header->version != kRingVersion) {
throw std::runtime_error("Shared memory ring version mismatch for " + name);
}
// ensure the mapping actually spans every slot the header describes.
validate_geometry(*header, mapped_size, name);
ShmRing ring{};
ring.fd_ = scoped_fd.release();
const auto [released_mapped, released_size] = scoped_mmap.release();
ring.mapped_ = released_mapped;
ring.mapped_size_ = released_size;
ring.header_ = static_cast<Header*>(released_mapped);
return ring;
}
void ShmRing::unlink_ring(const std::string& name) {
validate_name(name);
if (shm_unlink(name.c_str()) != 0 && errno != ENOENT) {
throw errno_message("Failed to unlink shared memory ring", name);
}
}
auto ShmRing::is_open() const -> bool {
return header_ != nullptr;
}
auto ShmRing::capacity() const -> std::uint32_t {
return header_ != nullptr ? header_->capacity : 0;
}
auto ShmRing::slot_size_bytes() const -> std::uint32_t {
return header_ != nullptr ? header_->slot_size_bytes : 0;
}
auto ShmRing::dropped_count() const -> std::uint64_t {
if (header_ == nullptr) {
return 0;
}
return header_->dropped.load(std::memory_order_acquire);
}
auto ShmRing::push(std::span<const std::uint8_t> payload) -> bool {
if (header_ == nullptr) {
throw std::runtime_error("Ring is not open");
@@ -341,7 +190,7 @@ auto ShmRing::push(std::span<const std::uint8_t> payload) -> bool {
const auto write_seq = header_->write_seq.load(std::memory_order_relaxed);
auto read_seq = header_->read_seq.load(std::memory_order_acquire);
if (checked_u64_diff(write_seq, read_seq) >= header_->capacity) {
if (write_seq >= read_seq && write_seq - read_seq >= header_->capacity) {
// Ring full: overwrite the oldest unread slot. Advance read_seq via CAS so a
// concurrent consumer's advance is never clobbered (read_seq never moves back).
header_->read_seq.compare_exchange_strong(
@@ -444,37 +293,41 @@ void ShmRing::close() {
}
void ShmRing::validate_name(const std::string& name) {
if (name.empty() || name.front() != '/') {
throw std::runtime_error("POSIX shm name must start with '/'");
if (name.size() < 2U || name.front() != '/' || name.find('/', 1) != std::string::npos
|| name.find('\0') != std::string::npos) {
throw std::runtime_error("POSIX shm name must be '/' followed by a single nonempty filename");
}
}
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
// derive the expected size from the header's own geometry fields and
// require the real mapping to cover it. A bogus capacity/slot_size or a truncated
// mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
const std::uint32_t capacity = header.capacity;
const std::uint32_t slot_size_bytes = header.slot_size_bytes;
auto ShmRing::checked_mapped_size(
std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
) -> std::size_t {
static_assert(sizeof(Header) == 64 && sizeof(SlotHeader) == 16);
if (capacity == 0U) {
throw std::runtime_error("Shared memory ring capacity is zero for " + name);
throw std::runtime_error("Ring capacity must be > 0");
}
if (slot_size_bytes == 0U) {
throw std::runtime_error("Shared memory ring slot size is zero for " + name);
throw std::runtime_error("Ring slot size must be > 0");
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
throw std::runtime_error("Shared memory ring slot size must be a multiple of 8 for " + name);
throw std::runtime_error("Ring slot size must be a multiple of 8");
}
const auto stride = sizeof(SlotHeader) + static_cast<std::uint64_t>(slot_size_bytes);
if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
}
const auto expected_size = sizeof(Header) + stride * capacity;
if (expected_size > kMaxMappedSize || expected_size > std::numeric_limits<std::size_t>::max()) {
const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
if (mapped_size_u64 > kMaxMappedSize
|| mapped_size_u64 > std::numeric_limits<std::size_t>::max()
|| mapped_size_u64 > static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
return static_cast<std::size_t>(mapped_size_u64);
}
if (mapped_size < expected_size) {
void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
if (mapped_size < checked_mapped_size(header.capacity, header.slot_size_bytes, name)) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
}