Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
# Kamil ADC collector
|
||||
|
||||
Standalone acquisition binary for the **L-Card E-502** ADC. It captures the
|
||||
dual-channel main + reference IQ stream and publishes it as 8-byte framed packets
|
||||
over a PTY. `KamilAdcService` in `python_app/hardware_full/kamil_adc` reads and
|
||||
processes that stream for `python_app.scripts.kamil_adc_raw_producer`.
|
||||
|
||||
This is the data *source* for `radar.model == "kamil_adc"`, paired with the Python
|
||||
`kamil_adc_raw_producer`. LibreVNA acquisition uses Python drivers.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/` | Collector implementation (`main.cpp`) and the PTY / capture-file writers. |
|
||||
| `include/` | Headers for the writers plus `switch_sequencer.h`. |
|
||||
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||
|
||||
In switch-aware mode, `SwitchSequencer` drives the RF switches, stepping to the
|
||||
next combination inside the hardware idle gap between sweeps and flagging a
|
||||
sweep *dirty* when a readout backlog means the toggle could not have landed in
|
||||
that gap. The Python reader
|
||||
drops dirty sweeps; the collector repeats that combination without another
|
||||
switch transition. The collector uses the common H7992 driver and `run_config`
|
||||
loader, with its own include set and `build/kamil/` object tree.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make kamil_adc_collector # -> build/bin/kamil_adc_collector
|
||||
```
|
||||
|
||||
It is **not** part of `make all`; `start.sh` builds it automatically when the
|
||||
active config selects `radar.model == "kamil_adc"`, unless `--skip-build` is set.
|
||||
The Linux build uses the repository's common sources and vendored headers and
|
||||
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
|
||||
|
||||
## Runtime
|
||||
|
||||
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
|
||||
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
|
||||
The Kamil ADC service prepends `~/.local/lib` (their default install location)
|
||||
unless the config pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
|
||||
|
||||
The command-line arguments (capture profile, clocking, the `do8_freq_ref`
|
||||
reference overlay, etc.) are supplied by `radar.kamil_adc.args` in the run config;
|
||||
the service appends `tty:<path>` from `radar.kamil_adc.tty_path`.
|
||||
|
||||
When both RF switches use `driver_mode: "native"`, the producer also passes
|
||||
`config:<path>` to the collector. After each sweep boundary, the collector emits
|
||||
an 8-byte combo tag `[0x00C0, input_pos, output_pos, dirty]` (four little-endian
|
||||
16-bit words). The producer accepts only clean sweeps for the requested combo.
|
||||
Otherwise, the producer selects and settles the switches itself; the collector
|
||||
runs without `config:<path>` and emits no combo tags. Calibration captures also
|
||||
use this Python-controlled path.
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct CapturePacket {
|
||||
std::size_t packet_index = 0;
|
||||
std::size_t channel_count = 2;
|
||||
bool has_di1_trace = false;
|
||||
std::vector<double> ch1;
|
||||
std::vector<double> ch2;
|
||||
std::vector<uint8_t> di1;
|
||||
};
|
||||
|
||||
class CaptureFileWriter {
|
||||
public:
|
||||
CaptureFileWriter(std::string csv_path,
|
||||
std::string svg_path,
|
||||
std::string live_html_path,
|
||||
std::string live_json_path);
|
||||
|
||||
void write(const std::vector<CapturePacket>& packets,
|
||||
double frame_freq_hz,
|
||||
double nominal_range_v) const;
|
||||
|
||||
void initialize_live_plot() const;
|
||||
void initialize_csv(std::size_t channel_count, bool has_di1_trace) const;
|
||||
void append_csv_packet(const CapturePacket& packet) const;
|
||||
|
||||
void update_live_plot(const CapturePacket& packet,
|
||||
std::size_t packets_seen,
|
||||
double packets_per_second,
|
||||
double frame_freq_hz,
|
||||
const std::string& close_reason,
|
||||
std::size_t zeroed_samples,
|
||||
std::size_t stored_samples) const;
|
||||
|
||||
const std::string& live_html_path() const;
|
||||
const std::string& live_json_path() const;
|
||||
|
||||
private:
|
||||
void finalize_csv_from_spool(double frame_freq_hz) const;
|
||||
void write_svg(const std::vector<CapturePacket>& packets,
|
||||
double frame_freq_hz,
|
||||
double nominal_range_v) const;
|
||||
|
||||
std::string csv_path_;
|
||||
std::string svg_path_;
|
||||
std::string live_html_path_;
|
||||
std::string live_json_path_;
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shared_types.hpp"
|
||||
#include "h7992_minimal_driver.hpp"
|
||||
|
||||
namespace radar::kamil {
|
||||
|
||||
/**
|
||||
* @brief Drives the input/output RF switches in lock-step with sweep boundaries.
|
||||
*
|
||||
* The E-502 stream is free-running: sweeps are delimited by DI_SYN2 edges with a
|
||||
* hardware idle gap between them. On every completed sweep we step to the next
|
||||
* switch combination *during that gap*, so the next sweep starts already settled
|
||||
* in the new state and no samples are lost.
|
||||
*
|
||||
* The one failure mode of doing this purely in software is the readout backlog:
|
||||
* if, at the moment we observe a sweep's end, we are running so far behind real
|
||||
* time that the toggle could not have landed within the gap, the upcoming sweep
|
||||
* straddles the transition. We flag that sweep *dirty*; the consumer drops it and
|
||||
* we re-take the same combination on the following sweep (which is then clean,
|
||||
* because no transition happens during its gap). A clean sweep costs nothing; a
|
||||
* miss costs exactly one extra sweep period for that one combination.
|
||||
*
|
||||
* The sequencer owns the two switch drivers and exposes only what the collector
|
||||
* needs at a sweep boundary: which combination the upcoming sweep belongs to and
|
||||
* whether it is expected to be clean. All timing policy lives here so the recv
|
||||
* loop stays readable.
|
||||
*/
|
||||
class SwitchSequencer {
|
||||
public:
|
||||
struct Settings {
|
||||
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||
/// the sequencer entirely (the collector then streams a single passthrough
|
||||
/// channel exactly as before).
|
||||
std::vector<radar::ipc::ComboKey> combos{};
|
||||
/// Guaranteed minimum inter-sweep idle window, in milliseconds. This is a
|
||||
/// hardware property of the sweep generator; treat it as a lower bound.
|
||||
double gap_ms = 10.0;
|
||||
/// RF settle time required after a position change, in milliseconds.
|
||||
double settle_ms = 1.0;
|
||||
};
|
||||
|
||||
SwitchSequencer(
|
||||
Settings settings,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
|
||||
)
|
||||
: settings_(std::move(settings)),
|
||||
input_switch_(std::move(input_switch)),
|
||||
output_switch_(std::move(output_switch)) {}
|
||||
|
||||
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||
|
||||
/// Open both switch drivers and park at the first combination.
|
||||
void open() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
output_switch_->open();
|
||||
input_switch_->open();
|
||||
index_ = 0;
|
||||
applied_ = settings_.combos.front();
|
||||
apply(applied_);
|
||||
upcoming_dirty_ = false; // the first sweep is captured in a settled state
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
// Best-effort: teardown must never throw out of the collector's stop path.
|
||||
try {
|
||||
output_switch_->close();
|
||||
} catch (...) {
|
||||
}
|
||||
try {
|
||||
input_switch_->close();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combination the in-progress / upcoming sweep is captured under.
|
||||
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||
return settings_.combos[index_];
|
||||
}
|
||||
|
||||
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||
/// transition and must be dropped by the consumer.
|
||||
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||
|
||||
/**
|
||||
* @brief Advance the sequence at a completed sweep's end (start of the gap).
|
||||
*
|
||||
* @param backlog_ms Estimated readout lag at this instant — how far behind
|
||||
* real time we are, i.e. how late the toggle we issue now will land.
|
||||
*
|
||||
* If the sweep that just ended was clean we step to the next combination and
|
||||
* toggle the switches now, inside the gap. If it was dirty we re-take the same
|
||||
* combination (the switches are already there and long settled). The upcoming
|
||||
* sweep is marked dirty only when a real transition happens *and* the backlog
|
||||
* leaves no room for it to settle before the next sweep starts.
|
||||
*/
|
||||
void on_sweep_end(double backlog_ms) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (upcoming_dirty_) {
|
||||
// The sweep that just ended straddled a transition: re-take the same
|
||||
// combination. The switches are already applied and settled, so the
|
||||
// next sweep is clean without touching the hardware again.
|
||||
upcoming_dirty_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
index_ = (index_ + 1U) % settings_.combos.size();
|
||||
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||
const bool position_changed = !(next == applied_);
|
||||
if (position_changed) {
|
||||
apply(next);
|
||||
applied_ = next;
|
||||
}
|
||||
upcoming_dirty_ = position_changed && ((backlog_ms + settings_.settle_ms) > settings_.gap_ms);
|
||||
}
|
||||
|
||||
private:
|
||||
void apply(const radar::ipc::ComboKey& combo) {
|
||||
// Set the transmitter before the receiver.
|
||||
output_switch_->switch_to(combo.output_pos);
|
||||
input_switch_->switch_to(combo.input_pos);
|
||||
}
|
||||
|
||||
Settings settings_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
|
||||
std::size_t index_ = 0;
|
||||
radar::ipc::ComboKey applied_{};
|
||||
bool upcoming_dirty_ = false;
|
||||
};
|
||||
|
||||
} // namespace radar::kamil
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,469 +0,0 @@
|
||||
#include "tty_protocol_writer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
struct TtyProtocolWriter::Impl {};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)) {
|
||||
(void) ring_capacity_bytes;
|
||||
throw std::runtime_error("tty output is supported only on Linux/POSIX");
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() = default;
|
||||
|
||||
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
|
||||
(void) marker;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
|
||||
(void) index;
|
||||
(void) ch1_avg;
|
||||
(void) ch2_avg;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
|
||||
(void) words;
|
||||
(void) frame_count;
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {}
|
||||
|
||||
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
(void) word0;
|
||||
(void) word1;
|
||||
(void) word2;
|
||||
(void) word3;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {}
|
||||
|
||||
#else
|
||||
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <pty.h>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kFrameWordCount = 4U;
|
||||
constexpr std::size_t kFrameByteCount = kFrameWordCount * sizeof(uint16_t);
|
||||
constexpr std::size_t kWriteBatchFrames = 256U;
|
||||
|
||||
using EncodedFrame = std::array<std::uint8_t, kFrameByteCount>;
|
||||
|
||||
std::string io_error(const std::string& action, const std::string& path) {
|
||||
std::ostringstream out;
|
||||
out << action << " '" << path << "': " << std::strerror(errno);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void close_fd_if_open(int& fd) noexcept {
|
||||
if (fd >= 0) {
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void set_fd_raw(int fd) {
|
||||
struct termios tio {};
|
||||
if (::tcgetattr(fd, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot read tty attributes for", std::to_string(fd)));
|
||||
}
|
||||
::cfmakeraw(&tio);
|
||||
tio.c_cc[VINTR] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VQUIT] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VERASE] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VKILL] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VEOF] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VTIME] = 0;
|
||||
tio.c_cc[VMIN] = 1;
|
||||
#ifdef VSWTC
|
||||
tio.c_cc[VSWTC] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
tio.c_cc[VSTART] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VSTOP] = _POSIX_VDISABLE;
|
||||
tio.c_cc[VSUSP] = _POSIX_VDISABLE;
|
||||
#ifdef VEOL
|
||||
tio.c_cc[VEOL] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VREPRINT
|
||||
tio.c_cc[VREPRINT] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VDISCARD
|
||||
tio.c_cc[VDISCARD] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VWERASE
|
||||
tio.c_cc[VWERASE] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VLNEXT
|
||||
tio.c_cc[VLNEXT] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
#ifdef VEOL2
|
||||
tio.c_cc[VEOL2] = _POSIX_VDISABLE;
|
||||
#endif
|
||||
if (::tcsetattr(fd, TCSANOW, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot apply raw tty attributes to", std::to_string(fd)));
|
||||
}
|
||||
}
|
||||
|
||||
bool is_character_device_path(const std::string& path) {
|
||||
struct stat st {};
|
||||
if (::stat(path.c_str(), &st) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
return false;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot stat tty output", path));
|
||||
}
|
||||
return S_ISCHR(st.st_mode);
|
||||
}
|
||||
|
||||
std::optional<std::string> read_link_target(const std::string& path) {
|
||||
std::array<char, PATH_MAX> buf {};
|
||||
const ssize_t len = ::readlink(path.c_str(), buf.data(), buf.size() - 1U);
|
||||
if (len < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
return std::nullopt;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot read symlink", path));
|
||||
}
|
||||
buf[static_cast<std::size_t>(len)] = '\0';
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
EncodedFrame encode_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
|
||||
EncodedFrame frame {};
|
||||
std::memcpy(frame.data(), words, sizeof(words));
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct TtyProtocolWriter::Impl {
|
||||
explicit Impl(std::size_t ring_capacity_bytes)
|
||||
: capacity_frames(std::max<std::size_t>(1U, ring_capacity_bytes / kFrameByteCount)),
|
||||
ring(capacity_frames) {}
|
||||
|
||||
int fd = -1;
|
||||
int slave_fd = -1;
|
||||
std::string slave_path;
|
||||
bool owns_link = false;
|
||||
|
||||
const std::size_t capacity_frames;
|
||||
std::vector<EncodedFrame> ring;
|
||||
std::size_t head = 0;
|
||||
std::size_t size = 0;
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable data_ready_cv;
|
||||
std::thread worker;
|
||||
bool stop_requested = false;
|
||||
std::exception_ptr failure;
|
||||
StatsSnapshot stats;
|
||||
};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)),
|
||||
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
|
||||
if (is_character_device_path(path_)) {
|
||||
impl_->fd = ::open(path_.c_str(), O_WRONLY | O_NOCTTY | O_NONBLOCK);
|
||||
if (impl_->fd < 0) {
|
||||
throw std::runtime_error(io_error("Cannot open tty output", path_));
|
||||
}
|
||||
} else {
|
||||
std::array<char, PATH_MAX> slave_name {};
|
||||
if (::openpty(&impl_->fd, &impl_->slave_fd, slave_name.data(), nullptr, nullptr) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create PTY bridge for", path_));
|
||||
}
|
||||
|
||||
try {
|
||||
impl_->slave_path = slave_name.data();
|
||||
set_fd_raw(impl_->slave_fd);
|
||||
|
||||
struct stat st {};
|
||||
if (::lstat(path_.c_str(), &st) == 0) {
|
||||
if (!S_ISLNK(st.st_mode) && !S_ISREG(st.st_mode)) {
|
||||
throw std::runtime_error("Refusing to replace non-link path '" + path_ + "'");
|
||||
}
|
||||
if (::unlink(path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot remove existing tty link", path_));
|
||||
}
|
||||
} else if (errno != ENOENT) {
|
||||
throw std::runtime_error(io_error("Cannot inspect tty link path", path_));
|
||||
}
|
||||
|
||||
if (::symlink(impl_->slave_path.c_str(), path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create tty symlink", path_));
|
||||
}
|
||||
impl_->owns_link = true;
|
||||
} catch (...) {
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const auto flags = ::fcntl(impl_->fd, F_GETFL);
|
||||
if (flags < 0 || ::fcntl(impl_->fd, F_SETFL, flags | O_NONBLOCK) < 0) {
|
||||
throw std::runtime_error(io_error("Cannot make tty output nonblocking", path_));
|
||||
}
|
||||
impl_->worker = std::thread([this]() { worker_loop(); });
|
||||
} catch (...) {
|
||||
if (impl_->owns_link) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() {
|
||||
try {
|
||||
shutdown();
|
||||
} catch (...) {
|
||||
}
|
||||
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (impl_->owns_link && !path_.empty()) {
|
||||
try {
|
||||
const auto target = read_link_target(path_);
|
||||
if (target && (*target == impl_->slave_path)) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
impl_->owns_link = false;
|
||||
}
|
||||
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_packet_start(uint16_t marker) {
|
||||
enqueue_frame(marker, 0xFFFF, 0xFFFF, 0xFFFF);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg) {
|
||||
enqueue_frame(0x000A,
|
||||
index,
|
||||
static_cast<uint16_t>(ch1_avg),
|
||||
static_cast<uint16_t>(ch2_avg));
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count) {
|
||||
if ((frame_count == 0U) || (words == nullptr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw_if_failed();
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->failure) {
|
||||
std::rethrow_exception(impl_->failure);
|
||||
}
|
||||
if (impl_->stop_requested) {
|
||||
throw std::runtime_error("tty writer is already shut down");
|
||||
}
|
||||
|
||||
std::size_t start_frame = 0;
|
||||
std::size_t frames_to_copy = frame_count;
|
||||
std::size_t dropped_frames = 0;
|
||||
|
||||
if (frame_count >= impl_->capacity_frames) {
|
||||
start_frame = frame_count - impl_->capacity_frames;
|
||||
frames_to_copy = impl_->capacity_frames;
|
||||
dropped_frames = impl_->size + start_frame;
|
||||
impl_->head = 0;
|
||||
impl_->size = 0;
|
||||
} else {
|
||||
const std::size_t available_frames = impl_->capacity_frames - impl_->size;
|
||||
if (frame_count > available_frames) {
|
||||
dropped_frames = frame_count - available_frames;
|
||||
impl_->head = (impl_->head + dropped_frames) % impl_->capacity_frames;
|
||||
impl_->size -= dropped_frames;
|
||||
}
|
||||
}
|
||||
|
||||
if (dropped_frames != 0U) {
|
||||
impl_->stats.frames_dropped += static_cast<std::uint64_t>(dropped_frames);
|
||||
++impl_->stats.ring_overflows;
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < frames_to_copy; ++i) {
|
||||
const std::size_t src_index = (start_frame + i) * kFrameWordCount;
|
||||
const std::size_t dst_index = (impl_->head + impl_->size) % impl_->capacity_frames;
|
||||
impl_->ring[dst_index] = encode_frame(words[src_index + 0U],
|
||||
words[src_index + 1U],
|
||||
words[src_index + 2U],
|
||||
words[src_index + 3U]);
|
||||
++impl_->size;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_one();
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
if (!impl_) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
return impl_->stats;
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::exception_ptr failure;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
failure = impl_->failure;
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
std::rethrow_exception(failure);
|
||||
}
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stop_requested = true;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_all();
|
||||
|
||||
if (impl_->worker.joinable()) {
|
||||
impl_->worker.join();
|
||||
}
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) {
|
||||
const uint16_t words[kFrameWordCount] = {word0, word1, word2, word3};
|
||||
enqueue_encoded_frames(words, 1U);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {
|
||||
for (;;) {
|
||||
std::array<std::uint8_t, kWriteBatchFrames * kFrameByteCount> write_batch;
|
||||
std::size_t batch_count = 0;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(impl_->mutex);
|
||||
impl_->data_ready_cv.wait(lock, [this]() {
|
||||
return impl_->stop_requested || impl_->failure || (impl_->size != 0U);
|
||||
});
|
||||
|
||||
if (impl_->failure || impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
batch_count = std::min<std::size_t>(impl_->size, kWriteBatchFrames);
|
||||
for (std::size_t i = 0; i < batch_count; ++i) {
|
||||
const EncodedFrame& frame = impl_->ring[impl_->head];
|
||||
std::memcpy(write_batch.data() + (i * kFrameByteCount), frame.data(), kFrameByteCount);
|
||||
impl_->head = (impl_->head + 1U) % impl_->capacity_frames;
|
||||
--impl_->size;
|
||||
}
|
||||
}
|
||||
|
||||
const std::uint8_t* bytes = write_batch.data();
|
||||
std::size_t remaining = batch_count * kFrameByteCount;
|
||||
while (remaining != 0U) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ssize_t written = ::write(impl_->fd, bytes, remaining);
|
||||
if (written < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if ((errno == EAGAIN) || (errno == EWOULDBLOCK) || (errno == EIO)) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error(io_error("Cannot write tty frame to", path_)));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (written == 0) {
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error("tty write returned 0 bytes for '" + path_ + "'"));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
|
||||
bytes += static_cast<std::size_t>(written);
|
||||
remaining -= static_cast<std::size_t>(written);
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stats.frames_written += static_cast<std::uint64_t>(batch_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -23,7 +23,7 @@
|
||||
// * Matrix radars (`librevna_multi`) fire one Tx at a time and
|
||||
// receive on all Rx channels in parallel. A run of 8 traces is just 2
|
||||
// Tx-events; all (tx_k, *) pairs share one timestamp.
|
||||
// * Sequential radars (single librevna with switches, kamil_adc)
|
||||
// * Sequential radars (single librevna with switches, rph_radar)
|
||||
// measure each pair separately. A run of 8 traces is 8 separate sweeps;
|
||||
// every pair has its own timestamp.
|
||||
// Both cases reduce to: there are N events in the frame, each event lasts
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# RadioPhotonic radar (RPH) collector
|
||||
|
||||
Application-owned acquisition binary for the **L-Card E-502** ADC. It captures the
|
||||
dual-channel main + reference IQ stream and publishes complete `RPH1` sweeps over
|
||||
a PTY. `RphRadarService` in `python_app/hardware_full/rph_radar` reads and
|
||||
processes that stream for `python_app.scripts.rph_radar_raw_producer`.
|
||||
|
||||
This is the data *source* for `radar.model == "rph_radar"`, paired with the Python
|
||||
`rph_radar_raw_producer`. LibreVNA acquisition uses Python drivers.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/` | Collector implementation (`main.cpp`) and the PTY writer. |
|
||||
| `include/` | PTY header and `switch_sequencer.h`. |
|
||||
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
|
||||
|
||||
In switch-aware mode, `SwitchSequencer` drives the H7992 switches at actual
|
||||
DI_SYN2 sweep ends. Duration limits and Stop never advance the switches. Every
|
||||
physical combination change discards the remaining decoded ADC/DIN block and
|
||||
recreates the source session after `max(1 ms, run.settling_ms)` has elapsed.
|
||||
Timestamps never decide whether a sweep belongs to the new combination. The
|
||||
collector completes a cycle only after a full, clean sweep for each combination.
|
||||
An unchanged combination keeps the existing session.
|
||||
|
||||
A restart stops cyclic DOUT with `WAIT_DONE`, stops and disables the streams,
|
||||
restores DO2 high / DO1 and DO8 low, then closes and frees the SDK descriptor.
|
||||
After settling, a new descriptor is created, opened and configured. The collector
|
||||
reloads the DO1/DO8 pattern, waits for `OutCycleSetup(WAIT_DONE)`, and starts
|
||||
ADC/DIN/DOUT together. Pending samples and phase accumulators are cleared;
|
||||
source sweep and cycle IDs remain continuous. The initial high DI_SYN2 portion
|
||||
is dirty: acquisition waits for a complete rising-to-falling sweep. This can
|
||||
skip a variable number of physical sweeps while reopening; no fixed skip count
|
||||
or firmware-pause estimate is used. Hardware validation must measure reopen
|
||||
latency, USB resource release and multi-combination throughput.
|
||||
|
||||
The application uses the internal ADC clock. ADC and DIN start together,
|
||||
continuously; DI_SYN2 gates sweeps in software. Source times are the monotonic
|
||||
clock immediately before `StreamsStart` plus the ADC sample index divided by the
|
||||
configured sample rate. These are source-clock estimates, not hardware timestamps;
|
||||
startup latency shifts the initial epoch earlier. USB and PTY queueing do not
|
||||
shift timestamps to the time Python reads a sweep. An estimated source timestamp
|
||||
ahead of receipt stops acquisition instead of publishing a future capture window.
|
||||
ADC/host drift can trigger this guard during long single-combination runs; its
|
||||
bound and the accuracy of capture windows need measurement on hardware. Drift
|
||||
cannot make data from an old SDK session clean after a physical switch.
|
||||
|
||||
The collector uses the common H7992 driver, with its own include set and
|
||||
`build/rph/` object tree.
|
||||
|
||||
## PTY format
|
||||
|
||||
Each packet is a 48-byte little-endian header (`<4sIQQQQHHI`) followed by the
|
||||
payload: `RPH1`, payload bytes (u32), source sweep ID, cycle ID, start/end ns
|
||||
(four u64), input/output positions (two u16), flags (u32; bit 0 means dirty).
|
||||
Without collector switching both positions are `0xffff`, and cycle equals sweep
|
||||
ID. Payload points remain four u16 words: marker (`0x000a` main, `0x00a8`
|
||||
reference), step index, signed I, signed Q. There are no boundary or combo frames.
|
||||
The queue drops complete sweeps on overflow; a packet already being written is
|
||||
never truncated. Maximum payload is 1,048,544 bytes. Python matches complete
|
||||
cycles. Python-controlled switching closes the previous collector and PTY reader,
|
||||
then starts a new collector after settling; phase-processing state is preserved.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make rph_radar_collector # -> build/bin/rph_radar_collector
|
||||
```
|
||||
|
||||
It is **not** part of `make all`; `start.sh` builds it automatically when the
|
||||
active config selects `radar.model == "rph_radar"`, unless `--skip-build` is set.
|
||||
The Linux build uses the repository's common sources and vendored headers and
|
||||
links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
|
||||
|
||||
## Runtime
|
||||
|
||||
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
|
||||
via `dlopen`, so those libraries must be discoverable by the dynamic loader.
|
||||
The RPH radar service prepends `~/.local/lib` (their default install location)
|
||||
unless the config pins `LD_LIBRARY_PATH` in `radar.rph_radar.env`.
|
||||
|
||||
There is one acquisition path: USB, differential ADC channels 2/3, maximum
|
||||
internal-clock rate, rising/falling DI_SYN2 gating, DO1 pair subtraction and DO8
|
||||
reference overlay. `radar.rph_radar.args` sets ADC range, internal reference,
|
||||
maximum sweep duration and DO8 period. The example's fixed-path options remain
|
||||
accepted as guards; incompatible modes and unused diagnostic options are rejected.
|
||||
The service appends `tty:<path>` from `radar.rph_radar.tty_path`.
|
||||
|
||||
When both RF switches use `driver_mode: "native"`, Python passes
|
||||
`switching:<JSON>` with the already decoded input/output switch settings, combos
|
||||
and settling time. The collector reads no profile file. Otherwise, Python selects
|
||||
the switches and starts a new collector after settling when the combination
|
||||
changes. Calibration captures also use this Python-controlled path.
|
||||
|
||||
The launching service owns the collector process and forwards its output to the
|
||||
application log. On Linux, parent death sends SIGKILL to the collector; normal
|
||||
Stop drains and closes the service's own child. No global process-name kill is
|
||||
used.
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shared_types.hpp"
|
||||
#include "h7992_minimal_driver.hpp"
|
||||
|
||||
namespace radar::rph {
|
||||
|
||||
// Every real GPIO transition invalidates the ADC session. No timestamp can
|
||||
// declare queued data clean; only a newly started source session can do that.
|
||||
class SwitchSequencer {
|
||||
public:
|
||||
struct Settings {
|
||||
/// Combinations to cycle through, one sweep each, in order. Empty disables
|
||||
/// the sequencer entirely (the collector then streams a single passthrough
|
||||
/// channel exactly as before).
|
||||
std::vector<radar::ipc::ComboKey> combos{};
|
||||
/// RF settling allowance after both GPIO writes complete.
|
||||
double settle_ms = 1.0;
|
||||
};
|
||||
|
||||
SwitchSequencer(
|
||||
Settings settings,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch,
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch
|
||||
)
|
||||
: settings_(std::move(settings)),
|
||||
input_switch_(std::move(input_switch)),
|
||||
output_switch_(std::move(output_switch)) {}
|
||||
|
||||
[[nodiscard]] auto enabled() const -> bool { return !settings_.combos.empty(); }
|
||||
|
||||
/// Open both switch drivers and park at the first combination.
|
||||
void open() {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
output_switch_->open();
|
||||
input_switch_->open();
|
||||
index_ = 0;
|
||||
applied_ = settings_.combos.front();
|
||||
apply(applied_);
|
||||
upcoming_dirty_ = true;
|
||||
}
|
||||
|
||||
/// Combination the in-progress / upcoming sweep is captured under.
|
||||
[[nodiscard]] auto current_combo() const -> radar::ipc::ComboKey {
|
||||
return settings_.combos[index_];
|
||||
}
|
||||
|
||||
/// Whether the in-progress / upcoming sweep is expected to straddle a switch
|
||||
/// transition and must be dropped by the consumer.
|
||||
[[nodiscard]] auto current_dirty() const -> bool { return upcoming_dirty_; }
|
||||
|
||||
[[nodiscard]] auto cycle_id() const -> std::uint64_t { return cycle_id_; }
|
||||
|
||||
[[nodiscard]] auto settled_after_ns() const -> std::uint64_t { return settled_after_ns_; }
|
||||
void session_started() { upcoming_dirty_ = false; }
|
||||
|
||||
bool on_sweep_end(bool complete = true) {
|
||||
if (!enabled() || !complete || upcoming_dirty_) { return false; }
|
||||
index_ = (index_ + 1U) % settings_.combos.size();
|
||||
if (index_ == 0) { ++cycle_id_; }
|
||||
const radar::ipc::ComboKey next = settings_.combos[index_];
|
||||
if (!(next == applied_)) {
|
||||
apply(next);
|
||||
applied_ = next;
|
||||
upcoming_dirty_ = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
void apply(const radar::ipc::ComboKey& combo) {
|
||||
// Set the transmitter before the receiver.
|
||||
output_switch_->switch_to(combo.output_pos);
|
||||
input_switch_->switch_to(combo.input_pos);
|
||||
settled_after_ns_ = static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count()) +
|
||||
static_cast<std::uint64_t>(settings_.settle_ms * 1e6);
|
||||
}
|
||||
|
||||
Settings settings_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> input_switch_;
|
||||
std::unique_ptr<drivers::H7992MinimalDriver> output_switch_;
|
||||
std::size_t index_ = 0;
|
||||
radar::ipc::ComboKey applied_{};
|
||||
bool upcoming_dirty_ = false;
|
||||
std::uint64_t settled_after_ns_ = 0, cycle_id_ = 1;
|
||||
};
|
||||
|
||||
} // namespace radar::rph
|
||||
+7
-4
@@ -4,6 +4,7 @@
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class TtyProtocolWriter {
|
||||
public:
|
||||
@@ -21,9 +22,12 @@ public:
|
||||
TtyProtocolWriter(TtyProtocolWriter&& other) noexcept = delete;
|
||||
TtyProtocolWriter& operator=(TtyProtocolWriter&& other) noexcept = delete;
|
||||
|
||||
void emit_packet_start(uint16_t marker = 0x000A);
|
||||
void emit_step(uint16_t index, int16_t ch1_avg, int16_t ch2_avg);
|
||||
void enqueue_encoded_frames(const uint16_t* words, std::size_t frame_count);
|
||||
struct SweepMetadata {
|
||||
std::uint64_t source_id, cycle_id, capture_start_ns, capture_end_ns;
|
||||
std::uint16_t input = 0xFFFF, output = 0xFFFF;
|
||||
bool dirty = false;
|
||||
};
|
||||
void enqueue_sweep(const std::vector<uint16_t>& words, const SweepMetadata& metadata);
|
||||
StatsSnapshot stats() const;
|
||||
|
||||
const std::string& path() const;
|
||||
@@ -31,7 +35,6 @@ public:
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
void enqueue_frame(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3);
|
||||
void worker_loop();
|
||||
|
||||
std::string path_;
|
||||
@@ -0,0 +1,530 @@
|
||||
#include "x502api.h"
|
||||
#include "e502api.h"
|
||||
#include "tty_protocol_writer.h"
|
||||
#include "switch_sequencer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <dlfcn.h>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <string>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
constexpr uint32_t kDiSyn2 = (1U << 13U) | (1U << 17U);
|
||||
constexpr uint32_t kDi8 = 1U << 6U; // Hardware DI8 readback offset, verified by loopback.
|
||||
constexpr uint32_t kDo8 = 1U << 7U;
|
||||
constexpr uint32_t kBlockWords = 65536;
|
||||
|
||||
[[noreturn]] void fail(const std::string& message) { throw std::runtime_error(message); }
|
||||
|
||||
void bind_to_parent() {
|
||||
const auto parent = ::getppid();
|
||||
if (::prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || ::getppid() != parent || parent == 1) {
|
||||
fail("Collector must remain owned by its launching application");
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t monotonic_ns() {
|
||||
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
struct Config {
|
||||
std::string tty_path;
|
||||
Json switching;
|
||||
uint32_t range = X502_ADC_RANGE_2;
|
||||
uint32_t reference_hz = X502_REF_FREQ_2000KHZ;
|
||||
uint32_t do8_period = 8;
|
||||
double duration_ms = 100;
|
||||
};
|
||||
|
||||
Config parse_args(int argc, char** argv) {
|
||||
Config cfg;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
const auto colon = arg.find(':');
|
||||
const auto key = arg.substr(0, colon);
|
||||
const auto value = colon == std::string::npos ? "" : arg.substr(colon + 1);
|
||||
if (key == "tty") { cfg.tty_path = value; }
|
||||
else if (key == "switching") { cfg.switching = Json::parse(value); }
|
||||
else if (key == "duration_ms") {
|
||||
std::size_t consumed = 0;
|
||||
cfg.duration_ms = std::stod(value, &consumed);
|
||||
if (consumed != value.size()) { fail("Invalid duration_ms"); }
|
||||
}
|
||||
else if (key == "do8_cycle_period") {
|
||||
std::size_t consumed = 0;
|
||||
const auto period = std::stoull(value, &consumed);
|
||||
if (consumed != value.size() || period < 4 || period > 262144 ||
|
||||
std::lcm(std::uint64_t{4}, period) > 262144) {
|
||||
fail("Combined DO1+DO8 cyclic pattern exceeds 262144 words or has an invalid period");
|
||||
}
|
||||
cfg.do8_period = static_cast<uint32_t>(period);
|
||||
} else if (key == "internal_ref_hz") {
|
||||
if (value == "2000000") { cfg.reference_hz = X502_REF_FREQ_2000KHZ; }
|
||||
else if (value == "1500000") { cfg.reference_hz = X502_REF_FREQ_1500KHZ; }
|
||||
else { fail("internal_ref_hz must be 1500000 or 2000000"); }
|
||||
} else if (key == "range") {
|
||||
const std::array<std::string, 6> ranges{"10", "5", "2", "1", "0.5", "0.2"};
|
||||
const auto found = std::find(ranges.begin(), ranges.end(), value);
|
||||
if (found == ranges.end()) { fail("Unsupported ADC range: " + value); }
|
||||
cfg.range = static_cast<uint32_t>(found - ranges.begin());
|
||||
} else if (arg != "profile:phase" && arg != "clock:internal" &&
|
||||
arg != "start:di_syn2_rise" && arg != "stop:di_syn2_fall" &&
|
||||
arg != "sample_clock_hz:max" && arg != "packet_limit:0" &&
|
||||
arg != "do1_toggle_per_frame" && arg != "do1_pair_subtract_avg" && arg != "do8_freq_ref") {
|
||||
fail("Unsupported application collector option: " + arg);
|
||||
}
|
||||
}
|
||||
if (cfg.tty_path.empty()) { fail("Application must supply tty:<path>"); }
|
||||
if (!std::isfinite(cfg.duration_ms) || cfg.duration_ms <= 0 || cfg.duration_ms > 60000) {
|
||||
fail("duration_ms must be finite and in (0, 60000]");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
void* open_library(const char* name) {
|
||||
auto* handle = ::dlopen(name, RTLD_NOW | RTLD_LOCAL);
|
||||
if (!handle) { fail(std::string("Cannot load ") + name + ": " + ::dlerror()); }
|
||||
return handle;
|
||||
}
|
||||
|
||||
template <typename Fn>
|
||||
Fn symbol(void* library, const char* name) {
|
||||
auto fn = reinterpret_cast<Fn>(::dlsym(library, name));
|
||||
if (!fn) { fail(std::string("Missing L-Card SDK symbol: ") + name); }
|
||||
return fn;
|
||||
}
|
||||
|
||||
struct Api {
|
||||
void* x502 = open_library("libx502api.so");
|
||||
void* e502 = open_library("libe502api.so");
|
||||
decltype(&X502_Create) Create = symbol<decltype(Create)>(x502, "X502_Create");
|
||||
decltype(&X502_Free) Free = symbol<decltype(Free)>(x502, "X502_Free");
|
||||
decltype(&X502_Close) Close = symbol<decltype(Close)>(x502, "X502_Close");
|
||||
decltype(&X502_GetErrorString) GetErrorString = symbol<decltype(GetErrorString)>(x502, "X502_GetErrorString");
|
||||
decltype(&X502_SetMode) SetMode = symbol<decltype(SetMode)>(x502, "X502_SetMode");
|
||||
decltype(&X502_StreamsStop) StreamsStop = symbol<decltype(StreamsStop)>(x502, "X502_StreamsStop");
|
||||
decltype(&X502_StreamsDisable) StreamsDisable = symbol<decltype(StreamsDisable)>(x502, "X502_StreamsDisable");
|
||||
decltype(&X502_SetSyncMode) SetSyncMode = symbol<decltype(SetSyncMode)>(x502, "X502_SetSyncMode");
|
||||
decltype(&X502_SetSyncStartMode) SetSyncStartMode = symbol<decltype(SetSyncStartMode)>(x502, "X502_SetSyncStartMode");
|
||||
decltype(&X502_SetLChannelCount) SetLChannelCount = symbol<decltype(SetLChannelCount)>(x502, "X502_SetLChannelCount");
|
||||
decltype(&X502_SetLChannel) SetLChannel = symbol<decltype(SetLChannel)>(x502, "X502_SetLChannel");
|
||||
decltype(&X502_SetAdcFreq) SetAdcFreq = symbol<decltype(SetAdcFreq)>(x502, "X502_SetAdcFreq");
|
||||
decltype(&X502_GetAdcFreq) GetAdcFreq = symbol<decltype(GetAdcFreq)>(x502, "X502_GetAdcFreq");
|
||||
decltype(&X502_SetDinFreq) SetDinFreq = symbol<decltype(SetDinFreq)>(x502, "X502_SetDinFreq");
|
||||
decltype(&X502_SetOutFreq) SetOutFreq = symbol<decltype(SetOutFreq)>(x502, "X502_SetOutFreq");
|
||||
decltype(&X502_AsyncOutDig) AsyncOutDig = symbol<decltype(AsyncOutDig)>(x502, "X502_AsyncOutDig");
|
||||
decltype(&X502_SetRefFreq) SetRefFreq = symbol<decltype(SetRefFreq)>(x502, "X502_SetRefFreq");
|
||||
decltype(&X502_SetStreamBufSize) SetStreamBufSize = symbol<decltype(SetStreamBufSize)>(x502, "X502_SetStreamBufSize");
|
||||
decltype(&X502_SetStreamStep) SetStreamStep = symbol<decltype(SetStreamStep)>(x502, "X502_SetStreamStep");
|
||||
decltype(&X502_SetDigInPullup) SetDigInPullup = symbol<decltype(SetDigInPullup)>(x502, "X502_SetDigInPullup");
|
||||
decltype(&X502_Configure) Configure = symbol<decltype(Configure)>(x502, "X502_Configure");
|
||||
decltype(&X502_StreamsEnable) StreamsEnable = symbol<decltype(StreamsEnable)>(x502, "X502_StreamsEnable");
|
||||
decltype(&X502_StreamsStart) StreamsStart = symbol<decltype(StreamsStart)>(x502, "X502_StreamsStart");
|
||||
decltype(&X502_GetRecvReadyCount) GetRecvReadyCount = symbol<decltype(GetRecvReadyCount)>(x502, "X502_GetRecvReadyCount");
|
||||
decltype(&X502_Recv) Recv = symbol<decltype(Recv)>(x502, "X502_Recv");
|
||||
decltype(&X502_OutCycleLoadStart) OutCycleLoadStart = symbol<decltype(OutCycleLoadStart)>(x502, "X502_OutCycleLoadStart");
|
||||
decltype(&X502_OutCycleSetup) OutCycleSetup = symbol<decltype(OutCycleSetup)>(x502, "X502_OutCycleSetup");
|
||||
decltype(&X502_OutCycleStop) OutCycleStop = symbol<decltype(OutCycleStop)>(x502, "X502_OutCycleStop");
|
||||
decltype(&X502_Send) Send = symbol<decltype(Send)>(x502, "X502_Send");
|
||||
decltype(&X502_PrepareData) PrepareData = symbol<decltype(PrepareData)>(x502, "X502_PrepareData");
|
||||
decltype(&X502_ProcessData) ProcessData = symbol<decltype(ProcessData)>(x502, "X502_ProcessData");
|
||||
decltype(&E502_OpenUsb) OpenUsb = symbol<decltype(OpenUsb)>(e502, "E502_OpenUsb");
|
||||
~Api() { ::dlclose(e502); ::dlclose(x502); }
|
||||
};
|
||||
|
||||
void expect_ok(const Api& api, int32_t error, const char* operation) {
|
||||
if (error != X502_ERR_OK) {
|
||||
const char* description = api.GetErrorString(error);
|
||||
fail(std::string(operation) + ": " + std::to_string(error) +
|
||||
(description ? std::string(" (") + description + ")" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceHandle {
|
||||
const Api& api;
|
||||
t_x502_hnd hnd;
|
||||
bool opened = false;
|
||||
bool streams_started = false;
|
||||
explicit DeviceHandle(const Api& sdk) : api(sdk), hnd(api.Create()) {
|
||||
if (!hnd) { fail("X502_Create failed"); }
|
||||
}
|
||||
~DeviceHandle() {
|
||||
if (streams_started) { api.OutCycleStop(hnd, 0); api.StreamsStop(hnd); }
|
||||
if (opened) { api.Close(hnd); }
|
||||
api.Free(hnd);
|
||||
}
|
||||
};
|
||||
|
||||
volatile std::sig_atomic_t stop_requested = 0;
|
||||
void stop_handler(int signal) {
|
||||
if (stop_requested) { std::_Exit(128 + signal); }
|
||||
stop_requested = 1;
|
||||
}
|
||||
|
||||
std::unique_ptr<radar::rph::SwitchSequencer> make_switch_sequencer(const Config& cfg) {
|
||||
if (cfg.switching.is_null()) { return nullptr; }
|
||||
const auto driver = [](const Json& item) {
|
||||
if (item.value("driver", std::string("h7992")) != "h7992" || item.value("invert_logic", false)) {
|
||||
fail("Switches support only h7992 with invert_logic=false");
|
||||
}
|
||||
const auto mode = item.at("driver_mode").get<std::string>();
|
||||
if (mode != "native" && mode != "mock") { fail("Invalid switch driver_mode"); }
|
||||
return std::make_unique<radar::drivers::H7992MinimalDriver>(radar::drivers::H7992MinimalDriverSettings{
|
||||
.name = item.at("name").get<std::string>(),
|
||||
.mode = mode == "native" ? radar::config::DriverMode::Native : radar::config::DriverMode::Mock,
|
||||
.positions = item.at("positions").get<uint32_t>(),
|
||||
.default_position = item.at("default_position").get<uint32_t>(),
|
||||
.gpio_chip = item.at("gpio_chip").get<std::string>(),
|
||||
.pin_a = item.at("pin_a").get<int32_t>(), .pin_b = item.at("pin_b").get<int32_t>()});
|
||||
};
|
||||
radar::rph::SwitchSequencer::Settings settings;
|
||||
settings.settle_ms = cfg.switching.at("settling_ms").get<double>();
|
||||
if (!std::isfinite(settings.settle_ms) || settings.settle_ms < 0 || settings.settle_ms > 60000) {
|
||||
fail("Switch settling_ms must be finite and in [0, 60000]");
|
||||
}
|
||||
settings.settle_ms = std::max(1.0, settings.settle_ms);
|
||||
const auto& input = cfg.switching.at("input");
|
||||
const auto& output = cfg.switching.at("output");
|
||||
for (const auto& pair : cfg.switching.at("combos")) {
|
||||
if (!pair.is_array() || pair.size() != 2 || !pair[0].is_number_unsigned() || !pair[1].is_number_unsigned() ||
|
||||
pair[0].get<uint64_t>() >= input.at("positions").get<uint64_t>() ||
|
||||
pair[1].get<uint64_t>() >= output.at("positions").get<uint64_t>()) {
|
||||
fail("Switch combination out of range");
|
||||
}
|
||||
settings.combos.push_back({pair[0].get<uint32_t>(), pair[1].get<uint32_t>()});
|
||||
}
|
||||
if (settings.combos.empty()) { fail("Collector switching requires nonempty combos"); }
|
||||
return std::make_unique<radar::rph::SwitchSequencer>(std::move(settings), driver(input), driver(output));
|
||||
}
|
||||
|
||||
int16_t pack_raw_code_to_int16(double value) {
|
||||
const auto rounded = std::llround(value * 32767.0 / static_cast<double>(X502_ADC_SCALE_CODE_MAX));
|
||||
return static_cast<int16_t>(std::clamp<long long>(rounded, -32768, 32767));
|
||||
}
|
||||
struct Do1PairSubtractAverageState {
|
||||
struct PairSample {
|
||||
double ch1 = 0.0;
|
||||
double ch2 = 0.0;
|
||||
};
|
||||
|
||||
std::deque<PairSample> low_pairs;
|
||||
std::deque<PairSample> high_pairs;
|
||||
bool run_initialized = false;
|
||||
bool run_di1_high = false;
|
||||
double diff_sum_ch1 = 0.0;
|
||||
double diff_sum_ch2 = 0.0;
|
||||
uint32_t diff_count = 0;
|
||||
|
||||
bool pending_ch1_valid = false;
|
||||
double pending_ch1_raw = 0.0;
|
||||
bool pending_ch1_di1_high = false;
|
||||
bool pending_ch1_di2_high = false;
|
||||
bool pending_ch1_di8_high = false; // raw DI8 level (not guard-filtered)
|
||||
|
||||
uint16_t next_index = 1;
|
||||
|
||||
void clear_run_pairs() {
|
||||
low_pairs.clear();
|
||||
high_pairs.clear();
|
||||
diff_sum_ch1 = 0.0;
|
||||
diff_sum_ch2 = 0.0;
|
||||
diff_count = 0;
|
||||
}
|
||||
|
||||
void clear_pending_ch1() {
|
||||
pending_ch1_valid = false;
|
||||
pending_ch1_raw = 0.0;
|
||||
pending_ch1_di1_high = false;
|
||||
pending_ch1_di2_high = false;
|
||||
pending_ch1_di8_high = false;
|
||||
}
|
||||
|
||||
void reset_packet() {
|
||||
clear_run_pairs();
|
||||
run_initialized = false;
|
||||
run_di1_high = false;
|
||||
clear_pending_ch1();
|
||||
next_index = 1;
|
||||
}
|
||||
|
||||
void start_run(bool di1_high) {
|
||||
clear_run_pairs();
|
||||
run_initialized = true;
|
||||
run_di1_high = di1_high;
|
||||
}
|
||||
|
||||
void clear_run() {
|
||||
clear_run_pairs();
|
||||
run_initialized = false;
|
||||
}
|
||||
|
||||
void add_pending_ch1(double raw_code, bool di1_high, bool di2_high,
|
||||
bool di8_high_level = false) {
|
||||
pending_ch1_valid = true;
|
||||
pending_ch1_raw = raw_code;
|
||||
pending_ch1_di1_high = di1_high;
|
||||
pending_ch1_di2_high = di2_high;
|
||||
pending_ch1_di8_high = di8_high_level;
|
||||
}
|
||||
|
||||
void add_pair_sample(bool di2_high, double ch1_value, double ch2_value) {
|
||||
const PairSample sample{ch1_value, ch2_value};
|
||||
if (di2_high) {
|
||||
high_pairs.push_back(sample);
|
||||
} else {
|
||||
low_pairs.push_back(sample);
|
||||
}
|
||||
|
||||
while (!low_pairs.empty() && !high_pairs.empty()) {
|
||||
const PairSample low = low_pairs.front();
|
||||
const PairSample high = high_pairs.front();
|
||||
low_pairs.pop_front();
|
||||
high_pairs.pop_front();
|
||||
|
||||
diff_sum_ch1 += (low.ch1 - high.ch1);
|
||||
diff_sum_ch2 += (low.ch2 - high.ch2);
|
||||
++diff_count;
|
||||
}
|
||||
}
|
||||
|
||||
bool has_output() const { return diff_count != 0U; }
|
||||
|
||||
double avg_diff_ch1() const {
|
||||
if (diff_count == 0U) {
|
||||
return 0.0;
|
||||
}
|
||||
return diff_sum_ch1 / static_cast<double>(diff_count);
|
||||
}
|
||||
|
||||
double avg_diff_ch2() const {
|
||||
if (diff_count == 0U) {
|
||||
return 0.0;
|
||||
}
|
||||
return diff_sum_ch2 / static_cast<double>(diff_count);
|
||||
}
|
||||
};
|
||||
|
||||
int run(const Config& cfg) {
|
||||
stop_requested = 0;
|
||||
struct sigaction action{};
|
||||
action.sa_handler = stop_handler;
|
||||
sigemptyset(&action.sa_mask);
|
||||
if (::sigaction(SIGINT, &action, nullptr) != 0 || ::sigaction(SIGTERM, &action, nullptr) != 0) {
|
||||
fail("Cannot install collector stop handlers");
|
||||
}
|
||||
Api api;
|
||||
auto sequencer = make_switch_sequencer(cfg);
|
||||
if (sequencer) { sequencer->open(); }
|
||||
std::unique_ptr<DeviceHandle> device;
|
||||
bool restart_requested = true;
|
||||
double adc_hz = 0;
|
||||
uint64_t epoch_ns = 0, max_frames = 0;
|
||||
TtyProtocolWriter writer(cfg.tty_path, 2U * (48U + 65534U * 16U));
|
||||
std::vector<uint32_t> raw(kBlockWords), din(kBlockWords);
|
||||
std::vector<double> adc(kBlockWords);
|
||||
std::deque<double> pending_adc;
|
||||
std::deque<uint32_t> pending_din;
|
||||
std::vector<uint16_t> words;
|
||||
Do1PairSubtractAverageState pairs;
|
||||
std::array<double, 2> reference_sum{};
|
||||
uint32_t reference_count = 0;
|
||||
bool active = false, previous_gate = false, initialized = false;
|
||||
uint64_t sample_index = 0, sweep_id = 0, frames = 0;
|
||||
TtyProtocolWriter::SweepMetadata metadata{};
|
||||
const auto append = [&](uint16_t marker, uint16_t step, double i, double q) {
|
||||
words.insert(words.end(), {marker, step, static_cast<uint16_t>(pack_raw_code_to_int16(i)),
|
||||
static_cast<uint16_t>(pack_raw_code_to_int16(q))});
|
||||
};
|
||||
const auto finish_step = [&]() {
|
||||
if (pairs.run_initialized && pairs.has_output()) {
|
||||
if (pairs.next_index == 0xffff) { fail("Sweep exceeds 65534 phase points"); }
|
||||
const auto step = pairs.next_index++;
|
||||
append(0x000a, step, pairs.avg_diff_ch1(), pairs.avg_diff_ch2());
|
||||
if (reference_count) {
|
||||
append(0x00a8, step, reference_sum[0] / reference_count, reference_sum[1] / reference_count);
|
||||
}
|
||||
}
|
||||
pairs.clear_run();
|
||||
reference_sum = {};
|
||||
reference_count = 0;
|
||||
};
|
||||
const auto finish_sweep = [&](uint64_t end_ns, bool complete) {
|
||||
finish_step();
|
||||
metadata.capture_end_ns = end_ns;
|
||||
metadata.dirty = metadata.dirty || !complete;
|
||||
if (!words.empty() && end_ns > metadata.capture_start_ns) { writer.enqueue_sweep(words, metadata); }
|
||||
if (sequencer && frames && !metadata.dirty) { restart_requested = sequencer->on_sweep_end(complete); }
|
||||
words.clear();
|
||||
active = false;
|
||||
};
|
||||
const auto stop_session = [&]() {
|
||||
if (!device) { return; }
|
||||
expect_ok(api, api.OutCycleStop(device->hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Stop DOUT cycle");
|
||||
expect_ok(api, api.StreamsStop(device->hnd), "Stop streams");
|
||||
device->streams_started = false;
|
||||
expect_ok(api, api.StreamsDisable(device->hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT), "Disable streams");
|
||||
expect_ok(api, api.AsyncOutDig(device->hnd, 2, ~(3U | kDo8)), "Restore DO2 high");
|
||||
expect_ok(api, api.Close(device->hnd), "Close ADC session");
|
||||
device->opened = false;
|
||||
device.reset(); // X502_Free destroys this session's decoder/DMA buffers.
|
||||
};
|
||||
const auto start_session = [&]() {
|
||||
stop_session();
|
||||
pending_adc.clear(); pending_din.clear(); words.clear();
|
||||
pairs.reset_packet(); reference_sum = {}; reference_count = 0;
|
||||
active = false; previous_gate = false; initialized = false;
|
||||
sample_index = 0; frames = 0;
|
||||
while (sequencer && monotonic_ns() < sequencer->settled_after_ns() && !stop_requested) { ::usleep(1000); }
|
||||
if (stop_requested) { return; }
|
||||
device = std::make_unique<DeviceHandle>(api);
|
||||
expect_ok(api, api.OpenUsb(device->hnd, nullptr), "Open E-502");
|
||||
device->opened = true;
|
||||
expect_ok(api, api.SetMode(device->hnd, X502_MODE_FPGA), "Set FPGA mode");
|
||||
expect_ok(api, api.StreamsStop(device->hnd), "Stop prior streams");
|
||||
expect_ok(api, api.StreamsDisable(device->hnd, X502_STREAM_ALL_IN | X502_STREAM_ALL_OUT), "Disable prior streams");
|
||||
expect_ok(api, api.SetSyncMode(device->hnd, X502_SYNC_INTERNAL), "Set internal sample clock");
|
||||
expect_ok(api, api.SetSyncStartMode(device->hnd, X502_SYNC_INTERNAL), "Set immediate start");
|
||||
expect_ok(api, api.SetRefFreq(device->hnd, cfg.reference_hz), "Set reference clock");
|
||||
expect_ok(api, api.SetLChannelCount(device->hnd, 2), "Set logical channels");
|
||||
for (uint32_t ch = 0; ch < 2; ++ch) {
|
||||
expect_ok(api, api.SetLChannel(device->hnd, ch, ch + 2, X502_LCH_MODE_DIFF, cfg.range, 1), "Set ADC channel");
|
||||
}
|
||||
adc_hz = cfg.reference_hz;
|
||||
double frame_hz = 0;
|
||||
expect_ok(api, api.SetAdcFreq(device->hnd, &adc_hz, nullptr), "Set maximum ADC frequency");
|
||||
expect_ok(api, api.GetAdcFreq(device->hnd, &adc_hz, &frame_hz), "Get ADC frequencies");
|
||||
double din_hz = adc_hz, dout_hz = adc_hz;
|
||||
expect_ok(api, api.SetDinFreq(device->hnd, &din_hz), "Set DIN frequency");
|
||||
expect_ok(api, api.SetOutFreq(device->hnd, &dout_hz), "Set DOUT frequency");
|
||||
if (!std::isfinite(adc_hz) || adc_hz <= 0 || !std::isfinite(frame_hz) || frame_hz <= 0 ||
|
||||
!std::isfinite(din_hz) || !std::isfinite(dout_hz) || adc_hz > cfg.reference_hz ||
|
||||
std::abs(frame_hz * 2 - adc_hz) > 0.5 || std::abs(din_hz - adc_hz) > 0.5 || std::abs(dout_hz - adc_hz) > 0.5) {
|
||||
fail("ADC, DIN and DOUT must have the same finite sample frequency");
|
||||
}
|
||||
for (const auto stream : {X502_STREAM_CH_IN, X502_STREAM_CH_OUT}) {
|
||||
expect_ok(api, api.SetStreamBufSize(device->hnd, stream, 16U * 1024U * 1024U), "Set stream buffer");
|
||||
expect_ok(api, api.SetStreamStep(device->hnd, stream, kBlockWords), "Set stream step");
|
||||
}
|
||||
expect_ok(api, api.SetDigInPullup(device->hnd, 0), "Set input pulls");
|
||||
expect_ok(api, api.Configure(device->hnd, 0), "Configure E-502");
|
||||
expect_ok(api, api.AsyncOutDig(device->hnd, 2, 0), "Set DO2 high");
|
||||
expect_ok(api, api.StreamsEnable(device->hnd, X502_STREAM_ADC | X502_STREAM_DIN | X502_STREAM_DOUT), "Enable streams");
|
||||
|
||||
// Parse bounds were checked before loading the SDK or allocating either vector.
|
||||
const auto pattern_size = std::lcm(uint32_t{4}, cfg.do8_period);
|
||||
std::vector<uint32_t> pattern(pattern_size), encoded(pattern_size);
|
||||
for (std::size_t i = 0; i < pattern.size(); ++i) {
|
||||
pattern[i] = 2U | (i % 4U >= 2U ? 1U : 0U) |
|
||||
(i % cfg.do8_period >= cfg.do8_period - 2U ? kDo8 : 0U);
|
||||
}
|
||||
expect_ok(api, api.OutCycleLoadStart(device->hnd, pattern_size), "Allocate DOUT cycle");
|
||||
expect_ok(api, api.PrepareData(device->hnd, nullptr, nullptr, pattern.data(), pattern_size, 0, encoded.data()), "Encode DOUT cycle");
|
||||
for (std::size_t sent = 0; sent < encoded.size();) {
|
||||
const auto n = api.Send(device->hnd, encoded.data() + sent, static_cast<uint32_t>(encoded.size() - sent), 50);
|
||||
if (n <= 0) { fail("Send DOUT cycle failed or timed out: " + std::to_string(n)); }
|
||||
sent += static_cast<std::size_t>(n);
|
||||
}
|
||||
expect_ok(api, api.OutCycleSetup(device->hnd, X502_OUT_CYCLE_FLAGS_WAIT_DONE), "Activate DOUT cycle");
|
||||
max_frames = std::max<uint64_t>(1, static_cast<uint64_t>(std::llround(cfg.duration_ms * frame_hz / 1000.0)));
|
||||
// Diagnostic source-clock estimate only. Freshness follows the new SDK
|
||||
// session after settled GPIO, independently of either clock's drift.
|
||||
epoch_ns = monotonic_ns();
|
||||
expect_ok(api, api.StreamsStart(device->hnd), "Start ADC/DIN/DOUT streams");
|
||||
device->streams_started = true;
|
||||
if (sequencer) { sequencer->session_started(); }
|
||||
restart_requested = false;
|
||||
};
|
||||
while (!stop_requested) {
|
||||
if (restart_requested) { start_session(); }
|
||||
if (stop_requested) { break; }
|
||||
writer.throw_if_failed();
|
||||
uint32_t ready = 0;
|
||||
expect_ok(api, api.GetRecvReadyCount(device->hnd, &ready), "Read ADC backlog");
|
||||
const auto count = api.Recv(device->hnd, raw.data(), ready ? std::min(ready, kBlockWords) : kBlockWords, ready ? 0 : 50);
|
||||
if (count < 0) { expect_ok(api, count, "Receive ADC/DIN"); }
|
||||
if (count == 0) { continue; }
|
||||
uint32_t adc_count = kBlockWords, din_count = kBlockWords;
|
||||
expect_ok(api, api.ProcessData(device->hnd, raw.data(), static_cast<uint32_t>(count), 0,
|
||||
adc.data(), &adc_count, din.data(), &din_count), "Decode ADC/DIN");
|
||||
pending_adc.insert(pending_adc.end(), adc.begin(), adc.begin() + adc_count);
|
||||
pending_din.insert(pending_din.end(), din.begin(), din.begin() + din_count);
|
||||
if (pending_adc.size() > 1'000'000 || pending_din.size() > 1'000'000) {
|
||||
fail("ADC/DIN alignment backlog exceeded its bound");
|
||||
}
|
||||
const auto received_at_ns = monotonic_ns();
|
||||
while (!pending_adc.empty() && !pending_din.empty() && !stop_requested && !restart_requested) {
|
||||
const auto value = pending_adc.front(); pending_adc.pop_front();
|
||||
const auto bits = pending_din.front(); pending_din.pop_front();
|
||||
const auto at_ns = epoch_ns + static_cast<uint64_t>(static_cast<long double>(sample_index) * 1e9L / adc_hz);
|
||||
if (at_ns > received_at_ns) { fail("ADC source clock leads host monotonic clock; restart acquisition"); }
|
||||
const auto channel = sample_index++ % 2U;
|
||||
const bool gate = (bits & kDiSyn2) != 0;
|
||||
if (!initialized || (!previous_gate && gate)) {
|
||||
if (gate) {
|
||||
pairs.reset_packet(); reference_sum = {}; reference_count = 0;
|
||||
words.clear(); frames = 0; ++sweep_id;
|
||||
metadata = {sweep_id, sequencer ? sequencer->cycle_id() : sweep_id, at_ns, at_ns,
|
||||
0xffff, 0xffff, !initialized};
|
||||
if (sequencer) {
|
||||
const auto combo = sequencer->current_combo();
|
||||
metadata.input = static_cast<uint16_t>(combo.input_pos);
|
||||
metadata.output = static_cast<uint16_t>(combo.output_pos);
|
||||
metadata.dirty = metadata.dirty || sequencer->current_dirty();
|
||||
}
|
||||
active = true;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
if (active && previous_gate && !gate) { finish_sweep(at_ns, true); }
|
||||
previous_gate = gate;
|
||||
if (!active) { continue; }
|
||||
const bool di1 = (bits & 1U) != 0, di2 = (bits & 2U) != 0, di8 = (bits & kDi8) != 0;
|
||||
if (channel == 0) {
|
||||
pairs.add_pending_ch1(value, di1, di2, di8);
|
||||
} else {
|
||||
if (pairs.pending_ch1_valid && pairs.pending_ch1_di1_high == di1 && pairs.pending_ch1_di2_high == di2) {
|
||||
if (pairs.pending_ch1_di8_high && di8) {
|
||||
reference_sum[0] += pairs.pending_ch1_raw;
|
||||
reference_sum[1] += value;
|
||||
++reference_count;
|
||||
} else if (!pairs.pending_ch1_di8_high && !di8) {
|
||||
if (!pairs.run_initialized) { pairs.start_run(di1); }
|
||||
else if (pairs.run_di1_high != di1) { finish_step(); pairs.start_run(di1); }
|
||||
pairs.add_pair_sample(di2, pairs.pending_ch1_raw, value);
|
||||
}
|
||||
}
|
||||
pairs.clear_pending_ch1();
|
||||
if (++frames >= max_frames) { finish_sweep(at_ns, false); }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop discards the incomplete current sweep. It never advances the switches.
|
||||
writer.shutdown();
|
||||
stop_session();
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
bind_to_parent();
|
||||
return run(parse_args(argc, argv));
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Collector error: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#include "tty_protocol_writer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <cerrno>
|
||||
#include <condition_variable>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <pty.h>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string io_error(const std::string& action, const std::string& path) {
|
||||
std::ostringstream out;
|
||||
out << action << " '" << path << "': " << std::strerror(errno);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void close_fd_if_open(int& fd) noexcept {
|
||||
if (fd >= 0) {
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void set_fd_raw(int fd) {
|
||||
struct termios tio {};
|
||||
if (::tcgetattr(fd, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot read tty attributes for", std::to_string(fd)));
|
||||
}
|
||||
::cfmakeraw(&tio);
|
||||
tio.c_cc[VMIN] = 1;
|
||||
tio.c_cc[VTIME] = 0;
|
||||
if (::tcsetattr(fd, TCSANOW, &tio) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot apply raw tty attributes to", std::to_string(fd)));
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::string> read_link_target(const std::string& path) {
|
||||
std::array<char, PATH_MAX> buf {};
|
||||
const ssize_t len = ::readlink(path.c_str(), buf.data(), buf.size() - 1U);
|
||||
if (len < 0) {
|
||||
if (errno == EINVAL || errno == ENOENT) {
|
||||
return std::nullopt;
|
||||
}
|
||||
throw std::runtime_error(io_error("Cannot read symlink", path));
|
||||
}
|
||||
buf[static_cast<std::size_t>(len)] = '\0';
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct TtyProtocolWriter::Impl {
|
||||
explicit Impl(std::size_t bytes) : capacity_bytes(bytes) {}
|
||||
|
||||
int fd = -1;
|
||||
int slave_fd = -1;
|
||||
std::string slave_path;
|
||||
bool owns_link = false;
|
||||
const std::size_t capacity_bytes;
|
||||
std::deque<std::vector<std::uint8_t>> packets;
|
||||
std::size_t queued_bytes = 0;
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable data_ready_cv;
|
||||
std::thread worker;
|
||||
bool stop_requested = false;
|
||||
std::exception_ptr failure;
|
||||
StatsSnapshot stats;
|
||||
};
|
||||
|
||||
TtyProtocolWriter::TtyProtocolWriter(std::string path, std::size_t ring_capacity_bytes)
|
||||
: path_(std::move(path)),
|
||||
impl_(std::make_unique<Impl>(ring_capacity_bytes)) {
|
||||
std::array<char, PATH_MAX> slave_name {};
|
||||
if (::openpty(&impl_->fd, &impl_->slave_fd, slave_name.data(), nullptr, nullptr) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create PTY bridge for", path_));
|
||||
}
|
||||
|
||||
try {
|
||||
impl_->slave_path = slave_name.data();
|
||||
set_fd_raw(impl_->slave_fd);
|
||||
|
||||
struct stat st {};
|
||||
if (::lstat(path_.c_str(), &st) == 0) {
|
||||
if (!S_ISLNK(st.st_mode) && !S_ISREG(st.st_mode)) {
|
||||
throw std::runtime_error("Refusing to replace non-link path '" + path_ + "'");
|
||||
}
|
||||
if (::unlink(path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot remove existing tty link", path_));
|
||||
}
|
||||
} else if (errno != ENOENT) {
|
||||
throw std::runtime_error(io_error("Cannot inspect tty link path", path_));
|
||||
}
|
||||
|
||||
if (::symlink(impl_->slave_path.c_str(), path_.c_str()) != 0) {
|
||||
throw std::runtime_error(io_error("Cannot create tty symlink", path_));
|
||||
}
|
||||
impl_->owns_link = true;
|
||||
} catch (...) {
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
|
||||
try {
|
||||
const auto flags = ::fcntl(impl_->fd, F_GETFL);
|
||||
if (flags < 0 || ::fcntl(impl_->fd, F_SETFL, flags | O_NONBLOCK) < 0) {
|
||||
throw std::runtime_error(io_error("Cannot make tty output nonblocking", path_));
|
||||
}
|
||||
impl_->worker = std::thread([this]() { worker_loop(); });
|
||||
} catch (...) {
|
||||
if (impl_->owns_link) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
TtyProtocolWriter::~TtyProtocolWriter() {
|
||||
try {
|
||||
shutdown();
|
||||
} catch (...) {
|
||||
}
|
||||
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (impl_->owns_link && !path_.empty()) {
|
||||
try {
|
||||
const auto target = read_link_target(path_);
|
||||
if (target && (*target == impl_->slave_path)) {
|
||||
::unlink(path_.c_str());
|
||||
}
|
||||
} catch (...) {
|
||||
}
|
||||
impl_->owns_link = false;
|
||||
}
|
||||
|
||||
close_fd_if_open(impl_->slave_fd);
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::enqueue_sweep(const std::vector<uint16_t>& words, const SweepMetadata& metadata) {
|
||||
if (words.empty()) { return; }
|
||||
if (words.size() % 4U != 0U || words.size() > 65534U * 8U ||
|
||||
metadata.source_id == 0 || metadata.cycle_id == 0 ||
|
||||
metadata.capture_start_ns == 0 || metadata.capture_end_ns <= metadata.capture_start_ns) {
|
||||
throw std::runtime_error("Invalid RPH sweep packet");
|
||||
}
|
||||
std::vector<std::uint8_t> packet{'R', 'P', 'H', '1'};
|
||||
packet.reserve(48U + words.size() * 2U);
|
||||
const auto append = [&](std::uint64_t value, unsigned bytes) {
|
||||
for (unsigned i = 0; i < bytes; ++i) { packet.push_back(static_cast<std::uint8_t>(value >> (i * 8U))); }
|
||||
};
|
||||
append(words.size() * 2U, 4);
|
||||
append(metadata.source_id, 8);
|
||||
append(metadata.cycle_id, 8);
|
||||
append(metadata.capture_start_ns, 8);
|
||||
append(metadata.capture_end_ns, 8);
|
||||
append(metadata.input, 2);
|
||||
append(metadata.output, 2);
|
||||
append(metadata.dirty, 4);
|
||||
for (const auto word : words) { append(word, 2); }
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->failure) { std::rethrow_exception(impl_->failure); }
|
||||
if (impl_->stop_requested) { throw std::runtime_error("tty writer is already shut down"); }
|
||||
if (packet.size() > impl_->capacity_bytes) {
|
||||
throw std::runtime_error("RPH sweep exceeds tty queue capacity");
|
||||
}
|
||||
while (impl_->queued_bytes + packet.size() > impl_->capacity_bytes) {
|
||||
impl_->stats.frames_dropped += (impl_->packets.front().size() - 48U) / 8U;
|
||||
impl_->queued_bytes -= impl_->packets.front().size();
|
||||
impl_->packets.pop_front();
|
||||
++impl_->stats.ring_overflows;
|
||||
}
|
||||
impl_->queued_bytes += packet.size();
|
||||
impl_->packets.push_back(std::move(packet));
|
||||
impl_->data_ready_cv.notify_one();
|
||||
}
|
||||
|
||||
TtyProtocolWriter::StatsSnapshot TtyProtocolWriter::stats() const {
|
||||
if (!impl_) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
return impl_->stats;
|
||||
}
|
||||
|
||||
const std::string& TtyProtocolWriter::path() const {
|
||||
return path_;
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::throw_if_failed() const {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::exception_ptr failure;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
failure = impl_->failure;
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
std::rethrow_exception(failure);
|
||||
}
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::shutdown() {
|
||||
if (!impl_) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stop_requested = true;
|
||||
}
|
||||
|
||||
impl_->data_ready_cv.notify_all();
|
||||
|
||||
if (impl_->worker.joinable()) {
|
||||
impl_->worker.join();
|
||||
}
|
||||
close_fd_if_open(impl_->fd);
|
||||
}
|
||||
|
||||
void TtyProtocolWriter::worker_loop() {
|
||||
for (;;) {
|
||||
std::vector<std::uint8_t> packet;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(impl_->mutex);
|
||||
impl_->data_ready_cv.wait(lock, [this]() {
|
||||
return impl_->stop_requested || impl_->failure || !impl_->packets.empty();
|
||||
});
|
||||
if (impl_->failure || impl_->stop_requested) { return; }
|
||||
packet = std::move(impl_->packets.front());
|
||||
impl_->packets.pop_front();
|
||||
impl_->queued_bytes -= packet.size();
|
||||
}
|
||||
// Once a packet starts writing it is indivisible, even if the reader
|
||||
// stalls. Overflow drops only complete packets still in the queue.
|
||||
const std::uint8_t* bytes = packet.data();
|
||||
std::size_t remaining = packet.size();
|
||||
while (remaining != 0U) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ssize_t written = ::write(impl_->fd, bytes, remaining);
|
||||
if (written < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if ((errno == EAGAIN) || (errno == EWOULDBLOCK) || (errno == EIO)) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (impl_->stop_requested) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error(io_error("Cannot write tty frame to", path_)));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
if (written == 0) {
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
if (!impl_->stop_requested) {
|
||||
impl_->failure = std::make_exception_ptr(
|
||||
std::runtime_error("tty write returned 0 bytes for '" + path_ + "'"));
|
||||
}
|
||||
impl_->data_ready_cv.notify_all();
|
||||
return;
|
||||
}
|
||||
|
||||
bytes += static_cast<std::size_t>(written);
|
||||
remaining -= static_cast<std::size_t>(written);
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->mutex);
|
||||
impl_->stats.frames_written += (packet.size() - 48U) / 8U;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Exercise the production collector with a test-only SDK and a real PTY.
|
||||
#define main collector_program_main
|
||||
#include "../rph_radar_collector/src/main.cpp"
|
||||
#undef main
|
||||
#include <sys/wait.h>
|
||||
#include <fcntl.h>
|
||||
#include <fstream>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
|
||||
|
||||
void test_source_session_restart() {
|
||||
char directory[] = "/tmp/rph-collector-XXXXXX";
|
||||
if (!::mkdtemp(directory)) { fail("mkdtemp failed"); }
|
||||
const std::string tty = std::string(directory) + "/tty", log = std::string(directory) + "/sdk.log";
|
||||
const auto child = ::fork();
|
||||
if (child == 0) {
|
||||
::setenv("RPH_SDK_LOG", log.c_str(), 1);
|
||||
Config cfg;
|
||||
cfg.tty_path = tty;
|
||||
cfg.switching = Json::parse(R"({"input":{"name":"input","driver_mode":"mock","positions":2,"default_position":0,"gpio_chip":"/dev/unused","pin_a":1,"pin_b":2},"output":{"name":"output","driver_mode":"mock","positions":2,"default_position":0,"gpio_chip":"/dev/unused","pin_a":3,"pin_b":4},"combos":[[0,0],[0,1]],"settling_ms":0})");
|
||||
try { ::_exit(run(cfg)); }
|
||||
catch (const std::exception& error) { std::cerr << error.what() << '\n'; ::_exit(2); }
|
||||
}
|
||||
if (child < 0) { std::filesystem::remove_all(directory); fail("collector fork failed"); }
|
||||
int fd = -1, status = 0;
|
||||
std::vector<unsigned char> packets;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
bool finished = false;
|
||||
const auto drain = [&]() {
|
||||
unsigned char bytes[4096];
|
||||
while (fd >= 0) {
|
||||
const auto count = ::read(fd, bytes, sizeof(bytes));
|
||||
if (count <= 0) { break; }
|
||||
packets.insert(packets.end(), bytes, bytes + count);
|
||||
}
|
||||
};
|
||||
while (!finished && std::chrono::steady_clock::now() < deadline) {
|
||||
if (fd < 0) { fd = ::open(tty.c_str(), O_RDONLY | O_NONBLOCK); }
|
||||
drain();
|
||||
finished = ::waitpid(child, &status, WNOHANG) == child;
|
||||
if (!finished) { ::usleep(1000); }
|
||||
}
|
||||
drain();
|
||||
if (fd >= 0) { ::close(fd); }
|
||||
if (!finished) { ::kill(child, SIGKILL); (void)::waitpid(child, &status, 0); }
|
||||
std::ifstream file(log);
|
||||
const std::string calls{std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
|
||||
std::filesystem::remove_all(directory);
|
||||
if (!finished || !WIFEXITED(status) || WEXITSTATUS(status)) { fail("SDK session regression did not finish successfully"); }
|
||||
const std::string session = "Create\nOpen\nStreamsStop\nStreamsDisable\nConfigure\nOutCycleLoadStart\nPrepareData\nSend\nOutCycleSetup WAIT_DONE\nStart\nOutCycleStop WAIT_DONE\nStreamsStop\nStreamsDisable\nClose\nFree\n";
|
||||
if (calls != session + session + session + session) { fail("SDK session/DOUT restart order changed: " + calls); }
|
||||
const auto read_le = [&packets](std::size_t offset, unsigned size) {
|
||||
uint64_t value = 0;
|
||||
for (unsigned i = 0; i < size; ++i) { value |= uint64_t{packets.at(offset + i)} << (8 * i); }
|
||||
return value;
|
||||
};
|
||||
unsigned clean = 0, total = 0;
|
||||
for (std::size_t offset = 0; offset < packets.size();) {
|
||||
if (packets.size() - offset < 48 || std::memcmp(packets.data() + offset, "RPH1", 4)) { fail("Invalid SDK fixture packet"); }
|
||||
const auto payload = read_le(offset + 4, 4), dirty = read_le(offset + 44, 4);
|
||||
if (payload != 16 || offset + 48 + payload > packets.size()) { fail("Incomplete phase sweep from SDK fixture"); }
|
||||
++total;
|
||||
if (!dirty) {
|
||||
++clean;
|
||||
if (read_le(offset + 8, 8) != 2 * clean || read_le(offset + 16, 8) != (clean + 1) / 2 ||
|
||||
read_le(offset + 40, 2) != 0 || read_le(offset + 42, 2) != (clean - 1) % 2 ||
|
||||
static_cast<int16_t>(read_le(offset + 52, 2)) != pack_raw_code_to_int16(10000 * clean)) {
|
||||
fail("Old ADC block crossed GPIO change, or source/cycle identity reset");
|
||||
}
|
||||
}
|
||||
offset += 48 + payload;
|
||||
}
|
||||
if (clean != 3 || total != 6) { fail("Expected only new-session full sweeps and dirty initial partials"); }
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_source_session_restart();
|
||||
for (const auto* period : {"do8_cycle_period:262145", "do8_cycle_period:4294967295"}) {
|
||||
std::vector<std::string> args{"collector", "profile:phase", "clock:internal", "tty:/tmp/unused",
|
||||
"do1_toggle_per_frame", "do1_pair_subtract_avg", "do8_freq_ref", period};
|
||||
std::vector<char*> argv;
|
||||
for (auto& arg : args) { argv.push_back(arg.data()); }
|
||||
bool rejected = false;
|
||||
try { (void)parse_args(static_cast<int>(argv.size()), argv.data()); }
|
||||
catch (const std::runtime_error& error) {
|
||||
rejected = std::string(error.what()).find("cyclic pattern exceeds") != std::string::npos;
|
||||
}
|
||||
if (!rejected) { throw std::runtime_error("DO8 overflow must be rejected before allocation or SDK access"); }
|
||||
}
|
||||
// Preserve the application's low-minus-high IQ averaging and quantization.
|
||||
Do1PairSubtractAverageState state;
|
||||
state.start_run(false);
|
||||
state.add_pair_sample(false, 1000, -3000);
|
||||
state.add_pair_sample(false, 5000, 1000);
|
||||
state.add_pair_sample(true, 200, -1000);
|
||||
state.add_pair_sample(true, 1000, -1000);
|
||||
if (state.diff_count != 2 || state.avg_diff_ch1() != 2400 || state.avg_diff_ch2() != 0 ||
|
||||
pack_raw_code_to_int16(X502_ADC_SCALE_CODE_MAX) != 32767 ||
|
||||
pack_raw_code_to_int16(-2 * X502_ADC_SCALE_CODE_MAX) != -32768) {
|
||||
throw std::runtime_error("phase IQ subtraction/averaging changed");
|
||||
}
|
||||
// An SDK teardown can hang or ignore SIGTERM; parent death must still
|
||||
// release the ADC. Reap the orphan here so the regression leaks nothing.
|
||||
if (::prctl(PR_SET_CHILD_SUBREAPER, 1) != 0) { throw std::runtime_error("subreaper failed"); }
|
||||
int ready[2], identity[2];
|
||||
if (::pipe(ready) != 0 || ::pipe(identity) != 0) { throw std::runtime_error("pipe failed"); }
|
||||
const auto owner = ::fork();
|
||||
if (owner == 0) {
|
||||
const auto collector = ::fork();
|
||||
if (collector < 0) { ::_exit(2); }
|
||||
if (collector == 0) {
|
||||
::close(ready[0]);
|
||||
::signal(SIGTERM, SIG_IGN);
|
||||
bind_to_parent();
|
||||
const char started = 1;
|
||||
if (::write(ready[1], &started, 1) != 1) { ::_exit(2); }
|
||||
for (;;) { ::pause(); }
|
||||
}
|
||||
::close(ready[1]);
|
||||
if (::write(identity[1], &collector, sizeof(collector)) != sizeof(collector)) { ::_exit(2); }
|
||||
char started = 0;
|
||||
::_exit(::read(ready[0], &started, 1) == 1 ? 0 : 2);
|
||||
}
|
||||
if (owner < 0) { throw std::runtime_error("fork failed"); }
|
||||
::close(ready[0]);
|
||||
::close(ready[1]);
|
||||
::close(identity[1]);
|
||||
int status = 0;
|
||||
(void)::waitpid(owner, &status, 0);
|
||||
pid_t child = 0;
|
||||
if (::read(identity[0], &child, sizeof(child)) != sizeof(child)) { throw std::runtime_error("missing child PID"); }
|
||||
::close(identity[0]);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1);
|
||||
pid_t orphan = 0;
|
||||
while ((orphan = ::waitpid(-1, &status, WNOHANG)) == 0 && std::chrono::steady_clock::now() < deadline) {
|
||||
::usleep(1000);
|
||||
}
|
||||
if (orphan <= 0 || !WIFSIGNALED(status) || WTERMSIG(status) != SIGKILL) {
|
||||
if (child > 0) { ::kill(child, SIGKILL); (void)::waitpid(child, &status, 0); }
|
||||
throw std::runtime_error("collector survived parent death or entered SIGTERM teardown");
|
||||
}
|
||||
std::cout << "collector regressions passed\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
#include "capture_file_writer.h"
|
||||
#include "channel_bundle_support.hpp"
|
||||
#include "data_preprocessor.hpp"
|
||||
#include "locator/tcp_server.hpp"
|
||||
@@ -7,6 +6,8 @@
|
||||
#include "runtime_wait.hpp"
|
||||
#include "shm_ring.hpp"
|
||||
#include "tty_protocol_writer.h"
|
||||
#include "switch_sequencer.h"
|
||||
#include <fcntl.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
#include <sys/socket.h>
|
||||
@@ -307,40 +308,6 @@ void test_cosine_notch_taper() {
|
||||
}
|
||||
}
|
||||
|
||||
void test_csv_failures(const Workspace& workspace) {
|
||||
const auto csv = workspace.path / "capture.csv";
|
||||
const auto spool = workspace.path / "capture.capture_spool.bin";
|
||||
CaptureFileWriter writer(csv.string(), (workspace.path / "capture.svg").string(), "", "");
|
||||
writer.initialize_csv(2, false);
|
||||
CapturePacket packet{.ch1 = {1, 2}, .ch2 = {3, 4}, .di1 = {}};
|
||||
auto invalid_packet = packet;
|
||||
invalid_packet.ch2.pop_back();
|
||||
expect_error([&] { writer.append_csv_packet(invalid_packet); });
|
||||
require(std::filesystem::file_size(spool) == 16, "invalid packet partially changed the spool");
|
||||
writer.append_csv_packet(packet);
|
||||
std::filesystem::remove(csv);
|
||||
std::filesystem::create_symlink("/dev/full", csv);
|
||||
expect_error([&] { writer.write({packet}, 1000, 10); });
|
||||
require(std::filesystem::file_size(spool) > 16, "failed CSV write deleted the recovery spool");
|
||||
std::filesystem::remove(csv);
|
||||
writer.write({packet}, 1000, 10);
|
||||
require(!std::filesystem::exists(spool), "successful CSV export retained the spool");
|
||||
std::ifstream csv_input(csv);
|
||||
const std::string csv_text((std::istreambuf_iterator<char>(csv_input)), {});
|
||||
require(csv_text.find("1.000000000,3.000000000") != std::string::npos,
|
||||
"CSV retry lost channel samples");
|
||||
writer.initialize_csv(2, false);
|
||||
std::ofstream(spool, std::ios::binary | std::ios::app).put('x');
|
||||
expect_error([&] { writer.write({}, 1000, 10); });
|
||||
require(std::filesystem::exists(spool), "truncated packet header deleted the spool");
|
||||
writer.initialize_csv(2, false);
|
||||
const std::array<std::uint64_t, 4> corrupt_packet{0, 2, 0xffffffffffffffffULL, 0};
|
||||
std::ofstream(spool, std::ios::binary | std::ios::app).write(
|
||||
reinterpret_cast<const char*>(corrupt_packet.data()), sizeof(corrupt_packet));
|
||||
expect_error([&] { writer.write({}, 1000, 10); });
|
||||
require(std::filesystem::exists(spool), "oversized packet count deleted the spool");
|
||||
}
|
||||
|
||||
void test_asset_reload_preserves_data(const Workspace& workspace) {
|
||||
const auto save = [&](const char* name, float sample) {
|
||||
return save_bundle(workspace, name, {.traces = {
|
||||
@@ -497,10 +464,77 @@ void test_locator_packet_errors() {
|
||||
for (unsigned iteration = 0; iteration < 30; ++iteration) { server.start(); server.stop(); }
|
||||
}
|
||||
|
||||
void test_tty_sweep_overflow(const Workspace& workspace) {
|
||||
TtyProtocolWriter writer((workspace.path / "overflow_tty").string(), 32'096);
|
||||
const int fd = ::open(writer.path().c_str(), O_RDONLY | O_NONBLOCK);
|
||||
require(fd >= 0, "Cannot open test PTY");
|
||||
std::vector<std::uint16_t> words(8'000, 42);
|
||||
for (std::uint64_t id = 1; id <= 100; ++id) {
|
||||
writer.enqueue_sweep(words, {id, id, id * 100, id * 100 + 50, 1, 2, false});
|
||||
}
|
||||
require(writer.stats().ring_overflows > 0, "test did not overflow the queue");
|
||||
std::vector<std::uint8_t> received;
|
||||
std::uint64_t last_id = 0;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (last_id < 100 && std::chrono::steady_clock::now() < deadline) {
|
||||
std::array<std::uint8_t, 8192> chunk{};
|
||||
const auto n = ::read(fd, chunk.data(), chunk.size());
|
||||
if (n > 0) { received.insert(received.end(), chunk.begin(), chunk.begin() + n); }
|
||||
while (received.size() >= 16'048) {
|
||||
require(std::memcmp(received.data(), "RPH1", 4) == 0, "overflow truncated a sweep header");
|
||||
const auto value = [&](std::size_t at, unsigned size) {
|
||||
std::uint64_t result = 0;
|
||||
for (unsigned i = 0; i < size; ++i) { result |= std::uint64_t{received[at + i]} << (8U * i); }
|
||||
return result;
|
||||
};
|
||||
require(value(4, 4) == 16'000 && value(8, 8) > last_id, "lost framing or source order");
|
||||
last_id = value(8, 8);
|
||||
require(value(16, 8) == last_id && value(24, 8) == last_id * 100 &&
|
||||
value(32, 8) == last_id * 100 + 50 && value(40, 2) == 1 && value(42, 2) == 2,
|
||||
"sweep lost source identity, timing, or combo");
|
||||
require(received[48] == 42 && received[16'047] == 0, "corrupt point payload");
|
||||
received.erase(received.begin(), received.begin() + 16'048);
|
||||
}
|
||||
if (n <= 0) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
|
||||
}
|
||||
::close(fd);
|
||||
require(last_id == 100 && received.empty(), "latest complete sweep did not arrive");
|
||||
}
|
||||
|
||||
void test_switch_backlog() {
|
||||
drivers::H7992MinimalDriverSettings settings{.mode = config::DriverMode::Mock};
|
||||
rph::SwitchSequencer sequencer({.combos = {{0, 0}, {0, 1}}},
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings),
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings));
|
||||
sequencer.open();
|
||||
sequencer.session_started();
|
||||
require(!sequencer.on_sweep_end(false), "duration limit requested a new session");
|
||||
require(sequencer.current_combo().output_pos == 0 && sequencer.cycle_id() == 1,
|
||||
"duration limit switched hardware before the physical sweep ended");
|
||||
require(sequencer.on_sweep_end(), "GPIO change must request a new source session");
|
||||
for (unsigned i = 0; i < 10; ++i) {
|
||||
require(sequencer.current_dirty(), "queued pre-switch sweep incorrectly clean");
|
||||
require(!sequencer.on_sweep_end(), "dirty backlog requested another session");
|
||||
require(sequencer.current_combo().output_pos == 1 && sequencer.cycle_id() == 1,
|
||||
"dirty backlog advanced the combo or cycle");
|
||||
}
|
||||
sequencer.session_started();
|
||||
require(!sequencer.current_dirty(), "new source session must permit a complete sweep");
|
||||
sequencer.on_sweep_end();
|
||||
require(sequencer.current_combo().output_pos == 0 && sequencer.cycle_id() == 2,
|
||||
"clean cycle did not advance");
|
||||
rph::SwitchSequencer single({.combos = {{0, 0}}},
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings),
|
||||
std::make_unique<drivers::H7992MinimalDriver>(settings));
|
||||
single.open(); single.session_started();
|
||||
require(!single.on_sweep_end() && single.cycle_id() == 2 && !single.current_dirty(),
|
||||
"unchanged combination restarted its source or stopped advancing cycles");
|
||||
}
|
||||
|
||||
void test_unread_tty_shutdown(const Workspace& workspace) {
|
||||
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'000);
|
||||
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'048);
|
||||
const std::vector<std::uint16_t> words(80'000, 42);
|
||||
writer.enqueue_encoded_frames(words.data(), words.size() / 4U);
|
||||
writer.enqueue_sweep(words, {1, 1, 100, 200});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
writer.shutdown();
|
||||
@@ -524,9 +558,10 @@ int main(int argc, char** argv) {
|
||||
test_acquisition_identity();
|
||||
test_interruptible_wait();
|
||||
test_cosine_notch_taper();
|
||||
test_csv_failures(workspace);
|
||||
test_locator_packet_errors();
|
||||
test_tty_sweep_overflow(workspace);
|
||||
test_switch_backlog();
|
||||
test_unread_tty_shutdown(workspace);
|
||||
test_locator_packet_errors();
|
||||
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "native regression failed: " << error.what() << '\n';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SDK contract fixture: Stop retains queued input; only a new descriptor is fresh.
|
||||
#include "x502api.h"
|
||||
#include "e502api.h"
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
struct Session { unsigned id; bool consumed = false; std::vector<uint32_t> input; };
|
||||
unsigned sessions = 0;
|
||||
void record(const char* name) { std::ofstream(std::getenv("RPH_SDK_LOG"), std::ios::app) << name << '\n'; }
|
||||
Session& session(t_x502_hnd hnd) { return *reinterpret_cast<Session*>(hnd); }
|
||||
void generate(Session& s) {
|
||||
for (unsigned sweep = 0; sweep < 4; ++sweep) {
|
||||
for (unsigned step = 0; step < 2; ++step) {
|
||||
for (unsigned n = 0; n < 8; ++n) {
|
||||
const bool high = n % 4 >= 2;
|
||||
s.input.push_back(high ? 0 : 10000 * s.id);
|
||||
s.input.push_back((1U << 13) | step | (high ? 2 : 0));
|
||||
}
|
||||
}
|
||||
for (unsigned n = 0; n < 4; ++n) { s.input.push_back(0); s.input.push_back(0); }
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" int32_t E502_OpenUsb(t_x502_hnd hnd, const char *serial) { record("Open"); return 0; }
|
||||
extern "C" int32_t X502_AsyncOutDig(t_x502_hnd hnd, uint32_t val, uint32_t msk) { return 0; }
|
||||
extern "C" int32_t X502_Close(t_x502_hnd hnd) { record("Close"); return 0; }
|
||||
extern "C" int32_t X502_Configure(t_x502_hnd hnd, uint32_t flags) { record("Configure"); return 0; }
|
||||
extern "C" t_x502_hnd X502_Create(void) { record("Create"); auto* s = new Session{++sessions}; generate(*s); return reinterpret_cast<t_x502_hnd>(s); }
|
||||
extern "C" int32_t X502_Free(t_x502_hnd hnd) { record("Free"); delete &session(hnd); return 0; }
|
||||
extern "C" int32_t X502_GetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; *f_frame = 1000000; return 0; }
|
||||
extern "C" const char* X502_GetErrorString(int32_t err) { return "SDK contract fixture"; }
|
||||
extern "C" int32_t X502_GetRecvReadyCount(t_x502_hnd hnd, uint32_t *rdy_cnt) { *rdy_cnt = session(hnd).input.size(); return 0; }
|
||||
extern "C" int32_t X502_OutCycleLoadStart(t_x502_hnd hnd, uint32_t size) { record("OutCycleLoadStart"); return 0; }
|
||||
extern "C" int32_t X502_OutCycleSetup(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleSetup WAIT_DONE"); return 0; }
|
||||
extern "C" int32_t X502_OutCycleStop(t_x502_hnd hnd, uint32_t flags) { if (flags != X502_OUT_CYCLE_FLAGS_WAIT_DONE) { return -1; } record("OutCycleStop WAIT_DONE"); return 0; }
|
||||
extern "C" int32_t X502_PrepareData(t_x502_hnd hnd, const double* dac1, const double* dac2, const uint32_t* digout, uint32_t size, int32_t flags, uint32_t* out_buf) { record("PrepareData"); std::copy_n(digout, size, out_buf); return 0; }
|
||||
extern "C" int32_t X502_ProcessData(t_x502_hnd hnd, const uint32_t* src, uint32_t size, uint32_t flags, double *adc_data, uint32_t *adc_data_size, uint32_t *din_data, uint32_t *din_data_size) { *adc_data_size = *din_data_size = size / 2;
|
||||
for (unsigned n = 0; n < size / 2; ++n) {
|
||||
adc_data[n] = static_cast<int32_t>(src[2 * n]); din_data[n] = src[2 * n + 1];
|
||||
}
|
||||
return 0; }
|
||||
extern "C" int32_t X502_Recv(t_x502_hnd hnd, uint32_t* buf, uint32_t size, uint32_t tout) { auto& s = session(hnd); usleep(20000);
|
||||
if (s.consumed) { raise(SIGTERM); return 0; }
|
||||
if (size < s.input.size()) { return -1; }
|
||||
std::copy(s.input.begin(), s.input.end(), buf); s.consumed = true;
|
||||
return static_cast<int32_t>(s.input.size()); }
|
||||
extern "C" int32_t X502_Send(t_x502_hnd hnd, const uint32_t* buf, uint32_t size, uint32_t tout) { record("Send"); return size; }
|
||||
extern "C" int32_t X502_SetAdcFreq(t_x502_hnd hnd, double *f_acq, double *f_frame) { *f_acq = 2000000; if (f_frame) { *f_frame = 1000000; } return 0; }
|
||||
extern "C" int32_t X502_SetDigInPullup(t_x502_hnd hnd, uint32_t pullups) { return 0; }
|
||||
extern "C" int32_t X502_SetDinFreq(t_x502_hnd hnd, double *f_din) { return 0; }
|
||||
extern "C" int32_t X502_SetLChannel(t_x502_hnd hnd, uint32_t lch, uint32_t phy_ch, uint32_t mode, uint32_t range, uint32_t avg) { return 0; }
|
||||
extern "C" int32_t X502_SetLChannelCount(t_x502_hnd hnd, uint32_t lch_cnt) { return 0; }
|
||||
extern "C" int32_t X502_SetMode(t_x502_hnd hnd, uint32_t mode) { return 0; }
|
||||
extern "C" int32_t X502_SetOutFreq(t_x502_hnd hnd, double *f_dout) { return 0; }
|
||||
extern "C" int32_t X502_SetRefFreq(t_x502_hnd hnd, uint32_t freq) { return 0; }
|
||||
extern "C" int32_t X502_SetStreamBufSize(t_x502_hnd hnd, uint32_t ch, uint32_t size) { return 0; }
|
||||
extern "C" int32_t X502_SetStreamStep(t_x502_hnd hnd, uint32_t dma_ch, uint32_t step) { return 0; }
|
||||
extern "C" int32_t X502_SetSyncMode(t_x502_hnd hnd, uint32_t sync_mode) { return 0; }
|
||||
extern "C" int32_t X502_SetSyncStartMode(t_x502_hnd hnd, uint32_t sync_start_mode) { return 0; }
|
||||
extern "C" int32_t X502_StreamsDisable(t_x502_hnd hnd, uint32_t streams) { record("StreamsDisable"); return 0; }
|
||||
extern "C" int32_t X502_StreamsEnable(t_x502_hnd hnd, uint32_t streams) { return 0; }
|
||||
extern "C" int32_t X502_StreamsStart(t_x502_hnd hnd) { record("Start"); if (session(hnd).id == 4) { raise(SIGTERM); } return 0; }
|
||||
extern "C" int32_t X502_StreamsStop(t_x502_hnd hnd) { record("StreamsStop"); return 0; }
|
||||
Reference in New Issue
Block a user