Skip to content

Station mode: whole receive site in one process#102

Merged
kevinelliott merged 1 commit into
masterfrom
station-mode
Jun 12, 2026
Merged

Station mode: whole receive site in one process#102
kevinelliott merged 1 commit into
masterfrom
station-mode

Conversation

@kevinelliott

@kevinelliott kevinelliott commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

What

Task #39 — the platform feature no single-mode decoder has: xng station station.toml runs several decode sessions (modes × SDRs/files) in one process, sharing one message bus, one output set (one Airframes feed, one JSONL/MQTT/metrics endpoint).

  • runtime::run_station + spawn_outputs extracted from run_session (no behavior change to existing commands)
  • TOML config, kebab-case with deny_unknown_fields (typos fail loudly): [outputs] + [[session]] entries; sdr = for live hardware or file = for IQ replay; rate/center/channels derive from the mode's built-in plan when omitted (the TUI's derivation)
  • contrib/station.example.toml + hardened systemd unit (ProtectSystem=strict, NoNewPrivileges)
  • README section

Tested

Integration: VDL2 (105 kS/s capture) + ADS-B (modes1) concurrently in one process → both hit their bench-gate counts (44 / 323) into a shared JSONL. Config parsing unit tests (example config, sdr/file exclusivity, unknown-field rejection). Full workspace + all four bench gates green.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added "Whole-station mode" enabling a single process to manage multiple decode sessions (ACARS, VDL2, ADS-B, etc.) with shared outputs, feed configuration, and metrics endpoints via TOML configuration files.
    • Included example configuration and systemd service file for deployment support.

…oml)

Several decode sessions - different modes, different SDRs or IQ files -
run as one process sharing one message bus, one output set (one
Airframes feed, one JSONL, one MQTT...), and one metrics endpoint.
No single-mode decoder can do this; it matches how stations actually
deploy.

- runtime::run_station + spawn_outputs extracted from run_session
- TOML config (kebab-case, deny_unknown_fields): [outputs] block +
  [[session]] entries; sessions take sdr= (live) or file= (replay);
  rate/center/channels derive from the mode plan when omitted (same
  derivation as the TUI)
- contrib/station.example.toml + hardened systemd unit
- README section

Integration-tested: VDL2 (105k file) + ADS-B (modes1) concurrently in
one process, shared JSONL, both at their bench-gate counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces "whole-station mode," a new CLI subcommand that runs multiple SDR-based or file-replay decode sessions within a single process, sharing unified outputs (console, JSONL, Airframes feed, metrics). Configuration is loaded from a TOML file specifying station identity, output sinks, and a list of sessions with their tuning and source parameters.

Changes

Whole-station mode: multi-session SDR coordination

Layer / File(s) Summary
Configuration schema and TOML loading
Cargo.toml, src/commands/station.rs, src/commands/mod.rs, contrib/station.example.toml
Adds toml dependency. Defines StationFile, OutputsToml, and SessionToml with serde kebab-case keys and strict field validation. Implements load(path) to deserialize TOML, validate non-empty sessions, and enforce that each session has exactly one of sdr or file. Includes unit tests for schema parsing, sdr/file exclusivity, and unknown field rejection. Exports module and provides a complete example station configuration.
CLI subcommand wiring and session setup
src/main.rs
Adds Station subcommand variant taking a config: PathBuf. Extends command dispatcher to invoke run_station_cmd, which loads the TOML, builds a shared OutputConfig with optional Airframes UDP feed, loops over sessions to auto-resolve or parse tuning (sample rate, center frequency, channels), opens the appropriate IqSource (SDR or file), constructs per-session SessionConfig, and invokes runtime::run_station.
Multi-session runtime execution and shared outputs
src/runtime.rs
Makes OutputConfig cloneable. Introduces spawn_outputs helper to centralize task spawning for all output sinks (console, JSONL, UDP, SBS, Beast, NMEA, MQTT, ASF2) by subscribing each from the shared MessageBus. Refactors run_session to use spawn_outputs. Implements new run_station function to validate sessions, prepare decoders and capture centers, create shared message bus and station identity, spawn shared outputs once, run one blocking decode task per session with independent reassembly state and label filtering, log per-session decode statistics, then drop the bus and await output task completion.
Documentation and systemd deployment
README.md, contrib/xng-station.service
Documents whole-station mode with example showing shared outputs (Airframes + Prometheus metrics) and multiple sessions (ACARS and ADS-B via RTL-SDR, with IQ file replay support). Provides systemd unit file that starts the station with hardening (ProtectSystem=strict, NoNewPrivileges), user/group isolation (xng/plugdev), write access only to /var/log/xng, and network ordering.

