Station mode: whole receive site in one process#102
Conversation
…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>
📝 WalkthroughWalkthroughThis 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. ChangesWhole-station mode: multi-session SDR coordination
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| prep.cfg.sdr.clone(), | ||
| bus, | ||
| stop, | ||
| None, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
Cargo.tomlREADME.mdcontrib/station.example.tomlcontrib/xng-station.servicesrc/commands/mod.rssrc/commands/station.rssrc/main.rssrc/runtime.rs
| 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())); | ||
| } |
There was a problem hiding this comment.
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.
| 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"); | ||
| }; |
There was a problem hiding this comment.
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. citeturn0search0
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.
| 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); |
There was a problem hiding this comment.
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. citeturn0search0
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
What
Task #39 — the platform feature no single-mode decoder has:
xng station station.tomlruns 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_outputsextracted fromrun_session(no behavior change to existing commands)deny_unknown_fields(typos fail loudly):[outputs]+[[session]]entries;sdr =for live hardware orfile =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)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