diff --git a/README.md b/README.md index 0b5cb6b5..5deb414b 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ The whole story, in order: [What's new](user_docs/whats-new.md). |---|---|---| | HackRF One | โœ… Full support | All diagnostics, gain stages, ADC metrics | | RTL-SDR (R820T, E4000, R828D) | โœ… Full support | Single tuner gain + AGC; no VGA, no BB filter, no Friis NF | -| tinySA / Ultra / Ultra+ | โœ… Spectrum support | ZS405 verified; calibrated dBm spectrum and waterfall | +| tinySA / Ultra / Ultra+ | โœ… Spectrum support | ZS405 verified; calibrated dBm spectrum, waterfall and native band sweeps | | **Anything with a SoapySDR driver** | ๐Ÿงช **Beta** | Airspy, SDRplay, Pluto, Lime, bladeRF, USRP, SoapyRemote. Unconfirmed on anything but a HackRF | | PortaPack H4M (Mayhem) | โœ… Full support | HackRF mode: all HackRF diagnostics apply | | HackRF Pro | ๐Ÿ”ฒ Planned | Needs hardware | diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index 9f17fc9e..84542787 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -194,7 +194,6 @@ impl App { std::thread::spawn(move || NetWorker::new(net_rx, net_state, geometry).run()); tasks::spawn_rx_task(Arc::clone(&state), Arc::clone(&device), Arc::clone(&rx_ctx)); - tasks::spawn_sweep_task(Arc::clone(&state), Arc::clone(&device)); tasks::spawn_net_survey_task(Arc::clone(&state), Arc::clone(&device)); } hardware::AcquisitionKind::PowerTrace => { @@ -207,6 +206,7 @@ impl App { ); } } + tasks::spawn_sweep_task(Arc::clone(&state), Arc::clone(&device)); tasks::spawn_sys_resource_task(Arc::clone(&state)); Ok(app) diff --git a/src/app/builder/registry.rs b/src/app/builder/registry.rs index 2043369f..2ff73746 100644 --- a/src/app/builder/registry.rs +++ b/src/app/builder/registry.rs @@ -145,6 +145,11 @@ impl App { acquisition: crate::hardware::AcquisitionKind, ) -> anyhow::Result> { let mut warnings = Vec::new(); + if acquisition == crate::hardware::AcquisitionKind::PowerTrace { + if let Some(preset) = config.presets.get_mut("lab_sweep") { + preset.panels.retain(|panel| panel.name != "signal_metrics"); + } + } config.presets.retain(|name, preset| { if preset.panels.is_empty() { warnings.push(format!("Preset '{name}' is unavailable because it has no panels")); @@ -526,8 +531,40 @@ mod tests { ..Default::default() }, ); + user.insert( + "my_sweep".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "header_slim".into(), + position: crate::config::Position::Top, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "sweep_panel".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "system_resources".into(), + position: crate::config::Position::Right, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "footer".into(), + position: crate::config::Position::Bottom, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); - let (engine, _) = App::build_ui_for( + let (mut engine, _) = App::build_ui_for( "my_trace", &user, None, @@ -539,8 +576,11 @@ mod tests { "spectrum", "waterfall", "spectrum_waterfall", + "lab_sweep", + "micro_sweep", "my_trace", "my_status", + "my_sweep", ] { assert!(engine.has_preset(available), "{available} was hidden"); } @@ -550,13 +590,31 @@ mod tests { "lab_rf", "lab_timing", "lab_signal", - "lab_sweep", - "micro_sweep", "my_iq", ] { assert!(!engine.has_preset(unavailable), "{unavailable} survived"); } assert_eq!(engine.active_preset(), "my_trace"); + engine.set_preset("my_sweep"); + assert!(engine.is_panel_visible("sweep_panel")); + assert!(engine.is_panel_visible("system_resources")); + engine.set_preset("lab_sweep"); + assert!(engine.is_panel_visible("sweep_panel")); + assert!(!engine.is_panel_visible("signal_metrics")); + } + + #[test] + fn an_iq_device_keeps_signal_metrics_in_the_lab_sweep_layout() { + let (engine, _) = App::build_ui_for( + "lab_sweep", + &HashMap::new(), + None, + false, + crate::hardware::AcquisitionKind::IqSamples, + ) + .unwrap(); + assert_eq!(engine.active_preset(), "lab_sweep"); + assert!(engine.is_panel_visible("signal_metrics")); } #[test] @@ -615,7 +673,13 @@ mod tests { #[test] fn incompatible_overrides_cannot_leave_a_power_device_without_a_layout() { let mut user = HashMap::new(); - for name in ["spectrum", "waterfall", "spectrum_waterfall"] { + for name in [ + "spectrum", + "waterfall", + "spectrum_waterfall", + "lab_sweep", + "micro_sweep", + ] { user.insert( name.to_string(), crate::config::PresetConfig { diff --git a/src/app/input/sweep.rs b/src/app/input/sweep.rs index cc43d4e5..6bdf97cc 100644 --- a/src/app/input/sweep.rs +++ b/src/app/input/sweep.rs @@ -46,6 +46,7 @@ pub(super) fn sweep_panel(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyCode::Char('c') => { let m = metrics(state); + let unit = m.caps.level_unit.label(); let msg = if let Some(frame) = m.sweep.current_frame.as_ref() { let curve = if m.sweep.show_peak { &frame.peak_dbfs @@ -62,7 +63,7 @@ pub(super) fn sweep_panel(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { .min_by_key(|(_, &f)| f.abs_diff(hz)) .and_then(|(i, _)| curve.get(i).copied().filter(|v| v.is_finite())); let db_str = level - .map(|v| format!("{:.1} dBFS", v)) + .map(|v| format!("{v:.1} {unit}")) .unwrap_or_else(|| "\u{2014}".into()); format!("cursor {:.3} MHz {} ยท ", hz as f64 / 1e6, db_str) } else { @@ -72,7 +73,7 @@ pub(super) fn sweep_panel(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { .top_peaks(1, 500_000) .into_iter() .next() - .map(|(f, v)| format!("top {:.3} MHz {:.1} dBFS", f as f64 / 1e6, v)) + .map(|(f, v)| format!("top {:.3} MHz {v:.1} {unit}", f as f64 / 1e6)) .unwrap_or_else(|| "no data".into()); format!( "Sweep snapshot โ€” {}{} ยท {:.1}โ€“{:.1} MHz ({:.1}s/cycle)", diff --git a/src/app/input/text.rs b/src/app/input/text.rs index 162cb023..f1427f0b 100644 --- a/src/app/input/text.rs +++ b/src/app/input/text.rs @@ -200,11 +200,15 @@ pub(super) fn sweep_range(key: KeyEvent, state: &Arc>, is_star let (start, stop) = (m.sweep.config.start_hz, m.sweep.config.stop_hz); let ordered = if is_start { hz < stop } else { hz > start }; if ordered { + let changed = if is_start { hz != start } else { hz != stop }; if is_start { m.sweep.config.start_hz = hz; } else { m.sweep.config.stop_hz = hz; } + if changed { + m.sweep.generation = m.sweep.generation.wrapping_add(1); + } m.sweep.cycle_count = 0; m.sweep.positions_done = 0; m.sweep.current_frame = None; diff --git a/src/app/mod.rs b/src/app/mod.rs index b5eff2d9..201b5ea3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -114,11 +114,9 @@ impl App { } fn draw(&mut self, terminal: &mut Terminal) -> io::Result<()> { - // Sweep mode is owned by the `lab_sweep` preset: keep the real state's - // `sweep.active` in sync with the active preset so the sweep_task starts - // and stops with it, then take the render snapshot. let active_preset = self.engine.active_preset().to_string(); - let sweep_active = active_preset == "lab_sweep" || active_preset == "micro_sweep"; + let sweep_active = self.engine.is_panel_visible("sweep_panel") + || self.engine.is_panel_visible("micro_sweep_panel"); // The demod is gated on its panel being on screen, not on the preset being // called `lab_signal`: presets are data, and a user preset that lists // `fm_demod` used to get a panel that never received a block - it sat at diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 52635aa7..ea76b761 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -30,7 +30,7 @@ pub use discovery::{list_all_devices, open_device, DeviceKind, DeviceListing}; #[cfg(test)] pub(crate) use traits::RateSet; pub use traits::{ - AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, - LevelUnit, PowerTrace, RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, - StageSpec, StreamBlock, IQ_TRACE_STALE_MS, + AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, DirectSweepConfig, + FeedHealth, GainModel, LevelUnit, PowerTrace, PowerTraceTarget, RxContext, SampleFormat, + SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, IQ_TRACE_STALE_MS, }; diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index fe77e432..ecb0c840 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -15,8 +15,9 @@ use crossbeam_channel::{bounded, Receiver, Sender, TryRecvError}; use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; use crate::hardware::{ - AcquisitionKind, DeliveryModel, DeviceCapabilities, DeviceInfo, DeviceListing, GainModel, - LevelUnit, PowerTrace, RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, + AcquisitionKind, DeliveryModel, DeviceCapabilities, DeviceInfo, DeviceListing, + DirectSweepConfig, GainModel, LevelUnit, PowerTrace, PowerTraceTarget, RxContext, SampleFormat, + SampleGeometry, SdrDevice, SoftwareStack, }; use super::traits::RateSet; @@ -242,6 +243,10 @@ impl SdrDevice for TinySaDevice { self.request(Command::NoOp) } + fn set_direct_sweep(&self, config: Option) -> anyhow::Result<()> { + self.request(|reply| Command::SetDirectSweep(config, reply)) + } + fn open_notes(&self) -> &[String] { &self.notes } @@ -270,6 +275,7 @@ enum Command { SetFrequency(u64, UnitReply), SetSpan(f64, Sender>), NoOp(UnitReply), + SetDirectSweep(Option, UnitReply), Shutdown(UnitReply), } @@ -285,6 +291,7 @@ struct Worker { basic_input: BasicInput, center_hz: u64, span_hz: u64, + direct_sweep: Option, rx_context: Option>, } @@ -346,6 +353,7 @@ fn worker_entry( center_hz, span_hz: DEFAULT_SPAN_HZ, basic_input, + direct_sweep: None, rx_context: None, } .run(); @@ -373,10 +381,27 @@ impl Worker { effective_center_hz, effective_span_hz, }) => { + update_normal_window( + self.direct_sweep, + &mut self.center_hz, + &mut self.span_hz, + effective_center_hz, + effective_span_hz, + ); if let Some(context) = &self.rx_context { + let target = if self.direct_sweep.is_some() { + PowerTraceTarget::Sweep + } else { + PowerTraceTarget::Spectrum + }; let published = context .power_tx .try_send(PowerTrace { + target, + generation: self + .direct_sweep + .map(|config| config.generation) + .unwrap_or(0), frequencies_hz, levels_dbm, rbw_hz: self @@ -385,7 +410,7 @@ impl Worker { .map(|khz| (khz * 1_000.0).round() as u32), }) .is_ok(); - if published { + if published && target == PowerTraceTarget::Spectrum { let mut metrics = context .metrics .lock() @@ -469,6 +494,11 @@ impl Worker { Command::NoOp(reply) => { let _ = reply.send(Ok(())); } + Command::SetDirectSweep(config, reply) => { + let result = validate_direct_sweep(config, self.identity.model, self.basic_input) + .map(|()| self.direct_sweep = config); + let _ = reply.send(result); + } Command::Shutdown(reply) => { self.rx_context = None; let _ = reply.send(Ok(())); @@ -491,12 +521,15 @@ impl Worker { fn scan_once(&mut self) -> anyhow::Result { let (minimum_hz, maximum_hz) = frequency_range(self.identity.model, self.basic_input); - let (start_hz, stop_hz) = - centered_window(self.center_hz, self.span_hz, minimum_hz, maximum_hz); + let (start_hz, stop_hz) = self + .direct_sweep + .map(|config| (config.start_hz, config.stop_hz)) + .unwrap_or_else(|| { + centered_window(self.center_hz, self.span_hz, minimum_hz, maximum_hz) + }); let points = self.settings.points; - self.center_hz = window_center(start_hz, stop_hz); - self.span_hz = stop_hz - start_hz; - if self.span_hz < points as u64 { + let scan_span_hz = stop_hz - start_hz; + if scan_span_hz < points as u64 { bail!("tinySA scan span is too narrow for {points} points"); } let mut frequencies_hz = Vec::with_capacity(points as usize); @@ -525,12 +558,18 @@ impl Worker { } interrupted => return Ok(interrupted), } - let frequencies_hz = display_frequencies(&frequencies_hz)?; + let target = if self.direct_sweep.is_some() { + PowerTraceTarget::Sweep + } else { + PowerTraceTarget::Spectrum + }; + let frequencies_hz = trace_frequencies(frequencies_hz, target)?; + let (effective_center_hz, effective_span_hz) = effective_window(start_hz, stop_hz); Ok(ScanResult::Complete { frequencies_hz, levels_dbm, - effective_center_hz: self.center_hz, - effective_span_hz: self.span_hz, + effective_center_hz, + effective_span_hz, }) } } @@ -911,6 +950,7 @@ fn reject_command(command: Command, error: anyhow::Error) { | Command::Stop(reply) | Command::SetFrequency(_, reply) | Command::NoOp(reply) + | Command::SetDirectSweep(_, reply) | Command::Shutdown(reply) => { let _ = reply.send(Err(anyhow!(message))); } @@ -923,6 +963,28 @@ fn reject_command(command: Command, error: anyhow::Error) { } } +fn validate_direct_sweep( + config: Option, + model: Model, + basic_input: BasicInput, +) -> anyhow::Result<()> { + let Some(config) = config else { + return Ok(()); + }; + let (minimum_hz, maximum_hz) = frequency_range(model, basic_input); + if config.start_hz < minimum_hz + || config.stop_hz > maximum_hz + || config.start_hz >= config.stop_hz + { + bail!( + "tinySA sweep must be within {}..{} Hz with start below stop", + minimum_hz, + maximum_hz + ); + } + Ok(()) +} + fn capabilities(model: Model, basic_input: BasicInput) -> DeviceCapabilities { let (minimum_hz, maximum_hz) = frequency_range(model, basic_input); DeviceCapabilities { @@ -983,6 +1045,23 @@ fn window_center(start_hz: u64, stop_hz: u64) -> u64 { start_hz + (stop_hz - start_hz) / 2 } +fn effective_window(start_hz: u64, stop_hz: u64) -> (u64, u64) { + (window_center(start_hz, stop_hz), stop_hz - start_hz) +} + +fn update_normal_window( + direct_sweep: Option, + center_hz: &mut u64, + span_hz: &mut u64, + effective_center_hz: u64, + effective_span_hz: u64, +) { + if direct_sweep.is_none() { + *center_hz = effective_center_hz; + *span_hz = effective_span_hz; + } +} + fn normalize_span(hz: f64, maximum_hz: u64, points: u32) -> anyhow::Result { if !hz.is_finite() || hz <= 0.0 { bail!("tinySA span must be a positive finite value"); @@ -1016,6 +1095,19 @@ fn display_frequencies(measured_hz: &[u64]) -> anyhow::Result> { .collect()) } +fn trace_frequencies(measured_hz: Vec, target: PowerTraceTarget) -> anyhow::Result> { + if measured_hz.is_empty() { + bail!("tinySA scan returned no frequencies"); + } + if measured_hz.windows(2).any(|pair| pair[1] <= pair[0]) { + bail!("tinySA scan returned duplicate or descending frequencies"); + } + match target { + PowerTraceTarget::Spectrum => display_frequencies(&measured_hz), + PowerTraceTarget::Sweep => Ok(measured_hz), + } +} + fn startup_settings(model: Model) -> (ScanSettings, Vec<&'static str>) { let spur = if model.is_ultra() { SpurMode::Auto @@ -1280,6 +1372,22 @@ mod tests { assert!(display_frequencies(&[100_002, 100_001, 100_000]).is_err()); } + #[test] + fn native_sweeps_keep_raw_firmware_frequencies() { + let measured = protocol::scan_frequencies(100_000_000, 200_000_000, 450); + let sweep = + trace_frequencies(measured.clone(), PowerTraceTarget::Sweep).expect("sweep axis"); + let spectrum = + trace_frequencies(measured.clone(), PowerTraceTarget::Spectrum).expect("spectrum axis"); + + assert_eq!(sweep, measured); + assert_ne!(spectrum, measured); + for target in [PowerTraceTarget::Spectrum, PowerTraceTarget::Sweep] { + assert!(trace_frequencies(vec![100_000, 100_000, 100_001], target).is_err()); + assert!(trace_frequencies(vec![100_002, 100_001, 100_000], target).is_err()); + } + } + #[test] fn an_edge_shift_becomes_the_next_scan_center() { let (start, stop) = centered_window(100_000, 10_000_000, 100_000, 960_000_000); @@ -1291,6 +1399,98 @@ mod tests { ); } + #[test] + fn completed_windows_update_only_normal_tuning() { + let (start_hz, stop_hz) = + centered_window(100_000, 10_000_000, MIN_FREQUENCY_HZ, 960_000_000); + let mut center_hz = 100_000; + let mut span_hz = 10_000_000; + let (effective_center_hz, effective_span_hz) = effective_window(start_hz, stop_hz); + update_normal_window( + None, + &mut center_hz, + &mut span_hz, + effective_center_hz, + effective_span_hz, + ); + assert_eq!(center_hz, 5_100_000); + assert_eq!(span_hz, 10_000_000); + + update_normal_window( + Some(DirectSweepConfig { + start_hz: 88_000_000, + stop_hz: 108_000_000, + generation: 1, + }), + &mut center_hz, + &mut span_hz, + 98_000_000, + 20_000_000, + ); + assert_eq!(center_hz, 5_100_000); + assert_eq!(span_hz, 10_000_000); + } + + #[test] + fn direct_sweeps_accept_only_ordered_in_range_limits() { + let valid = DirectSweepConfig { + start_hz: 88_000_000, + stop_hz: 108_000_000, + generation: 7, + }; + assert!(validate_direct_sweep(Some(valid), Model::Basic, BasicInput::Low).is_ok()); + assert!(validate_direct_sweep(None, Model::Basic, BasicInput::Low).is_ok()); + assert!(validate_direct_sweep( + Some(DirectSweepConfig { + start_hz: valid.stop_hz, + stop_hz: valid.start_hz, + ..valid + }), + Model::Basic, + BasicInput::Low + ) + .is_err()); + assert!(validate_direct_sweep( + Some(DirectSweepConfig { + stop_hz: BASIC_LOW_MAX_HZ + 1, + ..valid + }), + Model::Basic, + BasicInput::Low + ) + .is_err()); + assert!(validate_direct_sweep( + Some(DirectSweepConfig { + start_hz: BASIC_HIGH_MIN_HZ, + stop_hz: BASIC_HIGH_MAX_HZ, + ..valid + }), + Model::Basic, + BasicInput::High + ) + .is_ok()); + assert!(validate_direct_sweep( + Some(DirectSweepConfig { + start_hz: MIN_FREQUENCY_HZ, + stop_hz: BASIC_HIGH_MAX_HZ, + ..valid + }), + Model::Basic, + BasicInput::High + ) + .is_err()); + assert!(validate_direct_sweep( + Some(DirectSweepConfig { + start_hz: MIN_FREQUENCY_HZ, + stop_hz: BASIC_HIGH_MAX_HZ, + ..valid + }), + Model::Basic, + BasicInput::Low + ) + .is_err()); + } + #[test] fn unknown_ultra_uses_the_conservative_zs405_range() { assert_eq!(Model::UltraUnknown.maximum_hz(), 6_000_000_000); @@ -1404,6 +1604,7 @@ mod tests { device.start_rx(context).unwrap(); let spectrum = power_rx.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(spectrum.target, PowerTraceTarget::Spectrum); assert_eq!(spectrum.frequencies_hz.len(), spectrum.levels_dbm.len()); assert!(!spectrum.frequencies_hz.is_empty()); device.stop_rx().unwrap(); diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index 46d093c2..5d31c534 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -40,14 +40,31 @@ impl LevelUnit { } } +/// Where a direct power trace should be published. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PowerTraceTarget { + Spectrum, + Sweep, +} + /// One calibrated power-spectrum trace. #[derive(Debug)] pub struct PowerTrace { + pub target: PowerTraceTarget, + pub generation: u64, pub frequencies_hz: Vec, pub levels_dbm: Vec, pub rbw_hz: Option, } +/// A native band sweep requested from a direct-power backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirectSweepConfig { + pub start_hz: u64, + pub stop_hz: u64, + pub generation: u64, +} + /// How raw USB bytes encode each I/Q component. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SampleFormat { @@ -943,6 +960,10 @@ pub trait SdrDevice: Send + Sync { Ok(()) } + fn set_direct_sweep(&self, _config: Option) -> anyhow::Result<()> { + anyhow::bail!("this backend does not support direct power sweeps") + } + /// Set one stage by position, exactly. /// /// **One path for every backend.** The default maps position onto the two diff --git a/src/signal/power.rs b/src/signal/power.rs index a3be84fe..93ee6e98 100644 --- a/src/signal/power.rs +++ b/src/signal/power.rs @@ -6,8 +6,8 @@ use std::time::{Duration, Instant}; use crossbeam_channel::Receiver; -use crate::hardware::PowerTrace; -use crate::state::{FftFrame, SdrMetrics}; +use crate::hardware::{PowerTrace, PowerTraceTarget}; +use crate::state::{FftFrame, SdrMetrics, SweepFrame}; const EMA_ALPHA: f32 = 0.2; const PEAK_DECAY_DB: f32 = 0.5; @@ -24,10 +24,15 @@ impl PowerWorker { } pub fn run(self) { - let mut accumulator = SpectrumAccumulator::default(); + let mut spectrum = SpectrumAccumulator::default(); + let mut sweep = SweepAccumulator::default(); let mut rejection_reporter = RejectionReporter::default(); while let Ok(trace) = self.trace_rx.recv() { - if let Err(reason) = accumulator.publish(&self.state, trace) { + let result = match trace.target { + PowerTraceTarget::Spectrum => spectrum.publish(&self.state, trace), + PowerTraceTarget::Sweep => sweep.push(&self.state, trace), + }; + if let Err(reason) = result { rejection_reporter.report(&self.state, reason, Instant::now()); } } @@ -39,6 +44,7 @@ enum TraceRejection { Empty, LengthMismatch, NonUniformGrid, + NonAscendingGrid, } impl TraceRejection { @@ -47,6 +53,7 @@ impl TraceRejection { Self::Empty => "empty trace", Self::LengthMismatch => "frequency and level counts differ", Self::NonUniformGrid => "frequency grid is not uniform and ascending", + Self::NonAscendingGrid => "frequency grid is not strictly ascending", } } } @@ -167,6 +174,167 @@ impl SpectrumAccumulator { } } +#[derive(Default)] +struct SweepAccumulator { + generation: u64, + start_hz: u64, + stop_hz: u64, + frequencies_hz: Vec, + sums: Vec, + samples: Vec, + peak: Vec, + traces: u32, + started: Option, +} + +impl SweepAccumulator { + fn push( + &mut self, + state: &Arc>, + trace: PowerTrace, + ) -> Result<(), TraceRejection> { + let requested = { + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + ( + metrics.sweep.active, + metrics.sweep.generation, + metrics.sweep.config.start_hz, + metrics.sweep.config.stop_hz, + metrics.sweep.config.dwell_ms, + ) + }; + if !requested.0 { + self.reset(); + return Ok(()); + } + if trace.generation != requested.1 { + return Ok(()); + } + if trace.frequencies_hz.is_empty() { + return Err(TraceRejection::Empty); + } + if trace.frequencies_hz.len() != trace.levels_dbm.len() { + return Err(TraceRejection::LengthMismatch); + } + if trace + .frequencies_hz + .windows(2) + .any(|pair| pair[1] <= pair[0]) + { + return Err(TraceRejection::NonAscendingGrid); + } + if !trace_covers_requested_range(&trace.frequencies_hz, requested.2, requested.3) { + return Ok(()); + } + + let changed = self.generation != trace.generation + || self.start_hz != requested.2 + || self.stop_hz != requested.3 + || self.frequencies_hz != trace.frequencies_hz; + if changed { + self.generation = trace.generation; + self.start_hz = requested.2; + self.stop_hz = requested.3; + self.frequencies_hz.clone_from(&trace.frequencies_hz); + self.sums = vec![0.0; trace.levels_dbm.len()]; + self.samples = vec![0; trace.levels_dbm.len()]; + self.peak = vec![f32::NEG_INFINITY; trace.levels_dbm.len()]; + self.traces = 0; + self.started = Some(Instant::now()); + } + + for (((sum, samples), peak), level) in self + .sums + .iter_mut() + .zip(self.samples.iter_mut()) + .zip(self.peak.iter_mut()) + .zip(trace.levels_dbm.iter().copied()) + { + if level.is_finite() { + *sum += level as f64; + *samples += 1; + *peak = peak.max(level); + } + } + self.traces += 1; + + let elapsed = self + .started + .map(|started| started.elapsed()) + .unwrap_or_default(); + { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.sweep.positions_total = self.frequencies_hz.len(); + metrics.sweep.positions_done = self.frequencies_hz.len(); + metrics.sweep.current_hz = *self.frequencies_hz.last().unwrap_or(&self.start_hz); + } + if elapsed.as_millis() < requested.4 as u128 { + return Ok(()); + } + + let mean = self + .sums + .iter() + .zip(&self.samples) + .map(|(sum, samples)| { + if *samples == 0 { + f32::NEG_INFINITY + } else { + (sum / *samples as f64) as f32 + } + }) + .collect(); + let duration_ms = elapsed.as_millis() as u64; + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + if !metrics.sweep.active + || metrics.sweep.generation != self.generation + || metrics.sweep.config.start_hz != self.start_hz + || metrics.sweep.config.stop_hz != self.stop_hz + { + return Ok(()); + } + metrics.sweep.cycle_count += 1; + metrics.sweep.cycle_duration_ms = duration_ms; + metrics.sweep.current_frame = Some(Arc::new(SweepFrame { + start_hz: self.start_hz, + stop_hz: self.stop_hz, + freq_hz: self.frequencies_hz.clone(), + peak_dbfs: self.peak.clone(), + mean_dbfs: mean, + timestamp: Instant::now(), + cycle_count: metrics.sweep.cycle_count, + cycle_duration_ms: duration_ms, + })); + drop(metrics); + + self.sums.fill(0.0); + self.samples.fill(0); + self.peak.fill(f32::NEG_INFINITY); + self.traces = 0; + self.started = Some(Instant::now()); + Ok(()) + } + + fn reset(&mut self) { + *self = Self::default(); + } +} + +fn trace_covers_requested_range(frequencies_hz: &[u64], start_hz: u64, stop_hz: u64) -> bool { + let Some((&first, rest)) = frequencies_hz.split_first() else { + return false; + }; + let Some(&last) = rest.last() else { + return false; + }; + if first != start_hz || last > stop_hz { + return false; + } + let intervals = frequencies_hz.len().saturating_sub(1) as u64; + let point_spacing = last.saturating_sub(first) / intervals; + point_spacing > 0 && stop_hz.saturating_sub(last) <= point_spacing +} + pub(crate) fn trace_window(frequencies_hz: &[u64]) -> Option<(u64, f64)> { let start = *frequencies_hz.first()?; let stop = *frequencies_hz.last()?; @@ -199,6 +367,8 @@ mod tests { let worker = std::thread::spawn(move || PowerWorker::new(rx, worker_state).run()); tx.send(PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000, 102_000_000], levels_dbm: vec![-90.0, -45.0, -80.0], rbw_hz: Some(10_000), @@ -246,6 +416,8 @@ mod tests { .publish( &state, PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000], levels_dbm: levels.to_vec(), rbw_hz: None, @@ -273,6 +445,8 @@ mod tests { .publish( &state, PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000], levels_dbm: vec![f32::NAN, -90.0], rbw_hz: None, @@ -283,6 +457,8 @@ mod tests { .publish( &state, PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000], levels_dbm: vec![-70.0, -80.0], rbw_hz: None, @@ -305,6 +481,8 @@ mod tests { .publish( &state, PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000], levels_dbm: vec![f32::NAN, f32::INFINITY], rbw_hz: None, @@ -331,6 +509,8 @@ mod tests { .publish( &state, PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100_000_000, 101_000_000], levels_dbm: vec![levels; 2], rbw_hz, @@ -377,6 +557,8 @@ mod tests { let cases = [ ( PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![], levels_dbm: vec![], rbw_hz: None, @@ -385,6 +567,8 @@ mod tests { ), ( PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100, 200], levels_dbm: vec![-90.0], rbw_hz: None, @@ -393,6 +577,8 @@ mod tests { ), ( PowerTrace { + target: PowerTraceTarget::Spectrum, + generation: 0, frequencies_hz: vec![100, 200, 350], levels_dbm: vec![-90.0; 3], rbw_hz: None, @@ -419,4 +605,120 @@ mod tests { Some((150_000, 100_000.0)) ); } + + fn sweep_trace(generation: u64, levels_dbm: Vec) -> PowerTrace { + PowerTrace { + target: PowerTraceTarget::Sweep, + generation, + frequencies_hz: vec![100_000_000, 100_500_000, 101_000_000], + levels_dbm, + rbw_hz: None, + } + } + + fn sweeping_state(generation: u64) -> Arc> { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + { + let mut metrics = state.lock().unwrap(); + metrics.sweep.active = true; + metrics.sweep.generation = generation; + metrics.sweep.config.start_hz = 100_000_000; + metrics.sweep.config.stop_hz = 101_000_000; + metrics.sweep.config.dwell_ms = 10_000; + } + state + } + + #[test] + fn sweep_accumulation_publishes_measured_peak_and_mean() { + let state = sweeping_state(4); + let mut accumulator = SweepAccumulator::default(); + accumulator + .push(&state, sweep_trace(4, vec![-80.0, f32::NAN, -60.0])) + .unwrap(); + accumulator.started = Some(Instant::now() - std::time::Duration::from_secs(11)); + accumulator + .push(&state, sweep_trace(4, vec![-70.0, -50.0, -90.0])) + .unwrap(); + + let metrics = state.lock().unwrap(); + let frame = metrics.sweep.current_frame.as_ref().unwrap(); + assert_eq!(frame.peak_dbfs, [-70.0, -50.0, -60.0]); + assert_eq!(frame.mean_dbfs, [-75.0, -50.0, -75.0]); + assert_eq!(frame.freq_hz, [100_000_000, 100_500_000, 101_000_000]); + assert_eq!(metrics.sweep.positions_total, 3); + assert_eq!(metrics.sweep.positions_done, 3); + } + + #[test] + fn sweep_generations_isolate_queued_traces() { + let state = sweeping_state(8); + let mut accumulator = SweepAccumulator::default(); + accumulator + .push(&state, sweep_trace(7, vec![-10.0, -10.0, -10.0])) + .unwrap(); + assert_eq!(accumulator.traces, 0); + + accumulator + .push(&state, sweep_trace(8, vec![-80.0, -70.0, -60.0])) + .unwrap(); + assert_eq!(accumulator.traces, 1); + state.lock().unwrap().sweep.generation = 9; + accumulator + .push(&state, sweep_trace(8, vec![-20.0, -20.0, -20.0])) + .unwrap(); + assert_eq!(accumulator.traces, 1); + + accumulator + .push(&state, sweep_trace(9, vec![-90.0, -80.0, -70.0])) + .unwrap(); + assert_eq!(accumulator.generation, 9); + assert_eq!(accumulator.traces, 1); + assert_eq!(accumulator.sums, [-90.0, -80.0, -70.0]); + } + + #[test] + fn stale_sweep_traces_are_discarded_before_shape_validation() { + let state = sweeping_state(8); + let mut accumulator = SweepAccumulator::default(); + let malformed = PowerTrace { + target: PowerTraceTarget::Sweep, + generation: 7, + frequencies_hz: vec![], + levels_dbm: vec![-80.0], + rbw_hz: None, + }; + assert_eq!(accumulator.push(&state, malformed), Ok(())); + + let mut matching = sweep_trace(8, vec![-80.0, -70.0, -60.0]); + matching.frequencies_hz = vec![100_000_000, 100_000_000, 101_000_000]; + assert_eq!( + accumulator.push(&state, matching), + Err(TraceRejection::NonAscendingGrid) + ); + + state.lock().unwrap().sweep.active = false; + let inactive = PowerTrace { + target: PowerTraceTarget::Sweep, + generation: 8, + frequencies_hz: vec![], + levels_dbm: vec![-80.0], + rbw_hz: None, + }; + assert_eq!(accumulator.push(&state, inactive), Ok(())); + } + + #[test] + fn a_trace_from_a_previous_range_is_not_relabelled() { + assert!(trace_covers_requested_range( + &[100_000_000, 100_499_999, 100_999_998], + 100_000_000, + 101_000_000, + )); + assert!(!trace_covers_requested_range( + &[100_000_000, 100_500_000, 101_000_000], + 90_000_000, + 110_000_000, + )); + } } diff --git a/src/state/sweep.rs b/src/state/sweep.rs index 6c6a2630..2034ccb1 100644 --- a/src/state/sweep.rs +++ b/src/state/sweep.rs @@ -79,6 +79,17 @@ pub struct SweepFrame { } impl SweepFrame { + pub fn point_spacing_hz(&self) -> Option { + let first = *self.freq_hz.first()?; + let last = *self.freq_hz.last()?; + let intervals = self + .freq_hz + .len() + .checked_sub(1) + .filter(|count| *count > 0)? as u64; + last.checked_sub(first).map(|span| span / intervals) + } + /// Project the stitched curve onto `width` horizontal buckets: each bucket /// holds the maximum dBFS of the bins that fall in it (peak or mean per /// `peak`). Empty buckets read `f32::NEG_INFINITY`. @@ -154,6 +165,7 @@ pub struct SweepState { pub positions_done: usize, pub cycle_count: u64, pub cycle_duration_ms: u64, + pub generation: u64, /// Render the peak curve (`true`) or the mean curve (`false`); toggled by `[M]`. pub show_peak: bool, /// Cursor position as a 0..1 fraction across the band, set in the panel's @@ -197,6 +209,7 @@ impl Default for SweepState { positions_done: 0, cycle_count: 0, cycle_duration_ms: 0, + generation: 0, show_peak: true, cursor_frac: None, pending_tune: None, @@ -345,6 +358,15 @@ mod tests { assert_eq!(f.freq_at_fraction(-1.0), 400_000_000); } + #[test] + fn point_spacing_uses_the_measured_trace() { + let mut f = frame(); + f.freq_hz = vec![400_000_000, 400_333_333, 400_666_667, 401_000_000]; + assert_eq!(f.point_spacing_hz(), Some(333_333)); + f.freq_hz.truncate(1); + assert_eq!(f.point_spacing_hz(), None); + } + /// A sweep in progress, parked on the position 12 MHz into the band, having /// interrupted a radio tuned to 145.5 MHz. fn mid_sweep() -> SweepState { diff --git a/src/tasks/sweep.rs b/src/tasks/sweep.rs index c8be924c..75246e4e 100644 --- a/src/tasks/sweep.rs +++ b/src/tasks/sweep.rs @@ -14,15 +14,18 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use crate::hardware::SdrDevice; +use crate::hardware::{DirectSweepConfig, SdrDevice}; use crate::state::{SdrMetrics, SweepFrame, SWEEP_SETTLING_MS}; -/// How often the dwell loop samples the shared FFT frame. const DWELL_POLL_MS: u64 = 10; -/// A frame older than this is treated as stale and skipped. const FRAME_FRESH_MS: u128 = 200; +const DIRECT_SWEEP_RETRY: Duration = Duration::from_secs(1); pub fn spawn_sweep_task(state: Arc>, device: Arc) { + if device.capabilities().acquisition == crate::hardware::AcquisitionKind::PowerTrace { + spawn_direct_sweep_task(state, device); + return; + } tokio::spawn(async move { let mut was_active = false; let mut saved_rx_enabled = false; @@ -39,14 +42,9 @@ pub fn spawn_sweep_task(state: Arc>, device: Arc>, device: Arc>, device: Arc>, device: Arc = Vec::new(); let mut peak: Vec = Vec::new(); @@ -108,8 +98,6 @@ pub fn spawn_sweep_task(state: Arc>, device: Arc>, device: Arc = Vec::new(); let mut pos_mean_sum: Vec = Vec::new(); @@ -160,7 +147,6 @@ pub fn spawn_sweep_task(state: Arc>, device: Arc>, device: Arc) -> anyhow::Result<()>; + fn set_frequency(&self, hz: u64) -> anyhow::Result<()>; +} + +impl DirectSweepControl for dyn SdrDevice { + fn set_direct_sweep(&self, config: Option) -> anyhow::Result<()> { + SdrDevice::set_direct_sweep(self, config) + } + + fn set_frequency(&self, hz: u64) -> anyhow::Result<()> { + SdrDevice::set_frequency(self, hz) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SweepRequest { + start_hz: u64, + stop_hz: u64, +} + +struct FailedRequest { + config: DirectSweepConfig, + at: Instant, + message: String, +} + +struct PendingExit { + tune_hz: u64, + jumped: bool, + direct_disabled: bool, + retry_at: Instant, + last_error: Option, +} + +#[derive(Default)] +struct DirectSweepController { + was_active: bool, + requested: Option, + applied: Option, + failed: Option, + request_generation: u64, + saved_frequency: u64, + saved_rx_enabled: bool, + pending_exit: Option, +} + +impl DirectSweepController { + fn update( + &mut self, + state: &Arc>, + device: &C, + now: Instant, + ) { + let (active, config) = { + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + (metrics.sweep.active, metrics.sweep.config.clone()) + }; + + if self.pending_exit.is_some() { + self.leave(state, device, now); + if self.pending_exit.is_some() || !active { + return; + } + } + + if active && !self.was_active { + self.was_active = true; + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + self.saved_frequency = metrics.radio.frequency; + self.saved_rx_enabled = metrics.radio.rx_enabled; + metrics.sweep.pre_sweep_hz = Some(self.saved_frequency); + metrics.radio.rx_enabled = true; + metrics.sweep.cycle_count = 0; + metrics.sweep.positions_done = 0; + metrics.sweep.positions_total = 0; + metrics.sweep.current_frame = None; + metrics.sweep.generation = metrics.sweep.generation.wrapping_add(1); + metrics.push_log(format!( + "Sweep started: {:.1}\u{2013}{:.1} MHz", + config.start_hz as f64 / 1e6, + config.stop_hz as f64 / 1e6 + )); + } + + if active { + self.apply_request(state, device, now, config); + } else if self.was_active { + self.leave(state, device, now); + } + } + + fn apply_request( + &mut self, + state: &Arc>, + device: &C, + now: Instant, + config: crate::state::SweepConfig, + ) { + let request = SweepRequest { + start_hz: config.start_hz, + stop_hz: config.stop_hz, + }; + let generation = { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + if self.requested.is_some() + && self.requested != Some(request) + && metrics.sweep.generation == self.request_generation + { + metrics.sweep.generation = metrics.sweep.generation.wrapping_add(1); + } + self.requested = Some(request); + self.request_generation = metrics.sweep.generation; + metrics.sweep.generation + }; + let requested = DirectSweepConfig { + start_hz: request.start_hz, + stop_hz: request.stop_hz, + generation, + }; + let retry_due = self.failed.as_ref().is_none_or(|failed| { + failed.config != requested + || now.saturating_duration_since(failed.at) >= DIRECT_SWEEP_RETRY + }); + if self.applied == Some(requested) || !retry_due { + return; + } + match device.set_direct_sweep(Some(requested)) { + Ok(()) => { + self.applied = Some(requested); + self.failed = None; + } + Err(error) => { + let message = error.to_string(); + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + if self + .failed + .as_ref() + .is_none_or(|failed| failed.config != requested || failed.message != message) + { + metrics.push_log(format!("Sweep error: {message}")); + } + self.failed = Some(FailedRequest { + config: requested, + at: now, + message, + }); + } + } + } + + fn leave( + &mut self, + state: &Arc>, + device: &C, + now: Instant, + ) { + if self.pending_exit.is_none() { + let exit = { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let exit = metrics.sweep.end(self.saved_frequency); + metrics.sweep.generation = metrics.sweep.generation.wrapping_add(1); + exit + }; + self.pending_exit = Some(PendingExit { + tune_hz: exit.tune_hz, + jumped: exit.jumped, + direct_disabled: false, + retry_at: now, + last_error: None, + }); + } + + let pending = self.pending_exit.as_mut().unwrap(); + if now < pending.retry_at { + return; + } + if !pending.direct_disabled { + match device.set_direct_sweep(None) { + Ok(()) => pending.direct_disabled = true, + Err(error) => { + record_exit_error(state, pending, now, error); + return; + } + } + } + if let Err(error) = device.set_frequency(pending.tune_hz) { + record_exit_error(state, pending, now, error); + return; + } + + let tune_hz = pending.tune_hz; + let jumped = pending.jumped; + self.pending_exit = None; + self.was_active = false; + self.requested = None; + self.applied = None; + self.failed = None; + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.frequency = tune_hz; + metrics.radio.rx_enabled = if jumped { true } else { self.saved_rx_enabled }; + metrics.push_log(if jumped { + format!("Tuned to {:.3} MHz from sweep", tune_hz as f64 / 1e6) + } else { + "Sweep stopped".to_string() + }); + } +} + +fn record_exit_error( + state: &Arc>, + pending: &mut PendingExit, + now: Instant, + error: anyhow::Error, +) { + let message = error.to_string(); + if pending.last_error.as_deref() != Some(&message) { + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push_log(format!("Sweep restore error: {message}")); + } + pending.last_error = Some(message); + pending.retry_at = now + DIRECT_SWEEP_RETRY; +} + +fn spawn_direct_sweep_task(state: Arc>, device: Arc) { + tokio::spawn(async move { + let mut controller = DirectSweepController::default(); + loop { + controller.update(&state, device.as_ref(), Instant::now()); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); +} + +#[cfg(test)] +mod direct_tests { + use super::*; + + #[derive(Default)] + struct TestDevice { + requests: Mutex>>, + frequencies: Mutex>, + failures: Mutex, + frequency_failures: Mutex, + } + + impl DirectSweepControl for TestDevice { + fn set_direct_sweep(&self, config: Option) -> anyhow::Result<()> { + self.requests.lock().unwrap().push(config); + let mut failures = self.failures.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + anyhow::bail!("temporary sweep failure"); + } + Ok(()) + } + + fn set_frequency(&self, hz: u64) -> anyhow::Result<()> { + self.frequencies.lock().unwrap().push(hz); + let mut failures = self.frequency_failures.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + anyhow::bail!("temporary frequency failure"); + } + Ok(()) + } + } + + fn active_state(rx_enabled: bool) -> Arc> { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + { + let mut metrics = state.lock().unwrap(); + metrics.radio.frequency = 145_500_000; + metrics.radio.rx_enabled = rx_enabled; + metrics.sweep.active = true; + metrics.sweep.config.start_hz = 88_000_000; + metrics.sweep.config.stop_hz = 108_000_000; + metrics.sweep.config.dwell_ms = 200; + } + state + } + + #[test] + fn requests_follow_session_and_range_transitions() { + let state = active_state(false); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + controller.update(&state, &device, now); + + { + let mut metrics = state.lock().unwrap(); + metrics.sweep.config.start_hz = 400_000_000; + metrics.sweep.config.stop_hz = 500_000_000; + } + controller.update(&state, &device, now); + state.lock().unwrap().sweep.active = false; + controller.update(&state, &device, now); + + let requests = device.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].unwrap().generation, 1); + assert_eq!(requests[1].unwrap().generation, 2); + assert_eq!(requests[1].unwrap().start_hz, 400_000_000); + assert_eq!(requests[2], None); + assert_eq!(state.lock().unwrap().sweep.generation, 3); + } + + #[test] + fn failures_retry_after_the_existing_delay() { + let state = active_state(false); + let device = TestDevice::default(); + *device.failures.lock().unwrap() = 1; + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + + controller.update(&state, &device, now); + controller.update(&state, &device, now + Duration::from_millis(999)); + assert_eq!(device.requests.lock().unwrap().len(), 1); + controller.update(&state, &device, now + DIRECT_SWEEP_RETRY); + assert_eq!(device.requests.lock().unwrap().len(), 2); + } + + #[test] + fn dwell_changes_do_not_restart_the_device_sweep() { + let state = active_state(false); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + controller.update(&state, &device, now); + + state.lock().unwrap().sweep.config.dwell_ms += 50; + controller.update(&state, &device, now); + assert_eq!(device.requests.lock().unwrap().len(), 1); + assert_eq!(state.lock().unwrap().sweep.generation, 1); + } + + #[test] + fn a_new_session_clears_the_previous_frame() { + let state = Arc::new(Mutex::new( + SdrMetrics::fixture() + .streaming() + .with_sweep(88_000_000, 108_000_000), + )); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + controller.update(&state, &device, Instant::now()); + + assert!(state.lock().unwrap().sweep.current_frame.is_none()); + } + + #[test] + fn pause_state_is_preserved() { + for started_enabled in [false, true] { + let state = active_state(started_enabled); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + + controller.update(&state, &device, now); + assert!(state.lock().unwrap().radio.rx_enabled); + state.lock().unwrap().radio.rx_enabled = false; + controller.update(&state, &device, now); + assert!(!state.lock().unwrap().radio.rx_enabled); + state.lock().unwrap().sweep.active = false; + controller.update(&state, &device, now); + assert_eq!(state.lock().unwrap().radio.rx_enabled, started_enabled); + } + } + + #[test] + fn tuner_restoration_keeps_exact_jumps() { + let state = active_state(false); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + controller.update(&state, &device, now); + + { + let mut metrics = state.lock().unwrap(); + metrics.sweep.pending_tune = Some(433_920_001); + metrics.sweep.active = false; + } + controller.update(&state, &device, now); + assert_eq!(device.frequencies.lock().unwrap().as_slice(), [433_920_001]); + let metrics = state.lock().unwrap(); + assert_eq!(metrics.radio.frequency, 433_920_001); + assert!(metrics.radio.rx_enabled); + assert_eq!(metrics.sweep.pre_sweep_hz, None); + } + + #[test] + fn reactivation_finishes_a_pending_exit_before_restarting() { + let state = active_state(false); + let device = TestDevice::default(); + let mut controller = DirectSweepController::default(); + let now = Instant::now(); + controller.update(&state, &device, now); + + *device.frequency_failures.lock().unwrap() = 1; + state.lock().unwrap().sweep.active = false; + controller.update(&state, &device, now); + assert!(controller + .pending_exit + .as_ref() + .is_some_and(|pending| pending.direct_disabled)); + + state.lock().unwrap().sweep.active = true; + controller.update(&state, &device, now + DIRECT_SWEEP_RETRY); + assert!(controller.pending_exit.is_none()); + assert!(controller.was_active); + let requests = device.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests[0].is_some()); + assert_eq!(requests[1], None); + assert!(requests[2].is_some()); + } +} diff --git a/src/ui/panels/lab/bars.rs b/src/ui/panels/lab/bars.rs index 86c4c465..a413de21 100644 --- a/src/ui/panels/lab/bars.rs +++ b/src/ui/panels/lab/bars.rs @@ -983,6 +983,9 @@ impl Panel for LabBannerPanel { fn name(&self) -> &'static str { "lab_banner" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (20, 1) } diff --git a/src/ui/panels/lab/sweep_panel/axes.rs b/src/ui/panels/lab/sweep_panel/axes.rs index 91358dad..5b761d1c 100644 --- a/src/ui/panels/lab/sweep_panel/axes.rs +++ b/src/ui/panels/lab/sweep_panel/axes.rs @@ -15,22 +15,22 @@ use ratatui::{ Frame, }; -use super::scale::{AXIS_W, Y_MAX, Y_MIN}; +use super::scale::AXIS_W; /// Below this many rows there is no room for a middle label without it crowding /// the top or bottom one. const MID_LABEL_MIN_H: usize = 5; /// The gutter column, top to bottom: `Y_MAX`, optionally the midpoint, `Y_MIN`. -pub(super) fn gutter_labels(plot_h: usize) -> Vec { +pub(super) fn gutter_labels(plot_h: usize, y_min: f32, y_max: f32) -> Vec { (0..plot_h) .map(|r| { if r == 0 { - format!("{:>4} ", Y_MAX as i32) + format!("{:>4} ", y_max as i32) } else if r == plot_h - 1 { - format!("{:>4} ", Y_MIN as i32) + format!("{:>4} ", y_min as i32) } else if plot_h >= MID_LABEL_MIN_H && r == plot_h / 2 { - format!("{:>4} ", ((Y_MAX + Y_MIN) / 2.0) as i32) + format!("{:>4} ", ((y_max + y_min) / 2.0) as i32) } else { " ".repeat(AXIS_W as usize) } @@ -54,8 +54,15 @@ pub(super) fn frequency_row(start_hz: u64, stop_hz: u64, plot_w: usize) -> Strin ) } -pub(super) fn draw_gutter(f: &mut Frame, area: Rect, plot_h: usize, theme: &crate::Theme) { - let lines: Vec = gutter_labels(plot_h) +pub(super) fn draw_gutter( + f: &mut Frame, + area: Rect, + plot_h: usize, + y_min: f32, + y_max: f32, + theme: &crate::Theme, +) { + let lines: Vec = gutter_labels(plot_h, y_min, y_max) .into_iter() .map(|s| Line::from(Span::styled(s, Style::default().fg(theme.label)))) .collect(); @@ -82,10 +89,12 @@ pub(super) fn draw_frequency( #[cfg(test)] mod tests { use super::*; + const Y_MIN: f32 = -100.0; + const Y_MAX: f32 = 0.0; #[test] fn the_gutter_labels_the_ends_of_the_window() { - let g = gutter_labels(9); + let g = gutter_labels(9, Y_MIN, Y_MAX); assert_eq!(g.len(), 9); assert!(g[0].trim() == "0", "top should be Y_MAX: {:?}", g[0]); assert!(g[8].trim() == "-100", "bottom should be Y_MIN: {:?}", g[8]); @@ -102,7 +111,7 @@ mod tests { /// four rows. #[test] fn a_short_gutter_keeps_only_the_two_ends() { - let g = gutter_labels(4); + let g = gutter_labels(4, Y_MIN, Y_MAX); assert_eq!(g.len(), 4); assert_eq!(g[0].trim(), "0"); assert_eq!(g[3].trim(), "-100"); @@ -111,11 +120,11 @@ mod tests { #[test] fn a_one_row_gutter_does_not_panic() { - let g = gutter_labels(1); + let g = gutter_labels(1, Y_MIN, Y_MAX); assert_eq!(g.len(), 1); // Row 0 is both the first and the last; the first branch wins. assert_eq!(g[0].trim(), "0"); - assert!(gutter_labels(0).is_empty()); + assert!(gutter_labels(0, Y_MIN, Y_MAX).is_empty()); } /// The frequency row spans the swept band and starts past the gutter, so the diff --git a/src/ui/panels/lab/sweep_panel/envelope.rs b/src/ui/panels/lab/sweep_panel/envelope.rs index 5b8b9f95..bd7ffeb3 100644 --- a/src/ui/panels/lab/sweep_panel/envelope.rs +++ b/src/ui/panels/lab/sweep_panel/envelope.rs @@ -10,8 +10,6 @@ use crate::state::SweepFrame; -use super::scale::{Y_MAX, Y_MIN}; - /// Horizontal buckets per character cell. /// /// Two, because the canvas draws in braille and a single-bin peak projected at @@ -22,21 +20,27 @@ const BUCKETS_PER_CELL: usize = 2; pub(super) struct Envelope { /// As projected: `-inf` where the sweep has no data. pub raw: Vec, - /// Clamped into the dBFS window, ready to draw. + /// Clamped into the level window, ready to draw. pub body: Vec, } impl Envelope { - pub(super) fn project(frame: &SweepFrame, plot_w: usize, show_peak: bool) -> Self { + pub(super) fn project( + frame: &SweepFrame, + plot_w: usize, + show_peak: bool, + y_min: f32, + y_max: f32, + ) -> Self { let n = (plot_w * BUCKETS_PER_CELL).max(2); let raw = frame.project(n, show_peak); let body = raw .iter() .map(|&v| { if v.is_finite() { - v.clamp(Y_MIN, Y_MAX) + v.clamp(y_min, y_max) } else { - Y_MIN + y_min } }) .collect(); @@ -57,6 +61,12 @@ impl Envelope { mod tests { use super::*; use std::time::Instant; + const Y_MIN: f32 = -100.0; + const Y_MAX: f32 = 0.0; + + fn project(frame: &SweepFrame, width: usize, peak: bool) -> Envelope { + Envelope::project(frame, width, peak, Y_MIN, Y_MAX) + } fn frame(peaks: &[f32]) -> SweepFrame { let n = peaks.len() as u64; @@ -74,7 +84,7 @@ mod tests { #[test] fn the_plot_is_projected_at_two_buckets_per_cell() { - let e = Envelope::project(&frame(&[-50.0; 8]), 20, true); + let e = project(&frame(&[-50.0; 8]), 20, true); assert_eq!(e.len(), 40); } @@ -82,7 +92,7 @@ mod tests { /// than an empty one that panics on `len() - 1`. #[test] fn a_zero_width_plot_still_has_two_buckets() { - assert_eq!(Envelope::project(&frame(&[-50.0]), 0, true).len(), 2); + assert_eq!(project(&frame(&[-50.0]), 0, true).len(), 2); } /// An unvisited bucket must stay unknown in `raw` and be floored in `body`. @@ -93,7 +103,7 @@ mod tests { // Only two positions across a band wide enough for many buckets. let mut f = frame(&[-30.0, -40.0]); f.stop_hz = f.start_hz + 100_000_000; - let e = Envelope::project(&f, 20, true); + let e = project(&f, 20, true); let empty = e .raw .iter() @@ -107,7 +117,7 @@ mod tests { /// Levels above the window are clamped, not drawn off the top of the canvas. #[test] fn levels_outside_the_window_are_clamped_for_drawing() { - let e = Envelope::project(&frame(&[20.0, -250.0]), 4, true); + let e = project(&frame(&[20.0, -250.0]), 4, true); assert!(e.body.iter().all(|&v| (Y_MIN..=Y_MAX).contains(&v))); // โ€ฆbut `raw` keeps what was measured, so the readout is honest. assert!(e.raw.iter().any(|&v| v > Y_MAX)); @@ -116,8 +126,8 @@ mod tests { #[test] fn peak_and_mean_select_different_curves() { let f = frame(&[-30.0, -30.0, -30.0, -30.0]); - let peak = Envelope::project(&f, 4, true); - let mean = Envelope::project(&f, 4, false); + let peak = project(&f, 4, true); + let mean = project(&f, 4, false); let hi = peak.raw.iter().cloned().fold(f32::MIN, f32::max); let lo = mean.raw.iter().cloned().fold(f32::MIN, f32::max); assert!(hi > lo, "peak {hi} should sit above mean {lo}"); diff --git a/src/ui/panels/lab/sweep_panel/mod.rs b/src/ui/panels/lab/sweep_panel/mod.rs index 6c126bdb..5d5ce146 100644 --- a/src/ui/panels/lab/sweep_panel/mod.rs +++ b/src/ui/panels/lab/sweep_panel/mod.rs @@ -4,7 +4,7 @@ //! `sweep_panel` - the frequency-scanner display for the `lab_sweep` preset. //! //! The latest completed `SweepFrame` as a braille envelope over the swept band, -//! with a dBFS gutter, a frequency axis, a band-plan row and a status line. The +//! with a level gutter, a frequency axis, a band-plan row and a status line. The //! cursor and the peak/mean toggle come from the panel's focus mode. //! //! Split the way `panels/core/spectrum/` is, because it is the same kind of @@ -48,6 +48,9 @@ impl Panel for SweepPanel { fn name(&self) -> &'static str { "sweep_panel" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 10) } @@ -70,7 +73,15 @@ impl Panel for SweepPanel { // brackets. The scan parameters ride along as the suffix, rebuilt every // frame so the band, dwell and cycle number stay live. let sw = &state.sweep; - let step_mhz = sw.config.effective_step_hz(state.radio.config_sample_rate) as f64 / 1e6; + let step_hz = if state.caps.acquisition == crate::hardware::AcquisitionKind::PowerTrace { + sw.current_frame + .as_ref() + .and_then(|frame| frame.point_spacing_hz()) + .unwrap_or(0) + } else { + sw.config.effective_step_hz(state.radio.config_sample_rate) + }; + let step_mhz = step_hz as f64 / 1e6; PanelChrome::new("Sweep").suffix(format!( " {:.1}\u{2013}{:.1} MHz \u{00b7} step {:.1} MHz \u{00b7} dwell {} ms \u{00b7} cycle #{}", sw.config.start_hz as f64 / 1e6, @@ -138,18 +149,26 @@ impl Panel for SweepPanel { return; } - let env = Envelope::project(frame, plot_w, sw.show_peak); + let (y_min, y_max) = + if state.caps.acquisition == crate::hardware::AcquisitionKind::PowerTrace { + (state.spectrum.y_min, state.spectrum.y_max) + } else { + (scale::Y_MIN, scale::Y_MAX) + }; + let env = Envelope::project(frame, plot_w, sw.show_peak, y_min, y_max); let cursor = sw .cursor_frac .map(|frac| (scale::cursor_x(frac, env.len()), theme.value_hi)); - axes::draw_gutter(f, gutter, plot_h, theme); + axes::draw_gutter(f, gutter, plot_h, y_min, y_max, theme); trace::draw( f, canvas, env.body.clone(), - Gradient::new(plot_h, theme), + Gradient::new(plot_h, y_min, y_max, theme), cursor, + y_min, + y_max, ); axes::draw_frequency(f, rows[1], frame.start_hz, frame.stop_hz, plot_w, theme); f.render_widget( @@ -256,4 +275,22 @@ mod tests { "an empty sweep drew filled cells:\n{out}" ); } + + #[test] + fn a_power_sweep_uses_dbm_and_measured_point_spacing() { + let mut metrics = swept(); + let mut caps = (*metrics.caps).clone(); + caps.acquisition = crate::hardware::AcquisitionKind::PowerTrace; + caps.level_unit = crate::hardware::LevelUnit::Dbm; + metrics.caps = std::sync::Arc::new(caps); + metrics.spectrum.y_min = -120.0; + metrics.spectrum.y_max = 20.0; + metrics.sweep.cursor_frac = Some(0.5); + + let out = draw(SweepPanel, 100, 16, &metrics).join("\n"); + assert!(out.contains("step 0.3 MHz"), "{out}"); + assert!(out.contains("dBm"), "{out}"); + assert!(out.contains("-120"), "{out}"); + assert!(out.contains(" 20"), "{out}"); + } } diff --git a/src/ui/panels/lab/sweep_panel/scale.rs b/src/ui/panels/lab/sweep_panel/scale.rs index 7f1769e6..06985af8 100644 --- a/src/ui/panels/lab/sweep_panel/scale.rs +++ b/src/ui/panels/lab/sweep_panel/scale.rs @@ -12,16 +12,16 @@ use ratatui::style::Color; use crate::palette::{magnitude_to_color_themed, ColorDepth}; -/// dBFS window for the vertical axis. pub(super) const Y_MIN: f32 = -100.0; pub(super) const Y_MAX: f32 = 0.0; + /// Width of the left dBFS-label gutter. pub(super) const AXIS_W: u16 = 5; /// Height of the window in dB, floored at 1 so nothing divides by zero if the /// constants are ever brought together. -pub(super) fn span_db() -> f32 { - (Y_MAX - Y_MIN).max(1.0) +pub(super) fn span_db(y_min: f32, y_max: f32) -> f32 { + (y_max - y_min).max(1.0) } /// Dim a truecolor toward black by factor `f` (256/16 pass through). Matches the @@ -53,13 +53,15 @@ pub(super) struct Gradient { body: Vec, /// Full brightness, for the top edge. edge: Vec, + y_min: f32, + y_max: f32, } impl Gradient { /// Four steps per character row, capped: enough that the shading is smooth on /// a tall pane, bounded so a very tall one does not spend the frame drawing /// bands nobody can distinguish. - pub(super) fn new(plot_h: usize, theme: &crate::Theme) -> Self { + pub(super) fn new(plot_h: usize, y_min: f32, y_max: f32, theme: &crate::Theme) -> Self { let steps = (plot_h * 4).clamp(1, 512); let depth = ColorDepth::detect(); let level: Vec = (0..steps) @@ -69,15 +71,21 @@ impl Gradient { } else { 0.0 }; - Y_MIN + f * span_db() + y_min + f * span_db(y_min, y_max) }) .collect(); let edge: Vec = level .iter() - .map(|&y| magnitude_to_color_themed(y, Y_MIN, Y_MAX, depth, theme)) + .map(|&y| magnitude_to_color_themed(y, y_min, y_max, depth, theme)) .collect(); let body = edge.iter().map(|&c| dim(c, BODY_DIM)).collect(); - Self { level, body, edge } + Self { + level, + body, + edge, + y_min, + y_max, + } } pub(super) fn steps(&self) -> usize { @@ -94,7 +102,7 @@ impl Gradient { /// The edge colour for a level, clamped into the window. pub(super) fn edge_at(&self, level_db: f32) -> Color { - let frac = ((level_db - Y_MIN) / span_db()).clamp(0.0, 1.0); + let frac = ((level_db - self.y_min) / span_db(self.y_min, self.y_max)).clamp(0.0, 1.0); let last = self.steps() - 1; self.edge[((frac * last as f32) as usize).min(last)] } @@ -129,13 +137,15 @@ pub(super) fn cursor_bucket(frac: f64, n: usize) -> usize { mod tests { use super::*; use crate::Theme; + const Y_MIN: f32 = -100.0; + const Y_MAX: f32 = 0.0; #[test] fn the_window_runs_the_right_way_up() { // A `const` block: the window running the wrong way up would draw the // whole plot inverted, and that is worth failing the build for. const { assert!(Y_MIN < Y_MAX, "the dBFS window must run bottom to top") }; - assert!((span_db() - 100.0).abs() < 1e-6); + assert!((span_db(Y_MIN, Y_MAX) - 100.0).abs() < 1e-6); } /// A level at the bottom of the window and one at the top must not get the @@ -143,7 +153,7 @@ mod tests { #[test] fn the_gradient_distinguishes_the_ends_of_the_window() { let t = Theme::sdr(); - let g = Gradient::new(10, &t); + let g = Gradient::new(10, Y_MIN, Y_MAX, &t); assert!(g.steps() > 1); assert_ne!(g.edge_at(Y_MIN), g.edge_at(Y_MAX)); assert_eq!(g.level(0), Y_MIN); @@ -154,7 +164,7 @@ mod tests { #[test] fn a_level_outside_the_window_still_has_a_colour() { let t = Theme::sdr(); - let g = Gradient::new(10, &t); + let g = Gradient::new(10, Y_MIN, Y_MAX, &t); assert_eq!(g.edge_at(-500.0), g.edge_at(Y_MIN)); assert_eq!(g.edge_at(50.0), g.edge_at(Y_MAX)); } @@ -163,7 +173,7 @@ mod tests { #[test] fn a_single_row_plot_has_at_least_one_band() { let t = Theme::sdr(); - let g = Gradient::new(0, &t); + let g = Gradient::new(0, Y_MIN, Y_MAX, &t); assert_eq!(g.steps(), 1); // And `edge_at` must not divide by `steps - 1 == 0`. let _ = g.edge_at(-50.0); diff --git a/src/ui/panels/lab/sweep_panel/status.rs b/src/ui/panels/lab/sweep_panel/status.rs index 8236b863..b1779402 100644 --- a/src/ui/panels/lab/sweep_panel/status.rs +++ b/src/ui/panels/lab/sweep_panel/status.rs @@ -25,17 +25,23 @@ pub(super) fn line( theme: &crate::Theme, ) -> Line<'static> { match state.sweep.cursor_frac { - Some(frac) => cursor(frac, frame, env, theme), + Some(frac) => cursor(frac, frame, env, state.caps.level_unit.label(), theme), None => cycle(state, frame, theme), } } -fn cursor(frac: f64, frame: &SweepFrame, env: &Envelope, theme: &crate::Theme) -> Line<'static> { +fn cursor( + frac: f64, + frame: &SweepFrame, + env: &Envelope, + unit: &str, + theme: &crate::Theme, +) -> Line<'static> { let hz = frame.freq_at_fraction(frac); // A bucket the sweep never reached reads as a dash, not as the window floor: // "no measurement here" and "โˆ’100 dBFS here" are different answers. let level = match env.level_at(cursor_bucket(frac, env.len())) { - Some(v) => format!("{v:.1} dBFS"), + Some(v) => format!("{v:.1} {unit}"), None => "\u{2014}".to_string(), }; let band = band_at(hz).map(|b| format!(" [{b}]")).unwrap_or_default(); diff --git a/src/ui/panels/lab/sweep_panel/trace.rs b/src/ui/panels/lab/sweep_panel/trace.rs index d12c6046..0beee6da 100644 --- a/src/ui/panels/lab/sweep_panel/trace.rs +++ b/src/ui/panels/lab/sweep_panel/trace.rs @@ -11,7 +11,7 @@ use ratatui::{ Frame, }; -use super::scale::{Gradient, Y_MAX, Y_MIN}; +use super::scale::Gradient; pub(super) fn draw( f: &mut Frame, @@ -19,12 +19,14 @@ pub(super) fn draw( body: Vec, gradient: Gradient, cursor: Option<(f64, Color)>, + y_min: f32, + y_max: f32, ) { let x_max = (body.len() as f64 - 1.0).max(0.0); f.render_widget( Canvas::default() .x_bounds([0.0, x_max]) - .y_bounds([Y_MIN as f64, Y_MAX as f64]) + .y_bounds([y_min as f64, y_max as f64]) .paint(move |ctx| { // 1. Filled body. One horizontal run per band step, so a plateau // costs one line rather than one per column. @@ -66,9 +68,9 @@ pub(super) fn draw( if let Some((cx, color)) = cursor { ctx.draw(&CanvasLine { x1: cx, - y1: Y_MIN as f64, + y1: y_min as f64, x2: cx, - y2: Y_MAX as f64, + y2: y_max as f64, color, }); } diff --git a/src/ui/panels/lab/sweep_strip.rs b/src/ui/panels/lab/sweep_strip.rs index 09d0002e..9574c192 100644 --- a/src/ui/panels/lab/sweep_strip.rs +++ b/src/ui/panels/lab/sweep_strip.rs @@ -23,6 +23,9 @@ impl Panel for SweepStripPanel { fn name(&self) -> &'static str { "sweep_strip" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 3) } diff --git a/src/ui/panels/micro/sweep.rs b/src/ui/panels/micro/sweep.rs index e2693b85..d2a0e04c 100644 --- a/src/ui/panels/micro/sweep.rs +++ b/src/ui/panels/micro/sweep.rs @@ -30,6 +30,9 @@ impl Panel for MicroSweepPanel { fn name(&self) -> &'static str { "micro_sweep_panel" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 8) } @@ -152,7 +155,7 @@ impl Panel for MicroSweepPanel { .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" {:>6.1} dBFS", db), + format!(" {:>6.1} {}", db, state.caps.level_unit.label()), Style::default().fg(theme.value), ), Span::styled(band, Style::default().fg(theme.status_ok)), @@ -170,3 +173,23 @@ impl Panel for MicroSweepPanel { f.render_widget(Paragraph::new(lines), list_area); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::fixture::draw; + + #[test] + fn power_sweep_peaks_use_dbm() { + let mut metrics = SdrMetrics::fixture() + .streaming() + .with_sweep(88_000_000, 108_000_000); + let mut caps = (*metrics.caps).clone(); + caps.level_unit = crate::hardware::LevelUnit::Dbm; + metrics.caps = std::sync::Arc::new(caps); + + let out = draw(MicroSweepPanel, 80, 14, &metrics).join("\n"); + assert!(out.contains("dBm"), "{out}"); + assert!(!out.contains("dBFS"), "{out}"); + } +} diff --git a/user_docs/hardware.md b/user_docs/hardware.md index fa40ec31..f80adf5c 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -10,7 +10,7 @@ |--------|--------| | HackRF One | Fully supported: spectrum, waterfall, every diagnostic | | RTL-SDR (R820T / R828D / E4000) | Fully supported: the whole spectrum, waterfall and lab stack, with a single tuner gain plus AGC | -| tinySA / tinySA Ultra / Ultra+ | Spectrum and waterfall in calibrated dBm. ZS405 is verified on hardware | +| tinySA / tinySA Ultra / Ultra+ | Spectrum, waterfall and native band sweeps in calibrated dBm. ZS405 is verified on hardware | | **Anything with a SoapySDR driver** | Supported, **and not yet confirmed on hardware other than a HackRF**. See [below](#soapysdr-the-honest-version) | | PortaPack H4M (Mayhem) | Fully supported (HackRF mode) | @@ -43,9 +43,9 @@ sdrtop uses the tinySA USB console for calibrated power traces. The backend supports the basic tinySA and the Ultra family. It was verified on a ZS405 running `tinySA4_v1.4-236-ge5aa115`. -The device supplies swept power readings without IQ samples. sdrtop therefore -offers only the spectrum and waterfall layouts. RBW, attenuation, gain, AGC and -spur handling use safe automatic defaults. +The device supplies swept power readings without IQ samples. sdrtop offers the +spectrum, waterfall, full band sweep and micro sweep layouts. RBW, attenuation, +gain, AGC and spur handling use safe automatic defaults. ```sh sdrtop --device tinysa