Sequence Diagram

sequenceDiagram
  participant User
  participant CLI
  participant ConfigLoader
  participant OutputFactory
  participant SessionFactory
  participant Runtime
  participant MessageBus
  participant OutputTasks
  participant DecodeTasks

  User->>CLI: xng station station.toml
  CLI->>ConfigLoader: load(config_path)
  ConfigLoader->>ConfigLoader: parse and validate TOML
  ConfigLoader-->>CLI: StationFile
  
  CLI->>OutputFactory: build OutputConfig
  OutputFactory-->>CLI: shared outputs config
  
  CLI->>SessionFactory: for each session, resolve tuning
  SessionFactory->>SessionFactory: open SDR or file source
  SessionFactory-->>CLI: Vec<(IqSource, SessionConfig)>
  
  CLI->>Runtime: run_station(sessions)
  Runtime->>MessageBus: create shared bus
  Runtime->>OutputTasks: spawn_outputs (console, JSONL, UDP, metrics, etc.)
  OutputTasks->>MessageBus: subscribe consumers
  
  Runtime->>DecodeTasks: spawn decode task per session
  DecodeTasks->>DecodeTasks: decode_loop with independent state
  DecodeTasks->>MessageBus: publish decoded messages
  MessageBus->>OutputTasks: broadcast to all output sinks
  
  DecodeTasks-->>Runtime: session complete
  Runtime->>Runtime: drop bus, await outputs
  Runtime-->>User: exit
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 One station, many signals bright,
Sharing outputs through the night.
SDRs dance in harmony,
TOML guides the symphony.
A rabbit's gift of coordination!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and clearly describes the main change: adding a new station mode that runs a whole receive site in one process, which is the primary objective of this changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch station-mode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b75873e323

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/runtime.rs
prep.cfg.sdr.clone(),
bus,
stop,
None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate station metrics from decode loops

When station configs enable [outputs].metrics, the endpoint is started with a fresh LiveState above, but each station decode loop is invoked with None here. In that configuration the metrics server has no producer updating samples or per-channel stats, so /metrics will keep reporting zero samples and no frame counters even while the sessions are decoding; pass a shared LiveState into the station decode loops (or otherwise aggregate their stats) so the advertised station metrics endpoint reflects live traffic.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/station.rs`:
- Around line 111-122: The test rejects_sdr_and_file_together currently only
deserializes into StationFile and never exercises the exclusivity check in
load(); update the test to actually invoke the validation by either (A)
extracting the sdr/file exclusivity logic from load() into a small helper (e.g.
validate_session_exclusivity or StationFile::validate_sessions) and calling that
helper in the test to assert it returns an error for f.sessions[0] where both
sdr and file are Some, or (B) write the TOML string to a temporary file and call
the existing StationFile::load() (or the public load() that reads/validates the
config) and assert load() returns an Err; reference the test
rejects_sdr_and_file_together, the StationFile type, the load() method, and the
sessions[].sdr / sessions[].file fields when making the change.

In `@src/main.rs`:
- Around line 705-714: The current branch requires tune.sample_rate,
tune.center_freq and tune.channels together and bails for file-backed sessions,
preventing auto-derivation; change the logic so that if
tune.sample_rate.is_some() and parse_tune(&tune) can fully parse, use it,
otherwise call resolve_tune_auto to derive missing center/channels (invoke
resolve_tune_auto with sess.sdr when present, or with the mode-plan source used
by the TUI when sess.sdr is None) instead of bailing; update the match around
parse_tune, resolve_tune_auto, and the use of label so file sessions with only
sample_rate will fall back to resolve_tune_auto rather than erroring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a50edf85-2a08-49b6-924b-358a4bb5440d

📥 Commits

Reviewing files that changed from the base of the PR and between 481d7d6 and b75873e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • README.md
  • contrib/station.example.toml
  • contrib/xng-station.service
  • src/commands/mod.rs
  • src/commands/station.rs
  • src/main.rs
  • src/runtime.rs

Comment thread src/commands/station.rs
Comment on lines +111 to +122
fn rejects_sdr_and_file_together() {
let toml = r#"
station-id = "X"
[[session]]
sdr = "driver=rtlsdr"
file = "x.cf32"
mode = "acars"
"#;
let f: StationFile = toml::from_str(toml).unwrap();
// load() enforces the exclusivity; emulate via the same check
assert!(!(f.sessions[0].sdr.is_some() ^ f.sessions[0].file.is_some()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Exercise the actual exclusivity validation here.

This test only proves TOML deserialization accepts both keys; it never hits the load() path that is supposed to reject them. If the anyhow::ensure! loop is removed from load(), this test still passes. Please either extract the sdr/file validation into a helper and assert that helper fails, or write the config to a temp file and assert load() returns an error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/station.rs` around lines 111 - 122, The test
rejects_sdr_and_file_together currently only deserializes into StationFile and
never exercises the exclusivity check in load(); update the test to actually
invoke the validation by either (A) extracting the sdr/file exclusivity logic
from load() into a small helper (e.g. validate_session_exclusivity or
StationFile::validate_sessions) and calling that helper in the test to assert it
returns an error for f.sessions[0] where both sdr and file are Some, or (B)
write the TOML string to a temporary file and call the existing
StationFile::load() (or the public load() that reads/validates the config) and
assert load() returns an Err; reference the test rejects_sdr_and_file_together,
the StationFile type, the load() method, and the sessions[].sdr /
sessions[].file fields when making the change.

Comment thread src/main.rs
Comment on lines +705 to +714
let (mode, rate, center_hz, channels) = if tune.sample_rate.is_some()
&& tune.center_freq.is_some()
&& !tune.channels.is_empty()
{
parse_tune(&tune)?
} else if let Some(sdr) = &sess.sdr {
resolve_tune_auto(&tune, sdr)?
} else {
anyhow::bail!("{label}: file sessions need sample-rate, center, and channels");
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

File-backed station sessions currently lose the TUI auto-derivation path.

When a session uses file and provides sample-rate but omits center and/or channels, this branch bails instead of deriving the missing values from the mode plan. That makes station replay sessions stricter than the TUI, even though this code explicitly claims to use the same derivation. citeturn0search0

Suggested fix
-        let (mode, rate, center_hz, channels) = if tune.sample_rate.is_some()
+        let (mode, rate, center_hz, channels) = if tune.sample_rate.is_some()
             && tune.center_freq.is_some()
             && !tune.channels.is_empty()
         {
             parse_tune(&tune)?
         } else if let Some(sdr) = &sess.sdr {
             resolve_tune_auto(&tune, sdr)?
+        } else if tune.sample_rate.is_some() {
+            // File sessions still need an explicit sample rate, but the
+            // missing center/channels should follow the same plan-based
+            // derivation used by the TUI.
+            resolve_tune_auto(&tune, "")?
         } else {
-            anyhow::bail!("{label}: file sessions need sample-rate, center, and channels");
+            anyhow::bail!("{label}: file sessions need sample-rate");
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.rs` around lines 705 - 714, The current branch requires
tune.sample_rate, tune.center_freq and tune.channels together and bails for
file-backed sessions, preventing auto-derivation; change the logic so that if
tune.sample_rate.is_some() and parse_tune(&tune) can fully parse, use it,
otherwise call resolve_tune_auto to derive missing center/channels (invoke
resolve_tune_auto with sess.sdr when present, or with the mode-plan source used
by the TUI when sess.sdr is None) instead of bailing; update the match around
parse_tune, resolve_tune_auto, and the use of label so file sessions with only
sample_rate will fall back to resolve_tune_auto rather than erroring.

Comment thread src/runtime.rs
Comment on lines +696 to +706
if let Some(addr) = prepared[0].cfg.outputs.metrics.clone() {
let live = LiveState::new();
tokio::spawn(async move {
if let Err(e) =
crate::outputs::metrics::serve(addr, live, "station".to_string()).await
{
tracing::warn!("metrics endpoint failed: {e}");
}
});
}
let output_tasks = spawn_outputs(&bus, &prepared[0].cfg.outputs, &station);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The station metrics endpoint never receives live decoder state.

run_station() creates a fresh LiveState for metrics::serve, but each session's decode_loop() is invoked with live = None. As a result, samples, per-channel stats, and spectrum never get updated in station mode, so the shared metrics endpoint will serve empty or stale values even while sessions are decoding. citeturn0search0

Fixing this likely needs a station-level metrics model that all session tasks can update, or an explicit decision to disable metrics for station mode until that aggregation exists.

Also applies to: 733-740

@kevinelliott kevinelliott merged commit c50dd15 into master Jun 12, 2026
3 checks passed
@kevinelliott kevinelliott deleted the station-mode branch June 12, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant