diff --git a/Cargo.lock b/Cargo.lock index 0c85a05..13a1330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -134,7 +140,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -172,6 +178,22 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -193,7 +215,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "libc", "mio 0.8.11", @@ -273,6 +295,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -343,6 +375,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.1" @@ -372,6 +413,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -479,7 +531,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -499,7 +551,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -556,6 +608,7 @@ dependencies = [ "ratatui", "rustfft", "serde", + "serialport", "tokio", "toml", ] @@ -587,7 +640,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -599,6 +652,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serialport" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -653,7 +724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -693,7 +764,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -707,6 +778,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio" version = "1.52.3" @@ -732,7 +834,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -786,6 +888,15 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -861,7 +972,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -879,13 +999,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -894,42 +1030,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index d85d500..7e27acc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ num-complex = "0.4" # exactly the binary it runs today and simply sees no Soapy devices. See # dev_docs/soapy-design.md, decision 1. libloading = "0.8" +serialport = { version = "4", default-features = false } [build-dependencies] pkg-config = "0.3" diff --git a/README.md b/README.md index 854737f..0b5cb6b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ It's a hobby project built in my spare time, and honestly, I made it for *you* > [!IMPORTANT] > **Project status: early development.** The TUI is feature-complete and the arc now is polish, sharper radio math and bug fixing, not more features. > -> Two radios are **verified on hardware**: HackRF One and RTL-SDR. Anything with a **SoapySDR** driver also works, and that backend is **beta**: it was written from the API rather than from owning the radios, so treat it as "should work, nobody has confirmed it yet". [The docs say exactly which parts are confirmed](user_docs/hardware.md#soapysdr-the-honest-version). If you own one of those, an issue either way is worth a lot to me. +> HackRF One, RTL-SDR and a tinySA Ultra ZS405 are **verified on hardware**. Anything with a **SoapySDR** driver also works, and that backend is **beta**: it was written from the API rather than from owning the radios, so treat it as "should work, nobody has confirmed it yet". [The docs say exactly which parts are confirmed](user_docs/hardware.md#soapysdr-the-honest-version). If you own one of those, an issue either way is worth a lot to me. > > Known issues: plenty ๐Ÿ˜„ If something looks broken, it's either a bug or an undocumented feature. Flip a coin, then open an issue. @@ -270,6 +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 | | **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/boot.rs b/src/app/builder/boot.rs index ee86e23..31fd715 100644 --- a/src/app/builder/boot.rs +++ b/src/app/builder/boot.rs @@ -301,7 +301,7 @@ pub fn resolve_gains(radio: &RadioConfig, gm: &GainModel) -> (Vec, Vec SdrMetrics { +pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> anyhow::Result { let Boot { caps, tuning, @@ -311,7 +311,9 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { recall, } = boot; - SdrMetrics { + caps.validate_display()?; + + Ok(SdrMetrics { radio: RadioState { frequency: tuning.frequency_hz, config_sample_rate: tuning.sample_rate, @@ -361,8 +363,8 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { observer, spectrum: SpectrumState { step_hz: 100_000, - y_min: -120.0, - y_max: 0.0, + y_min: caps.level_min_db, + y_max: caps.level_max_db, hold: None, cursor_freq: None, markers, @@ -375,6 +377,8 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { waterfall: WaterfallState::new( cfg.display.waterfall_max_rows.max(WATERFALL_MIN_ROWS), cfg.display.waterfall_palette, + caps.level_min_db, + caps.level_max_db, ), system: SystemState { board_name: Arc::from(identity.board_name.as_str()), @@ -406,7 +410,7 @@ pub(super) fn initial_metrics(cfg: &AppConfig, boot: Boot) -> SdrMetrics { net: crate::state::NetState::default(), caps, acc: Accumulators::default(), - } + }) } #[cfg(test)] @@ -564,6 +568,7 @@ mod tests { ), ), ] { + let m = m.unwrap(); assert!(!m.radio.rx_enabled); assert!(!m.radio.hw_streaming); assert_eq!(m.radio.actual_sample_rate, 0); @@ -576,6 +581,133 @@ mod tests { } } + #[test] + fn iq_devices_keep_the_existing_startup_defaults() { + let cfg = AppConfig::default(); + for caps in [ + hardware::native::hackrf::caps(), + hardware::native::rtlsdr::observer_caps(), + ] { + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -120.0); + assert_eq!(metrics.spectrum.y_max, 0.0); + assert_eq!(metrics.waterfall.db_min, -120.0); + assert_eq!(metrics.waterfall.db_max, 0.0); + assert_eq!(metrics.caps.level_unit, hardware::LevelUnit::Dbfs); + assert_eq!(metrics.caps.level_unit.label(), "dBFS"); + assert_eq!(metrics.caps.trace_stale_ms, 500); + } + } + + #[test] + fn startup_uses_the_device_level_axis() { + let cfg = AppConfig::default(); + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = -110.0; + caps.level_max_db = -10.0; + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -110.0); + assert_eq!(metrics.spectrum.y_max, -10.0); + assert_eq!(metrics.waterfall.db_min, -110.0); + assert_eq!(metrics.waterfall.db_max, -10.0); + } + + #[test] + fn startup_rejects_invalid_display_capabilities() { + let cfg = AppConfig::default(); + for (min, max, budget, error) in [ + (0.0, 0.0, 500, "Invalid display level bounds"), + (0.0, -10.0, 500, "Invalid display level bounds"), + (f32::NAN, 0.0, 500, "Invalid display level bounds"), + (-120.0, f32::NAN, 500, "Invalid display level bounds"), + (f32::NEG_INFINITY, 0.0, 500, "Invalid display level bounds"), + (-120.0, f32::INFINITY, 500, "Invalid display level bounds"), + (-f32::MAX, f32::MAX, 500, "Invalid display level bounds"), + (-120.0, 0.0, 0, "Invalid trace_stale_ms"), + ] { + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = min; + caps.level_max_db = max; + caps.trace_stale_ms = budget; + let tuning = resolve_tuning(&cfg.radio, &caps); + let result = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ); + assert!(result + .err() + .expect("invalid caps must fail") + .to_string() + .contains(error)); + } + } + + #[test] + fn startup_accepts_valid_display_capabilities() { + let cfg = AppConfig::default(); + for (min, max, budget) in [(-10.0, 0.0, 1), (-120.0, 20.0, 5_000)] { + let mut caps = hardware::native::hackrf::caps(); + caps.level_min_db = min; + caps.level_max_db = max; + caps.trace_stale_ms = budget; + let tuning = resolve_tuning(&cfg.radio, &caps); + let m = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert_eq!((m.spectrum.y_min, m.spectrum.y_max), (min, max)); + assert_eq!((m.waterfall.db_min, m.waterfall.db_max), (min, max)); + assert_eq!(m.caps.trace_stale_ms, budget); + } + } + + #[test] + fn observer_startup_also_rejects_invalid_display_capabilities() { + let cfg = AppConfig::default(); + let mut boot = Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)); + Arc::make_mut(&mut boot.caps).trace_stale_ms = 0; + assert!(initial_metrics(&cfg, boot) + .err() + .expect("invalid observer caps must fail") + .to_string() + .contains("Invalid trace_stale_ms")); + } + /// A config that asks for less waterfall history than a full-height panel /// needs is raised, not honoured. /// @@ -589,7 +721,8 @@ mod tests { let m = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert_eq!( m.waterfall.buffer.max_rows, crate::state::WATERFALL_MIN_ROWS @@ -604,10 +737,38 @@ mod tests { let m = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert_eq!(m.waterfall.buffer.max_rows, 4_096); } + #[test] + fn a_power_trace_backend_uses_its_level_axis_and_starts_paused() { + let cfg = AppConfig::default(); + let mut caps = hardware::native::hackrf::caps(); + caps.acquisition = hardware::AcquisitionKind::PowerTrace; + caps.level_unit = hardware::LevelUnit::Dbm; + caps.level_min_db = -110.0; + caps.level_max_db = -10.0; + let tuning = resolve_tuning(&cfg.radio, &caps); + let metrics = initial_metrics( + &cfg, + Boot::normal( + &cfg, + Arc::new(caps), + tuning, + &hardware::DeviceInfo::default(), + ), + ) + .unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(metrics.spectrum.y_min, -110.0); + assert_eq!(metrics.spectrum.y_max, -10.0); + assert_eq!(metrics.waterfall.db_min, -110.0); + assert_eq!(metrics.waterfall.db_max, -10.0); + } + #[test] fn observer_mode_starts_without_markers_or_recall() { let mut cfg = AppConfig::default(); @@ -627,14 +788,16 @@ mod tests { resolve_tuning(&cfg.radio, &hardware::native::hackrf::caps()), &hardware::DeviceInfo::default(), ), - ); + ) + .unwrap(); assert_eq!(live.spectrum.markers.len(), 1); assert_eq!(live.ui.recall[0], Some(100_000_000)); let obs = initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo(), profile(hardware::DeviceKind::HackRf)), - ); + ) + .unwrap(); assert!(obs.spectrum.markers.is_empty()); assert!(obs.ui.recall.iter().all(|s| s.is_none())); assert!(obs.observer.active); diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index 69051de..9f17fc9 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -25,7 +25,7 @@ use std::time::Duration; use crate::config::AppConfig; use crate::event::EventStream; use crate::hardware; -use crate::signal::{DemodWorker, FftWorker, NetWorker}; +use crate::signal::{DemodWorker, FftWorker, NetWorker, PowerWorker}; use crate::state::SdrMetrics; use crate::tasks; @@ -89,7 +89,7 @@ impl App { let state = Arc::new(Mutex::new(initial_metrics( &cfg, Boot::normal(&cfg, Arc::clone(&caps), tuning, &info), - ))); + )?)); { let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); @@ -113,7 +113,7 @@ impl App { // RTL-SDR reports a tuner instead of a board revision / USB-API version. if let Some(tuner) = &info.tuner_name { m.push_log(format!("Tuner: {}", tuner)); - } else { + } else if caps.acquisition == hardware::AcquisitionKind::IqSamples { m.push_log(board); } // Anything the backend declined while opening. Both native paths @@ -157,6 +157,7 @@ impl App { // thread, and anything deeper starts hiding the losses rather than // absorbing them. let (net_tx, net_rx) = crossbeam_channel::bounded::(4); + let (power_tx, power_rx) = crossbeam_channel::bounded::(4); let rx_ctx = Arc::new(hardware::RxContext { metrics: Arc::clone(&state), sample_tx, @@ -164,35 +165,51 @@ impl App { demod_tx, net_tx, net_feed: hardware::FeedHealth::default(), + power_tx, geometry, }); - let fft_state = Arc::clone(&state); - std::thread::spawn(move || FftWorker::new(sample_rx, fft_state, geometry).run()); + let app = Self::assemble( + cfg, + config_path, + Arc::clone(&state), + Some(Arc::clone(&device)), + Some(Arc::clone(&rx_ctx)), + None, + )?; - let demod_state = Arc::clone(&state); - std::thread::spawn(move || DemodWorker::new(demod_rx, demod_state, geometry).run()); + match caps.acquisition { + hardware::AcquisitionKind::IqSamples => { + let fft_state = Arc::clone(&state); + std::thread::spawn(move || FftWorker::new(sample_rx, fft_state, geometry).run()); - // Spawned whether or not the gate admitted the section: with no section - // on screen nothing is forwarded, so the thread costs one blocked - // `recv`. Deciding here would mean two places that know what admits the - // feature, and the one that already knows is `net::gate`. - let net_state = Arc::clone(&state); - std::thread::spawn(move || NetWorker::new(net_rx, net_state, geometry).run()); + let demod_state = Arc::clone(&state); + std::thread::spawn(move || DemodWorker::new(demod_rx, demod_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)); + // Spawned whether or not the gate admitted the section: with no section + // on screen nothing is forwarded, so the thread costs one blocked + // `recv`. Deciding here would mean two places that know what admits the + // feature, and the one that already knows is `net::gate`. + let net_state = Arc::clone(&state); + 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 => { + let power_state = Arc::clone(&state); + std::thread::spawn(move || PowerWorker::new(power_rx, power_state).run()); + tasks::spawn_power_rx_task( + Arc::clone(&state), + Arc::clone(&device), + Arc::clone(&rx_ctx), + ); + } + } tasks::spawn_sys_resource_task(Arc::clone(&state)); - Ok(Self::assemble( - cfg, - config_path, - state, - Some(device), - Some(rx_ctx), - None, - )) + Ok(app) } pub(super) fn new_observer( @@ -204,7 +221,7 @@ impl App { let state = Arc::new(Mutex::new(initial_metrics( &cfg, Boot::observer(&cfg, &sysinfo, profile), - ))); + )?)); { let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); @@ -216,17 +233,17 @@ impl App { m.push_log("Device is in use by another process โ€” hardware controls disabled"); } - tasks::spawn_observer_task(Arc::clone(&state), sysinfo.bus, sysinfo.dev, profile); - tasks::spawn_sys_resource_task(Arc::clone(&state)); - - Ok(Self::assemble( + let app = Self::assemble( cfg, config_path, - state, + Arc::clone(&state), None, None, Some("observer"), - )) + )?; + tasks::spawn_observer_task(Arc::clone(&state), sysinfo.bus, sysinfo.dev, profile); + tasks::spawn_sys_resource_task(Arc::clone(&state)); + Ok(app) } /// The tail both startups end in: resolve the config paths, build the theme @@ -243,7 +260,7 @@ impl App { device: Option>, rx_ctx: Option>, preset_override: Option<&str>, - ) -> Self { + ) -> anyhow::Result { let themes_dir = config_path .as_deref() .and_then(crate::config::AppConfig::themes_dir); @@ -261,8 +278,18 @@ impl App { }; let active = preset_override.unwrap_or(&cfg.display.active_preset); - let (engine, focus_keys) = - Self::build_ui(active, &cfg.presets, presets_dir.as_deref(), net.is_ok()); + let acquisition = state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .caps + .acquisition; + let (engine, focus_keys) = Self::build_ui_for( + active, + &cfg.presets, + presets_dir.as_deref(), + net.is_ok(), + acquisition, + )?; // A user preset that wanted a number key already taken says so, once, // here. `menu::model::build` collects these instead of logging them so it @@ -273,6 +300,9 @@ impl App { for warning in engine.menu_warnings() { m.push_log(warning.clone()); } + for warning in engine.startup_warnings() { + m.push_log(warning.clone()); + } if let Err(why) = &net { m.push_log(why.clone()); } @@ -294,7 +324,7 @@ impl App { }); } - Self { + Ok(Self { state, device, rx_ctx, @@ -307,6 +337,6 @@ impl App { focus_keys, theme_config: cfg.theme.clone(), user_presets: cfg.presets, - } + }) } } diff --git a/src/app/builder/registry.rs b/src/app/builder/registry.rs index bfd8382..2043369 100644 --- a/src/app/builder/registry.rs +++ b/src/app/builder/registry.rs @@ -17,17 +17,35 @@ use crate::ui; use crate::app::App; impl App { + #[cfg(test)] + pub(super) fn build_ui( + active_preset: &str, + user_presets: &HashMap, + presets_dir: Option<&std::path::Path>, + net_admitted: bool, + ) -> (ui::LayoutEngine, HashMap) { + Self::build_ui_for( + active_preset, + user_presets, + presets_dir, + net_admitted, + crate::hardware::AcquisitionKind::IqSamples, + ) + .expect("built-in IQ layouts must include a usable preset") + } + /// `net_admitted` comes from `signal::net::gate`: on a radio that cannot /// reach the 2.4 GHz band, or cannot run even the cheapest mode there, the /// presets in that section are dropped here and the section is **absent** /// from the menu rather than present and empty. Rule 2, and the same /// decision the RF bench makes about its noise-figure card. - pub(super) fn build_ui( + pub(super) fn build_ui_for( active_preset: &str, user_presets: &HashMap, presets_dir: Option<&std::path::Path>, net_admitted: bool, - ) -> (ui::LayoutEngine, HashMap) { + acquisition: crate::hardware::AcquisitionKind, + ) -> anyhow::Result<(ui::LayoutEngine, HashMap)> { let mut registry = ui::PanelRegistry::new(); registry.register(ui::HeaderPanel); registry.register(ui::SlimHeaderPanel); @@ -81,10 +99,84 @@ impl App { .presets .retain(|_, p| p.section.as_deref() != Some(ui::menu::model::NET)); } + let warnings = Self::filter_incompatible_layouts(&mut layout, ®istry, acquisition)?; + let selected = if layout.presets.contains_key(active_preset) { + active_preset.to_string() + } else { + ["spectrum_waterfall", "spectrum", "waterfall"] + .into_iter() + .find(|name| { + layout.presets.get(*name).is_some_and(|preset| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) + }) + .map(str::to_string) + .or_else(|| { + let mut names: Vec = layout + .presets + .iter() + .filter(|(_, preset)| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) + .map(|(name, _)| name.clone()) + .collect(); + names.sort(); + names.into_iter().next() + }) + .ok_or_else(|| anyhow::anyhow!("No usable presets remain for this device"))? + }; + layout.active_preset = selected; - let mut engine = ui::LayoutEngine::new(layout, registry); - engine.set_preset(active_preset); - (engine, focus_keys) + let mut engine = + ui::LayoutEngine::new_with_saved_preset(layout, registry, active_preset.to_string()); + engine.set_startup_warnings(warnings); + Ok((engine, focus_keys)) + } + + fn filter_incompatible_layouts( + config: &mut LayoutConfig, + registry: &ui::PanelRegistry, + acquisition: crate::hardware::AcquisitionKind, + ) -> anyhow::Result> { + let mut warnings = Vec::new(); + config.presets.retain(|name, preset| { + if preset.panels.is_empty() { + warnings.push(format!("Preset '{name}' is unavailable because it has no panels")); + return false; + } + for spec in &preset.panels { + let Some(panel) = registry.get(&spec.name) else { + warnings.push(format!( + "Preset '{name}' references unknown panel '{}'", + spec.name + )); + continue; + }; + if !panel.supports_acquisition(acquisition) { + warnings.push(format!( + "Preset '{name}' is unavailable because panel '{}' does not support this device", + spec.name + )); + return false; + } + } + true + }); + if !config.presets.values().any(|preset| { + preset + .panels + .iter() + .any(|spec| registry.get(&spec.name).is_some()) + }) { + anyhow::bail!("No usable presets remain for this device"); + } + Ok(warnings) } } @@ -365,6 +457,205 @@ mod tests { assert!(engine.is_panel_visible("spectrum")); } + #[test] + fn a_power_trace_device_keeps_only_compatible_trace_layouts() { + let mut user = HashMap::new(); + user.insert( + "my_trace".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: "spectrum".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "footer".into(), + position: crate::config::Position::Bottom, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + user.insert( + "my_iq".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "spectrum".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "iq_constellation".into(), + position: crate::config::Position::Right, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + user.insert( + "my_status".to_string(), + crate::config::PresetConfig { + panels: vec![ + crate::config::PanelSpec { + name: "system_resources".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }, + crate::config::PanelSpec { + name: "log".into(), + position: crate::config::Position::Bottom, + height: None, + width_pct: None, + }, + ], + ..Default::default() + }, + ); + + let (engine, _) = App::build_ui_for( + "my_trace", + &user, + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + for available in [ + "spectrum", + "waterfall", + "spectrum_waterfall", + "my_trace", + "my_status", + ] { + assert!(engine.has_preset(available), "{available} was hidden"); + } + for unavailable in [ + "command_rail", + "lab_iq", + "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"); + } + + #[test] + fn automatic_fallback_does_not_replace_the_saved_preference() { + let (engine, _) = App::build_ui_for( + "command_rail", + &HashMap::new(), + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + assert_eq!(engine.active_preset(), "spectrum_waterfall"); + assert_eq!(engine.saved_active_preset(), "command_rail"); + } + + #[test] + fn explicitly_selecting_the_fallback_updates_the_saved_preference() { + let (mut engine, _) = App::build_ui_for( + "command_rail", + &HashMap::new(), + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .unwrap(); + engine.set_preset("spectrum_waterfall"); + assert_eq!(engine.saved_active_preset(), "spectrum_waterfall"); + } + + #[test] + fn an_unknown_panel_warns_without_removing_the_preset() { + let mut user = HashMap::new(); + user.insert( + "future_panel".to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "not_registered_yet".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + + let (engine, _) = App::build_ui("future_panel", &user, None, true); + assert!(engine.has_preset("future_panel")); + assert_eq!(engine.active_preset(), "future_panel"); + assert!(engine + .startup_warnings() + .iter() + .any(|warning| warning.contains("unknown panel 'not_registered_yet'"))); + } + + #[test] + fn incompatible_overrides_cannot_leave_a_power_device_without_a_layout() { + let mut user = HashMap::new(); + for name in ["spectrum", "waterfall", "spectrum_waterfall"] { + user.insert( + name.to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "iq_constellation".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + } + user.insert( + "future_panel".to_string(), + crate::config::PresetConfig { + panels: vec![crate::config::PanelSpec { + name: "not_registered_yet".into(), + position: crate::config::Position::Body, + height: None, + width_pct: None, + }], + ..Default::default() + }, + ); + + let error = App::build_ui_for( + "spectrum_waterfall", + &user, + None, + false, + crate::hardware::AcquisitionKind::PowerTrace, + ) + .err() + .expect("all compatible layouts were overridden"); + assert!(error + .to_string() + .contains("No usable presets remain for this device")); + } + /// A full-height waterfall must reach its own bottom border. /// /// The `waterfall` preset gives the panel the whole body, and each character @@ -386,6 +677,8 @@ mod tests { m.waterfall = WaterfallState::new( WATERFALL_MIN_ROWS, crate::palette::WaterfallPalette::default(), + m.caps.level_min_db, + m.caps.level_max_db, ); for i in 0..WATERFALL_MIN_ROWS { let bins: Vec = (0..256) diff --git a/src/app/input/core.rs b/src/app/input/core.rs index cd8d50f..5a716c8 100644 --- a/src/app/input/core.rs +++ b/src/app/input/core.rs @@ -20,6 +20,31 @@ use crate::ui::widgets::micro_common::fmt_bw; use super::{global, metrics, InputCtx, KeyAction}; +fn strongest_bin_frequency(frame: &crate::state::FftFrame) -> Option { + let peak_bin = frame + .bins_dbfs + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(index, _)| index)?; + frame + .frequency_of_bin(peak_bin) + .filter(|frequency| *frequency >= 0.0) + .map(|frequency| frequency.round() as u64) +} +const LEVEL_ZOOM_STEP_DB: f32 = 10.0; +const LEVEL_MIN_WINDOW_DB: f32 = 20.0; +fn adjust_level_floor( + floor: f32, + ceiling: f32, + delta: f32, + caps: &crate::hardware::DeviceCapabilities, +) -> f32 { + let window = LEVEL_MIN_WINDOW_DB.min(caps.level_max_db - caps.level_min_db); + let highest_floor = (ceiling.min(caps.level_max_db) - window).max(caps.level_min_db); + (floor + delta).clamp(caps.level_min_db, highest_floor) +} + // โ”€โ”€ Spectrum focus keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { @@ -97,17 +122,29 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyCode::Up => { let mut m = metrics(state); - let new_min = (m.spectrum.y_min + 10.0).min(m.spectrum.y_max - 20.0); + let new_min = adjust_level_floor( + m.spectrum.y_min, + m.spectrum.y_max, + LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; - m.push_log(format!("Zoom: {:.0}โ€ฆ{:.0} dBFS", new_min, ymax)); + let unit = m.caps.level_unit.label(); + m.push_log(format!("Zoom: {new_min:.0}\u{2026}{ymax:.0} {unit}")); } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.spectrum.y_min - 10.0).max(-120.0); + let new_min = adjust_level_floor( + m.spectrum.y_min, + m.spectrum.y_max, + -LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.spectrum.y_min = new_min; let ymax = m.spectrum.y_max; - m.push_log(format!("Zoom: {:.0}โ€ฆ{:.0} dBFS", new_min, ymax)); + let unit = m.caps.level_unit.label(); + m.push_log(format!("Zoom: {new_min:.0}\u{2026}{ymax:.0} {unit}")); } KeyCode::Char('j') => { let mut m = metrics(state); @@ -131,16 +168,7 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { let freq = if let Some(f) = m.spectrum.cursor_freq { f } else if let Some(frame) = &m.waterfall.last_fft { - let peak_bin = frame - .bins_dbfs - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i) - .unwrap_or(frame.bins_dbfs.len() / 2); - let left_hz = m.radio.frequency as f64 - frame.sample_rate / 2.0; - (left_hz + peak_bin as f64 / frame.bins_dbfs.len() as f64 * frame.sample_rate) - .round() as u64 + strongest_bin_frequency(frame).unwrap_or(m.radio.frequency) } else { m.radio.frequency }; @@ -208,6 +236,7 @@ pub(super) fn spectrum(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyAction::Continue } + // โ”€โ”€ Waterfall focus keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { @@ -215,15 +244,33 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { match key.code { KeyCode::Up => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min + 10.0).min(-20.0); + let new_min = adjust_level_floor( + m.waterfall.db_min, + m.waterfall.db_max, + LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.waterfall.db_min = new_min; - m.push_log(format!("Waterfall zoom: {:.0}โ€ฆ0 dBFS", new_min)); + let max = m.waterfall.db_max; + let unit = m.caps.level_unit.label(); + m.push_log(format!( + "Waterfall zoom: {new_min:.0}\u{2026}{max:.0} {unit}" + )); } KeyCode::Down => { let mut m = metrics(state); - let new_min = (m.waterfall.db_min - 10.0).max(-120.0); + let new_min = adjust_level_floor( + m.waterfall.db_min, + m.waterfall.db_max, + -LEVEL_ZOOM_STEP_DB, + &m.caps, + ); m.waterfall.db_min = new_min; - m.push_log(format!("Waterfall zoom: {:.0}โ€ฆ0 dBFS", new_min)); + let max = m.waterfall.db_max; + let unit = m.caps.level_unit.label(); + m.push_log(format!( + "Waterfall zoom: {new_min:.0}\u{2026}{max:.0} {unit}" + )); } KeyCode::Char('[') => { let mut m = metrics(state); @@ -297,3 +344,173 @@ pub(super) fn waterfall(key: KeyEvent, ctx: &mut InputCtx<'_>) -> KeyAction { } KeyAction::Continue } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Instant; + + use super::*; + use crate::state::{BinAxis, FftFrame, SdrMetrics}; + use crate::ui::{LayoutEngine, PanelRegistry}; + + #[test] + fn peak_jump_uses_the_captured_fft_frequency_after_retuning() { + let mut state = SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + let frame = state.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(strongest_bin_frequency(frame), Some(108_000_000)); + assert_eq!(frame.bin_axis, BinAxis::FftBins); + assert_eq!(frame.window(4).unwrap().span_hz, 8_000_000.0); + } + + #[test] + fn peak_jump_rejects_an_empty_frame() { + let state = SdrMetrics::fixture().with_carrier(0.0, 70.0); + let mut frame = state.waterfall.last_fft.unwrap(); + frame.bins_dbfs = Arc::new(Vec::new()); + assert_eq!(strongest_bin_frequency(&frame), None); + } + + #[test] + fn peak_jump_reaches_the_last_measured_point() { + let mut bins = vec![-90.0; 64]; + bins[63] = -20.0; + let bins = Arc::new(bins); + let frame = FftFrame { + bins_dbfs: Arc::clone(&bins), + peak_hold: bins, + noise_floor: -90.0, + center_freq_hz: 131_500_000, + sample_rate: 63_000_000.0, + timestamp: Instant::now(), + peak_to_nf_db: 70.0, + channel_power_dbfs: -20.0, + occupied_bw_hz: 0, + enbw_hz: 0.0, + bin_axis: BinAxis::MeasuredPoints, + }; + + assert_eq!(strongest_bin_frequency(&frame), Some(163_000_000)); + } + + #[test] + fn peak_jump_falls_back_for_a_negative_frequency() { + let mut state = crate::state::SdrMetrics::fixture(); + state.radio.frequency = 1_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let state = state.with_carrier(-8_000_000.0, 70.0); + let frame = state.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(strongest_bin_frequency(frame), None); + assert_eq!( + strongest_bin_frequency(frame).unwrap_or(state.radio.frequency), + 1_000_000 + ); + } + + #[test] + fn either_zoom_direction_keeps_the_floor_within_device_limits() { + let mut caps = crate::hardware::native::hackrf::caps(); + for (min, max) in [(-120.0, 0.0), (-10.0, 0.0), (-10.5, -10.0)] { + caps.level_min_db = min; + caps.level_max_db = max; + for delta in [-LEVEL_ZOOM_STEP_DB, LEVEL_ZOOM_STEP_DB] { + for floor in [min - 100.0, min, max, max + 100.0] { + for ceiling in [max - 5.0, max, max + 100.0] { + let adjusted = adjust_level_floor(floor, ceiling, delta, &caps); + assert!((min..max).contains(&adjusted)); + assert!(adjusted <= max - (max - min).min(20.0)); + } + } + } + } + } + + #[test] + fn level_zoom_uses_device_bounds_and_preserves_iq_log_text() { + for (min, max) in [ + (-120.0_f32, 0.0), + (-120.0, 20.0), + (-110.0, -10.0), + (-10.0, 0.0), + (-10.5, -10.0), + ] { + let highest_floor = max - (max - min).min(20.0_f32); + for is_waterfall in [false, true] { + let mut m = SdrMetrics::fixture(); + Arc::make_mut(&mut m.caps).level_min_db = min; + Arc::make_mut(&mut m.caps).level_max_db = max; + m.spectrum.y_min = min; + m.spectrum.y_max = max; + m.waterfall.db_min = min; + m.waterfall.db_max = max; + let state = Arc::new(Mutex::new(m)); + let mut engine = LayoutEngine::new( + crate::config::LayoutConfig::default_config(), + PanelRegistry::new(), + ); + let mut show_footer = true; + let focus_keys = HashMap::new(); + let mut ctx = InputCtx { + state: &state, + device: None, + engine: &mut engine, + show_footer: &mut show_footer, + focus_keys: &focus_keys, + }; + let prefix = if is_waterfall { + "Waterfall zoom" + } else { + "Zoom" + }; + let mut press = |code| { + let key = KeyEvent::new(code, crossterm::event::KeyModifiers::NONE); + if is_waterfall { + waterfall(key, &mut ctx); + } else { + spectrum(key, &mut ctx); + } + }; + press(KeyCode::Up); + assert_eq!( + metrics(&state).ui.log.back().unwrap().text.as_ref(), + format!( + "{prefix}: {:.0}\u{2026}{max:.0} dBFS", + (min + 10.0).min(highest_floor) + ) + ); + for _ in 0..20 { + press(KeyCode::Up); + } + { + let m = metrics(&state); + let floor = if is_waterfall { + m.waterfall.db_min + } else { + m.spectrum.y_min + }; + assert_eq!(floor, highest_floor); + } + for _ in 0..20 { + press(KeyCode::Down); + } + let m = metrics(&state); + let floor = if is_waterfall { + m.waterfall.db_min + } else { + m.spectrum.y_min + }; + assert_eq!(floor, min); + assert_eq!( + m.ui.log.back().unwrap().text.as_ref(), + format!("{prefix}: {min:.0}\u{2026}{max:.0} dBFS") + ); + } + } + } +} diff --git a/src/app/input/global/radio.rs b/src/app/input/global/radio.rs index dbe104d..ab2e5b9 100644 --- a/src/app/input/global/radio.rs +++ b/src/app/input/global/radio.rs @@ -87,16 +87,23 @@ pub(super) fn begin_frequency_input(ctx: &mut InputCtx<'_>) { /// `[S]` - type a sample rate, with the device's own legal range in the prompt. pub(super) fn begin_sample_rate_input(ctx: &mut InputCtx<'_>) { let Some(device) = ctx.device else { return }; - let (lo, hi) = { + let (lo, hi, name) = { let c = device.capabilities(); - (c.sample_rate_min_hz / 1e6, c.sample_rate_max_hz / 1e6) + ( + c.sample_rate_min_hz / 1e6, + c.sample_rate_max_hz / 1e6, + if c.sample_rate_is_span { + "span" + } else { + "sample rate" + }, + ) }; let mut m = metrics(ctx.state); m.ui.input_mode = InputMode::SampleRateInput; m.ui.input_buf.clear(); m.push_log(format!( - "Enter sample rate in MHz ({:.1}\u{2013}{:.1}), then press Enter", - lo, hi + "Enter {name} in MHz ({lo:.1}\u{2013}{hi:.1}), then press Enter", )); } diff --git a/src/app/input/menu.rs b/src/app/input/menu.rs index 0d48e54..6c9f9ff 100644 --- a/src/app/input/menu.rs +++ b/src/app/input/menu.rs @@ -146,7 +146,7 @@ fn move_down(ctx: &mut InputCtx<'_>, state: MenuState, step: isize) { MenuPane::Keys => { let last = { let m = metrics(ctx.state); - keys::row_count(&m.caps.gain).saturating_sub(1) + keys::row_count_for(&m.caps).saturating_sub(1) }; let next = if step >= 0 { (state.scroll + 1).min(last) diff --git a/src/app/input/text.rs b/src/app/input/text.rs index fd0a6ea..162cb02 100644 --- a/src/app/input/text.rs +++ b/src/app/input/text.rs @@ -90,7 +90,12 @@ pub(super) fn sample_rate( let mut m = metrics(state); m.ui.input_mode = InputMode::Normal; m.ui.input_buf.clear(); - m.push_log("Sample rate input cancelled"); + let name = if m.caps.sample_rate_is_span { + "Span" + } else { + "Sample rate" + }; + m.push_log(format!("{name} input cancelled")); } KeyCode::Backspace => { metrics(state).ui.input_buf.pop(); @@ -118,6 +123,11 @@ pub(super) fn sample_rate( // rx_callback thread that needs the same lock to return. let result = rate_hz.map(|hz| device.set_sample_rate(hz)); let mut m = metrics(state); + let (name, lower_name) = if m.caps.sample_rate_is_span { + ("Span", "span") + } else { + ("Sample rate", "sample rate") + }; match (rate_hz, result) { // The rate recorded is the one the device came back with, // not the one that was typed: a driver is free to round onto @@ -129,21 +139,21 @@ pub(super) fn sample_rate( m.ui.input_mode = InputMode::Normal; m.ui.input_buf.clear(); m.push_log(if set.rate_hz == hz { - format!("Sample rate set to {:.3} MHz", hz / 1e6) + format!("{name} set to {:.3} MHz", hz / 1e6) } else { format!( - "Sample rate {:.3} MHz is not on this radio's grid; running at \ + "{name} {:.3} MHz is not on this device's grid; running at \ {:.3} MHz", hz / 1e6, set.rate_hz / 1e6 ) }); } - (Some(_), Some(Err(e))) => m.push_log(format!("Sample rate error: {}", e)), + (Some(_), Some(Err(e))) => m.push_log(format!("{name} error: {e}")), _ => { let bad = m.ui.input_buf.clone(); m.push_log(format!( - "Invalid sample rate: '{}' (valid: {:.1}โ€“{:.1} MHz)", + "Invalid {lower_name}: '{}' (valid: {:.1}โ€“{:.1} MHz)", bad, lo_hz / 1e6, hi_hz / 1e6 diff --git a/src/app/mod.rs b/src/app/mod.rs index 24619b9..b5eff2d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -311,7 +311,7 @@ impl App { recall_hz: recall, }, display: DisplayConfig { - active_preset: self.engine.active_preset().to_string(), + active_preset: self.engine.saved_active_preset().to_string(), waterfall_max_rows: wf_rows, waterfall_palette: wf_palette, spectrum_style: spec_style, diff --git a/src/cli.rs b/src/cli.rs index edc94de..88937de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -44,8 +44,11 @@ pub struct Cli { #[arg(long, value_name = "FILE")] pub config: Option, - /// Pick the backend, optionally with SoapySDR device args (soapy=driver=airspy) - #[arg(long, value_name = "hackrf|rtlsdr|soapy[=args]")] + /// Pick the backend, optionally with a tinySA path/input or SoapySDR device args + #[arg( + long, + value_name = "hackrf|rtlsdr|tinysa[=PATH[?input=low|high]]|soapy[=args]" + )] pub device: Option, /// Center frequency in Hz, e.g. 433920000 (overrides config) diff --git a/src/hardware/discovery.rs b/src/hardware/discovery.rs index 55080d9..e70054a 100644 --- a/src/hardware/discovery.rs +++ b/src/hardware/discovery.rs @@ -12,10 +12,11 @@ //! Everything in this file is about identity and offering. Opening a device is //! the backend's job; [`open_device`] only dispatches to it. +use std::path::PathBuf; use std::sync::Arc; use super::native::{hackrf, rtlsdr}; -use super::{soapy, sysfs, DeviceCapabilities, SdrDevice}; +use super::{soapy, sysfs, tinysa, DeviceCapabilities, SdrDevice}; /// Which backend a [`DeviceListing`] / open request targets. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -23,6 +24,7 @@ pub enum DeviceKind { HackRf, RtlSdr, Soapy, + TinySa, } /// What observer mode needs in order to describe a device it cannot open. @@ -64,7 +66,7 @@ impl DeviceKind { scan: sysfs::find_rtlsdr, caps: rtlsdr::observer_caps, }), - DeviceKind::Soapy => None, + DeviceKind::Soapy | DeviceKind::TinySa => None, } } } @@ -89,6 +91,10 @@ pub struct DeviceListing { /// a race. The two native backends genuinely are index addressed, so they /// carry nothing here. pub args: Option, + /// Character-device path for serial backends. + pub path: Option, + /// Explicit physical input override for a basic tinySA. + pub tiny_sa_input: Option, } /// Normalise a serial so two backends' spellings of the same radio compare @@ -125,11 +131,17 @@ pub fn parse_device_arg(spec: &str) -> anyhow::Result<(DeviceKind, Option DeviceKind::HackRf, "rtlsdr" | "rtl-sdr" | "rtl" => DeviceKind::RtlSdr, "soapy" | "soapysdr" => DeviceKind::Soapy, + "tinysa" | "tiny-sa" => DeviceKind::TinySa, other => anyhow::bail!( - "Unknown --device '{other}' (use 'hackrf', 'rtlsdr', or \ - 'soapy', optionally as 'soapy=driver=airspy')" + "Unknown --device '{other}' (use 'hackrf', 'rtlsdr', 'tinysa', or \ + 'soapy'; tinysa and soapy accept '=selector')" ), }; + if kind == DeviceKind::TinySa { + if let Some(selector) = filter.as_deref() { + tinysa::parse_selector(selector)?; + } + } Ok((kind, filter)) } @@ -163,6 +175,12 @@ pub fn list_all_devices( let mut out = Vec::new(); out.extend(hackrf::list()); out.extend(rtlsdr::list()); + let tinysa = if want == Some(DeviceKind::TinySa) { + tinysa::list(soapy_filter.filter(|value| !value.is_empty())) + } else { + tinysa::list(None) + }; + out.extend(tinysa); // A radio the native backend also found is normally dropped. Not when the // user asked for SoapySDR by name: then the Soapy path is the thing they // wanted, and hiding it would leave `--device soapy` finding nothing at all @@ -238,6 +256,15 @@ pub fn open_device(listing: &DeviceListing) -> anyhow::Result }; Ok(Arc::new(soapy::device::SoapyDevice::open(args)?)) } + DeviceKind::TinySa => { + let Some(path) = listing.path.as_deref() else { + anyhow::bail!("a tinySA listing with no serial port cannot be opened"); + }; + Ok(Arc::new(tinysa::TinySaDevice::open( + path, + listing.tiny_sa_input.unwrap_or_default(), + )?)) + } } } @@ -257,6 +284,8 @@ mod tests { label: label.to_string(), serial: serial.map(str::to_string), args: args.map(str::to_string), + path: None, + tiny_sa_input: None, } } @@ -436,6 +465,14 @@ mod tests { (DeviceKind::Soapy, Some("driver=airspy".to_string())), "only the first = separates the name from the arguments" ); + assert_eq!( + parse_device_arg("tinysa=/dev/ttyACM2?input=high").unwrap(), + ( + DeviceKind::TinySa, + Some("/dev/ttyACM2?input=high".to_string()) + ) + ); + assert!(parse_device_arg("tinysa=/dev/ttyACM2?input=other").is_err()); assert!(parse_device_arg("airspy").is_err(), "not a backend name"); assert!(parse_device_arg("").is_err(), "nor is nothing"); } @@ -449,6 +486,9 @@ mod tests { for s in ["soapy", "soapysdr", "SoapySDR"] { assert_eq!(parse_device_arg(s).unwrap().0, DeviceKind::Soapy, "{s}"); } + for s in ["tinysa", "tiny-sa", "TinySA"] { + assert_eq!(parse_device_arg(s).unwrap().0, DeviceKind::TinySa, "{s}"); + } } /// `--device soapy=` is a backend with an empty filter, and an empty filter @@ -479,6 +519,7 @@ mod tests { DeviceKind::Soapy.observer_profile().is_none(), "there is no sysfs profile for whatever SoapySDR was talking to" ); + assert!(DeviceKind::TinySa.observer_profile().is_none()); } /// The failure the deleted assertion existed to prevent: a user staring at diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index 504e252..52635aa 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -3,12 +3,9 @@ //! The hardware layer, and the map of it. //! -//! Three backends, in two groups. [`native`] holds the radios sdrtop drives -//! itself and has physically tested; [`soapy`] holds everything reachable -//! through libSoapySDR, under the replacement rule described there. Neither -//! group knows the other exists. [`discovery`] is the only module that sees -//! both, because deciding what to offer when they find the same radio is a -//! question neither can answer alone. +//! [`native`] holds the radios sdrtop drives itself. [`soapy`] holds everything +//! reachable through libSoapySDR. [`tinysa`] holds swept spectrum analyzers. +//! [`discovery`] is the only module that sees all three groups. //! //! The rest is shared and backend-neutral: [`traits`] is the vocabulary, //! [`process`] the per-sample decode all three feed, [`gain`] the placement @@ -26,10 +23,14 @@ pub mod native; pub mod process; pub mod soapy; pub mod sysfs; +pub mod tinysa; mod traits; pub use discovery::{list_all_devices, open_device, DeviceKind, DeviceListing}; +#[cfg(test)] +pub(crate) use traits::RateSet; pub use traits::{ - Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, RxContext, - SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, StageSpec, StreamBlock, + AcquisitionKind, Boost, DeliveryModel, DeviceCapabilities, DeviceInfo, FeedHealth, GainModel, + LevelUnit, PowerTrace, RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, + StageSpec, StreamBlock, IQ_TRACE_STALE_MS, }; diff --git a/src/hardware/native/hackrf/mod.rs b/src/hardware/native/hackrf/mod.rs index 8e59847..44773a6 100644 --- a/src/hardware/native/hackrf/mod.rs +++ b/src/hardware/native/hackrf/mod.rs @@ -294,6 +294,8 @@ pub fn list() -> Vec { label: format!("HackRF One ยท {}", serial), args: None, serial: Some(serial.clone()), + path: None, + tiny_sa_input: None, }); } } @@ -332,6 +334,12 @@ pub fn gain_model() -> GainModel { /// HackRF One capability descriptor - also used as the observer-mode default. pub fn caps() -> DeviceCapabilities { DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, + sample_rate_is_span: false, freq_min_hz: 1_000_000, freq_max_hz: 6_000_000_000, sample_rate_min_hz: 2_000_000.0, diff --git a/src/hardware/native/rtlsdr/mod.rs b/src/hardware/native/rtlsdr/mod.rs index 347662d..6b8779c 100644 --- a/src/hardware/native/rtlsdr/mod.rs +++ b/src/hardware/native/rtlsdr/mod.rs @@ -306,6 +306,8 @@ pub fn list() -> Vec { label: format!("RTL-SDR ยท {} ยท {}", name, shown), args: None, serial: device_serial(i), + path: None, + tiny_sa_input: None, }); } out @@ -364,6 +366,12 @@ fn rtl_caps(tuner: c_int, gains_tenths: &[i32]) -> DeviceCapabilities { } DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, + sample_rate_is_span: false, freq_min_hz, freq_max_hz, // RTL-SDR's usable upper band is 900_001..=3_200_000 Hz (the lower diff --git a/src/hardware/process.rs b/src/hardware/process.rs index 9c98d58..2d01512 100644 --- a/src/hardware/process.rs +++ b/src/hardware/process.rs @@ -492,6 +492,7 @@ mod tests { let (sample_tx, sample_rx) = crossbeam_channel::bounded(fft_cap); let (demod_tx, demod_rx) = crossbeam_channel::bounded(8); let (net_tx, net_rx) = crossbeam_channel::bounded(net_cap); + let (power_tx, _) = crossbeam_channel::bounded(1); let mut m = SdrMetrics::fixture(); m.demod.enabled = true; m.ui.section = crate::signal::net::SECTION.to_string(); @@ -502,6 +503,7 @@ mod tests { demod_tx, net_tx, net_feed: crate::hardware::FeedHealth::default(), + power_tx, geometry: eight_bit(), }; (Arc::new(ctx), sample_rx, demod_rx, net_rx) diff --git a/src/hardware/soapy/caps.rs b/src/hardware/soapy/caps.rs index d61e434..6f01630 100644 --- a/src/hardware/soapy/caps.rs +++ b/src/hardware/soapy/caps.rs @@ -163,6 +163,12 @@ pub fn capabilities(a: &DriverAnswers) -> Result { let boost = element_boost.or(a.has_gain_mode.then_some(Boost::GainMode)); let caps = DeviceCapabilities { + level_unit: crate::hardware::LevelUnit::Dbfs, + level_min_db: -120.0, + level_max_db: 0.0, + trace_stale_ms: crate::hardware::IQ_TRACE_STALE_MS, + acquisition: crate::hardware::AcquisitionKind::IqSamples, + sample_rate_is_span: false, freq_min_hz: freq_min.max(0.0) as u64, freq_max_hz: freq_max.max(0.0) as u64, sample_rate_min_hz: rate_min, @@ -330,6 +336,11 @@ mod tests { #[test] fn a_soapy_hackrf_comes_out_the_way_the_probe_describes_it() { let c = capabilities(&soapy_hackrf()).unwrap().caps; + assert_eq!(c.level_unit, crate::hardware::LevelUnit::Dbfs); + assert_eq!(c.level_unit.label(), "dBFS"); + assert_eq!(c.level_min_db, -120.0); + assert_eq!(c.level_max_db, 0.0); + assert_eq!(c.trace_stale_ms, 500); assert_eq!(c.freq_min_hz, 0); assert_eq!(c.freq_max_hz, 7_250_000_000); assert_eq!(c.sample_rate_min_hz, 1e6); diff --git a/src/hardware/soapy/device.rs b/src/hardware/soapy/device.rs index cc1f827..818327b 100644 --- a/src/hardware/soapy/device.rs +++ b/src/hardware/soapy/device.rs @@ -320,6 +320,8 @@ pub fn list() -> Vec { label: args::label(&kwargs, i), serial: args::get(&kwargs, "serial").map(str::to_string), args: Some(args::open_markup(&kwargs, i)), + path: None, + tiny_sa_input: None, }) .collect() } diff --git a/src/hardware/tinysa/discovery.rs b/src/hardware/tinysa/discovery.rs new file mode 100644 index 0000000..2783431 --- /dev/null +++ b/src/hardware/tinysa/discovery.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::hardware::{DeviceKind, DeviceListing}; + +pub(super) fn list() -> Vec { + list_at(Path::new("/sys/class/tty"), Path::new("/dev")) +} + +fn list_at(tty_root: &Path, dev_root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(tty_root) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("ttyACM") { + continue; + } + let Ok(mut ancestor) = fs::canonicalize(entry.path()) else { + continue; + }; + loop { + if let Some(product) = matching_product(&ancestor) { + found.push((dev_root.join(name.as_ref()), product)); + break; + } + if !ancestor.pop() { + break; + } + } + } + found.sort_by(|left, right| left.0.cmp(&right.0)); + found + .into_iter() + .enumerate() + .map(|(index, (path, product))| DeviceListing { + kind: DeviceKind::TinySa, + index, + label: format!("{product} ยท {}", path.display()), + serial: None, + args: None, + path: Some(path), + tiny_sa_input: None, + }) + .collect() +} + +fn matching_product(path: &Path) -> Option { + let vendor = read(path.join("idVendor"))?; + let product_id = read(path.join("idProduct"))?; + if !vendor.eq_ignore_ascii_case("0483") || !product_id.eq_ignore_ascii_case("5740") { + return None; + } + let manufacturer = read(path.join("manufacturer")).unwrap_or_default(); + let product = read(path.join("product")).unwrap_or_default(); + let known_manufacturer = manufacturer.eq_ignore_ascii_case("tinysa.org"); + let known_product = + product.eq_ignore_ascii_case("tinysa") || product.eq_ignore_ascii_case("tinysa4"); + if !known_manufacturer && !known_product { + return None; + } + Some(if product.is_empty() { + "tinySA".to_string() + } else { + product + }) +} + +fn read(path: PathBuf) -> Option { + fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) +} diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs new file mode 100644 index 0000000..fe77e43 --- /dev/null +++ b/src/hardware/tinysa/mod.rs @@ -0,0 +1,1413 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +mod discovery; +mod protocol; + +use std::io::{ErrorKind, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context}; +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, +}; + +use super::traits::RateSet; +use protocol::{Identity, Model, PROMPT}; + +const MIN_FREQUENCY_HZ: u64 = 100_000; +const DEFAULT_FREQUENCY_HZ: u64 = 100_000_000; +const DEFAULT_SPAN_HZ: u64 = 10_000_000; +const DEFAULT_POINTS: u32 = 450; +const READ_TIMEOUT: Duration = Duration::from_millis(50); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); +const DRAIN_QUIET: Duration = Duration::from_millis(200); +const MAX_RESPONSE_BYTES: usize = 128 * 1024; +const BASIC_LOW_MAX_HZ: u64 = 350_000_000; +const BASIC_HIGH_MIN_HZ: u64 = 240_000_000; +const BASIC_HIGH_MAX_HZ: u64 = 959_000_000; + +type UnitReply = Sender>; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BasicInput { + #[default] + Low, + High, +} + +impl BasicInput { + fn parse(value: &str) -> anyhow::Result { + match value.to_ascii_lowercase().as_str() { + "low" => Ok(Self::Low), + "high" => Ok(Self::High), + _ => bail!("tinySA input must be 'low' or 'high'"), + } + } + + fn label(self) -> &'static str { + match self { + Self::Low => "LOW", + Self::High => "HIGH", + } + } + + fn mode_command(self) -> &'static str { + match self { + Self::Low => "mode low input", + Self::High => "mode high input", + } + } + + fn range(self) -> (u64, u64) { + match self { + Self::Low => (MIN_FREQUENCY_HZ, BASIC_LOW_MAX_HZ), + Self::High => (BASIC_HIGH_MIN_HZ, BASIC_HIGH_MAX_HZ), + } + } +} + +#[derive(Clone, Copy)] +struct ScanSettings { + points: u32, + rbw_khz: Option, + spur: SpurMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SpurMode { + On, + Auto, +} + +pub fn list(selector: Option<&str>) -> Vec { + let (path, input) = match selector { + Some(selector) => match parse_selector(selector) { + Ok(selection) => selection, + Err(error) => { + eprintln!("tinySA: invalid device selector: {error}"); + return Vec::new(); + } + }, + None => (None, None), + }; + if let Some(path) = path { + let input_label = input + .map(|input| format!(" ยท {} input", input.label())) + .unwrap_or_default(); + return vec![DeviceListing { + kind: crate::hardware::DeviceKind::TinySa, + index: 0, + label: format!("tinySA ยท {}{input_label}", path.display()), + serial: None, + args: None, + path: Some(path), + tiny_sa_input: input, + }]; + } + let mut devices = discovery::list(); + for device in &mut devices { + device.tiny_sa_input = input; + if let Some(input) = input { + device + .label + .push_str(&format!(" ยท {} input", input.label())); + } + } + devices +} + +pub fn parse_selector(selector: &str) -> anyhow::Result<(Option, Option)> { + let (path, input) = match selector.rsplit_once("?input=") { + Some((path, input)) => (path, Some(BasicInput::parse(input)?)), + None => (selector, None), + }; + if path.contains('?') { + bail!("tinySA selector only supports '?input=low' or '?input=high'"); + } + Ok(((!path.is_empty()).then(|| PathBuf::from(path)), input)) +} + +pub struct TinySaDevice { + caps: DeviceCapabilities, + info: DeviceInfo, + notes: Vec, + command_tx: Sender, + worker: Mutex>>, +} + +impl TinySaDevice { + pub fn open(path: &Path, basic_input: BasicInput) -> anyhow::Result { + let port = serialport::new(path.to_string_lossy(), 115_200) + .data_bits(DataBits::Eight) + .stop_bits(StopBits::One) + .parity(Parity::None) + .flow_control(FlowControl::None) + .timeout(READ_TIMEOUT) + .open() + .with_context(|| format!("failed to open tinySA at {}", path.display()))?; + let (command_tx, command_rx) = crossbeam_channel::unbounded(); + let (init_tx, init_rx) = bounded(1); + let worker = thread::Builder::new() + .name("tinysa-serial".to_string()) + .spawn(move || worker_entry(port, command_rx, init_tx, basic_input)) + .context("failed to start tinySA serial worker")?; + let initialized = match init_rx.recv() { + Ok(Ok(initialized)) => initialized, + Ok(Err(error)) => { + let _ = worker.join(); + return Err(error); + } + Err(_) => { + let _ = worker.join(); + bail!("tinySA serial worker stopped during initialization"); + } + }; + let caps = capabilities(initialized.identity.model, basic_input); + let notes = initialized + .identity + .hardware + .iter() + .cloned() + .collect::>(); + let info = DeviceInfo { + board_name: initialized.identity.board.clone(), + serial: path.display().to_string(), + stack: Some(SoftwareStack { + label: "tinysa fw ", + value: Arc::from(initialized.identity.firmware.as_str()), + }), + ..DeviceInfo::default() + }; + Ok(Self { + caps, + info, + notes, + command_tx, + worker: Mutex::new(Some(worker)), + }) + } + + fn request( + &self, + make_command: impl FnOnce(Sender>) -> Command, + ) -> anyhow::Result { + let (reply_tx, reply_rx) = bounded(1); + self.command_tx + .send(make_command(reply_tx)) + .map_err(|_| anyhow!("tinySA serial worker is not running"))?; + reply_rx + .recv() + .map_err(|_| anyhow!("tinySA serial worker stopped without replying"))? + } +} + +impl SdrDevice for TinySaDevice { + fn capabilities(&self) -> &DeviceCapabilities { + &self.caps + } + + fn info(&self) -> DeviceInfo { + self.info.clone() + } + + fn start_rx(&self, ctx: Arc) -> anyhow::Result<()> { + self.request(|reply| Command::Start(ctx, reply)) + } + + fn stop_rx(&self) -> anyhow::Result<()> { + self.request(Command::Stop) + } + + fn is_streaming(&self) -> bool { + self.request(Command::IsStreaming).unwrap_or(false) + } + + fn set_frequency(&self, hz: u64) -> anyhow::Result<()> { + self.request(|reply| Command::SetFrequency(hz, reply)) + } + + fn set_sample_rate(&self, hz: f64) -> anyhow::Result { + self.request(|reply| Command::SetSpan(hz, reply)) + } + + fn set_lna_gain(&self, _db: u32) -> anyhow::Result<()> { + self.request(Command::NoOp) + } + + fn open_notes(&self) -> &[String] { + &self.notes + } +} + +impl Drop for TinySaDevice { + fn drop(&mut self) { + let (reply_tx, reply_rx) = bounded(1); + let _ = self.command_tx.send(Command::Shutdown(reply_tx)); + let _ = reply_rx.recv_timeout(Duration::from_secs(2)); + if let Some(worker) = self + .worker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + let _ = worker.join(); + } + } +} + +enum Command { + Start(Arc, UnitReply), + Stop(UnitReply), + IsStreaming(Sender>), + SetFrequency(u64, UnitReply), + SetSpan(f64, Sender>), + NoOp(UnitReply), + Shutdown(UnitReply), +} + +struct Initialized { + identity: Identity, +} + +struct Worker { + port: Box, + command_rx: Receiver, + identity: Identity, + settings: ScanSettings, + basic_input: BasicInput, + center_hz: u64, + span_hz: u64, + rx_context: Option>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Segment { + start_hz: u64, + stop_hz: u64, + points: u32, +} + +enum ScanResult { + Complete { + frequencies_hz: Vec, + levels_dbm: Vec, + effective_center_hz: u64, + effective_span_hz: u64, + }, + Interrupted(Command, anyhow::Result<()>), +} + +enum ByteEvent { + Byte(u8), + Command(Command), +} + +#[derive(Clone, Copy)] +enum ScanDrain { + Records(u32), + FrameClosed, +} + +fn worker_entry( + mut port: Box, + command_rx: Receiver, + init_tx: Sender>, + basic_input: BasicInput, +) { + let (identity, settings) = match initialize(&mut *port, basic_input) { + Ok(value) => value, + Err(error) => { + let _ = init_tx.send(Err(error)); + return; + } + }; + if init_tx + .send(Ok(Initialized { + identity: identity.clone(), + })) + .is_err() + { + return; + } + let center_hz = default_frequency(identity.model, basic_input); + Worker { + port, + command_rx, + identity, + settings, + center_hz, + span_hz: DEFAULT_SPAN_HZ, + basic_input, + rx_context: None, + } + .run(); +} + +impl Worker { + fn run(mut self) { + loop { + if self.rx_context.is_none() { + match self.command_rx.recv() { + Ok(command) => { + if self.handle_command(command) { + continue; + } + return; + } + Err(_) => return, + } + } + + match self.scan_once() { + Ok(ScanResult::Complete { + frequencies_hz, + levels_dbm, + effective_center_hz, + effective_span_hz, + }) => { + if let Some(context) = &self.rx_context { + let published = context + .power_tx + .try_send(PowerTrace { + frequencies_hz, + levels_dbm, + rbw_hz: self + .settings + .rbw_khz + .map(|khz| (khz * 1_000.0).round() as u32), + }) + .is_ok(); + if published { + let mut metrics = context + .metrics + .lock() + .unwrap_or_else(|error| error.into_inner()); + metrics.radio.frequency = effective_center_hz; + metrics.radio.config_sample_rate = effective_span_hz as f64; + } + } + if !self.drain_commands() { + return; + } + } + Ok(ScanResult::Interrupted(command, abort_result)) => { + if let Err(error) = abort_result { + let message = error.to_string(); + self.stop_acquisition(&message); + reject_command(command, anyhow!(message)); + return; + } + if !self.handle_command(command) || !self.drain_commands() { + return; + } + } + Err(error) => { + best_effort_abort(&mut *self.port); + self.stop_acquisition(&error.to_string()); + return; + } + } + } + } + + fn drain_commands(&mut self) -> bool { + while let Ok(command) = self.command_rx.try_recv() { + if !self.handle_command(command) { + return false; + } + } + true + } + + fn handle_command(&mut self, command: Command) -> bool { + match command { + Command::Start(context, reply) => { + let result = if self.rx_context.is_some() { + Err(anyhow!("tinySA acquisition is already running")) + } else { + self.rx_context = Some(context); + Ok(()) + }; + let _ = reply.send(result); + } + Command::Stop(reply) => { + self.rx_context = None; + let _ = reply.send(Ok(())); + } + Command::IsStreaming(reply) => { + let _ = reply.send(Ok(self.rx_context.is_some())); + } + Command::SetFrequency(hz, reply) => { + let (minimum, maximum) = frequency_range(self.identity.model, self.basic_input); + self.center_hz = hz.clamp(minimum, maximum); + let _ = reply.send(Ok(())); + } + Command::SetSpan(hz, reply) => { + let (minimum, maximum) = frequency_range(self.identity.model, self.basic_input); + let maximum = maximum - minimum; + let result = normalize_span(hz, maximum, self.settings.points).map(|span_hz| { + self.span_hz = span_hz; + RateSet::new( + hz, + Some(self.span_hz as f64), + self.settings + .rbw_khz + .map(|khz| (khz * 1_000.0).round() as u32) + .unwrap_or(0), + ) + }); + let _ = reply.send(result); + } + Command::NoOp(reply) => { + let _ = reply.send(Ok(())); + } + Command::Shutdown(reply) => { + self.rx_context = None; + let _ = reply.send(Ok(())); + return false; + } + } + true + } + + fn stop_acquisition(&mut self, message: &str) { + if let Some(context) = self.rx_context.take() { + let mut metrics = context + .metrics + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + metrics.radio.hw_streaming = false; + metrics.push_log(format!("tinySA scan error: {message}")); + } + } + + 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 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 { + bail!("tinySA scan span is too narrow for {points} points"); + } + let mut frequencies_hz = Vec::with_capacity(points as usize); + let mut levels_dbm = Vec::with_capacity(points as usize); + let segment = Segment { + start_hz, + stop_hz, + points, + }; + let inactivity_timeout = + scan_inactivity_timeout(segment, self.identity.model, self.settings)?; + match scan_segment( + &mut *self.port, + &self.command_rx, + segment, + self.identity.zero_dbm, + inactivity_timeout, + )? { + ScanResult::Complete { + frequencies_hz: segment_frequencies, + levels_dbm: segment_levels, + .. + } => { + frequencies_hz.extend(segment_frequencies); + levels_dbm.extend(segment_levels); + } + interrupted => return Ok(interrupted), + } + let frequencies_hz = display_frequencies(&frequencies_hz)?; + Ok(ScanResult::Complete { + frequencies_hz, + levels_dbm, + effective_center_hz: self.center_hz, + effective_span_hz: self.span_hz, + }) + } +} + +fn initialize( + port: &mut dyn SerialPort, + basic_input: BasicInput, +) -> anyhow::Result<(Identity, ScanSettings)> { + best_effort_abort(port); + drain_startup(port)?; + let mut last_error = None; + let mut version = None; + for _ in 0..3 { + match send_text_command(port, "version") { + Ok(response) + if String::from_utf8_lossy(&response) + .to_ascii_lowercase() + .contains("tinysa") => + { + version = Some(response); + break; + } + Ok(_) => last_error = Some(anyhow!("serial device did not identify as a tinySA")), + Err(error) => last_error = Some(error), + } + let _ = drain_startup(port); + } + let version = version + .ok_or_else(|| last_error.unwrap_or_else(|| anyhow!("tinySA version probe failed")))?; + send_text_command(port, "output off")?; + let info = send_text_command(port, "info")?; + let help = send_text_command(port, "help")?; + let zero = send_text_command(port, "zero")?; + let identity = protocol::parse_identity(&version, &info, &help, &zero)?; + if identity.model.is_ultra() && basic_input == BasicInput::High { + bail!("tinySA HIGH input selection applies only to the basic model"); + } + send_text_command(port, "abort on")?; + send_text_command(port, input_mode_command(identity.model, basic_input))?; + let (settings, _) = startup_settings(identity.model); + apply_scan_settings(port, identity.model)?; + Ok((identity, settings)) +} + +fn apply_scan_settings(port: &mut dyn SerialPort, model: Model) -> anyhow::Result<()> { + for command in startup_settings(model).1 { + send_text_command(port, command)?; + } + Ok(()) +} + +fn best_effort_abort(port: &mut dyn SerialPort) { + let _ = port.write_all(b"abort\r"); + let _ = port.flush(); +} + +fn input_mode_command(model: Model, basic_input: BasicInput) -> &'static str { + if model.is_ultra() { + "mode input" + } else { + basic_input.mode_command() + } +} + +fn send_text_command(port: &mut dyn SerialPort, command: &str) -> anyhow::Result> { + port.write_all(command.as_bytes()) + .with_context(|| format!("failed to write tinySA {command} command"))?; + port.write_all(b"\r") + .with_context(|| format!("failed to terminate tinySA {command} command"))?; + port.flush() + .with_context(|| format!("failed to flush tinySA {command} command"))?; + let frame = read_until_prompt(port, RESPONSE_TIMEOUT)?; + protocol::parse_text_frame(&frame, command) +} + +fn read_until_prompt( + port: &mut dyn SerialPort, + inactivity_timeout: Duration, +) -> anyhow::Result> { + let mut response = Vec::new(); + let mut last_byte = Instant::now(); + loop { + let mut byte = [0u8; 1]; + match port.read(&mut byte) { + Ok(1) => { + response.push(byte[0]); + last_byte = Instant::now(); + if response.ends_with(PROMPT) { + return Ok(response); + } + if response.len() > MAX_RESPONSE_BYTES { + bail!("tinySA response exceeded {MAX_RESPONSE_BYTES} bytes"); + } + } + Ok(0) => bail!("tinySA disconnected while returning a response"), + Ok(_) => unreachable!(), + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { + if last_byte.elapsed() >= inactivity_timeout { + bail!("timed out waiting for the tinySA shell prompt"); + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error).context("failed to read tinySA response"), + } + } +} + +fn drain_startup(port: &mut dyn SerialPort) -> anyhow::Result<()> { + let started = Instant::now(); + let mut last_byte = Instant::now(); + let mut buffer = [0u8; 256]; + loop { + match port.read(&mut buffer) { + Ok(count) if count > 0 => last_byte = Instant::now(), + Ok(_) => return Ok(()), + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { + if last_byte.elapsed() >= DRAIN_QUIET { + return Ok(()); + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error).context("failed to drain tinySA startup output"), + } + if started.elapsed() >= RESPONSE_TIMEOUT { + bail!("tinySA startup output did not become idle"); + } + } +} + +fn scan_segment( + port: &mut dyn SerialPort, + command_rx: &Receiver, + segment: Segment, + zero_dbm: i32, + inactivity_timeout: Duration, +) -> anyhow::Result { + let command = format!( + "scanraw {} {} {} 1", + segment.start_hz, segment.stop_hz, segment.points + ); + if let Some(command) = poll_scan_command(command_rx)? { + return Ok(ScanResult::Interrupted(command, Ok(()))); + } + port.write_all(command.as_bytes()) + .context("failed to write tinySA scan command")?; + port.write_all(b"\r") + .context("failed to terminate tinySA scan command")?; + port.flush() + .context("failed to flush tinySA scan command")?; + let mut expected = command.into_bytes(); + expected.extend_from_slice(b"\r\n{"); + let mut frame = Vec::with_capacity(2 + segment.points as usize * 3); + for (index, expected_byte) in expected.into_iter().enumerate() { + let byte = next_port_byte(port, inactivity_timeout)?; + if byte != expected_byte { + bail!( + "tinySA scan prelude byte {index} was 0x{byte:02x}, expected 0x{expected_byte:02x}" + ); + } + } + frame.push(b'{'); + for index in 0..segment.points { + let tag = match next_scan_byte(port, command_rx, inactivity_timeout)? { + ByteEvent::Byte(byte) => byte, + ByteEvent::Command(command) => { + return Ok(ScanResult::Interrupted( + command, + abort_active_scan( + port, + ScanDrain::Records(segment.points - index), + inactivity_timeout, + ), + )) + } + }; + if tag == b'}' { + bail!( + "tinySA scan closed after {index} of {} records", + segment.points + ); + } + if tag != b'x' { + bail!("tinySA scan record {index} has malformed tag 0x{tag:02x}"); + } + frame.push(tag); + for _ in 0..2 { + frame.push(next_port_byte(port, inactivity_timeout)?); + } + } + let close = match next_scan_byte(port, command_rx, inactivity_timeout)? { + ByteEvent::Byte(byte) => byte, + ByteEvent::Command(command) => { + return Ok(ScanResult::Interrupted( + command, + abort_active_scan(port, ScanDrain::Records(0), inactivity_timeout), + )) + } + }; + frame.push(close); + let levels_dbm = protocol::parse_scan_frame(&frame, segment.points, zero_dbm)?; + for expected_byte in PROMPT { + match next_scan_byte(port, command_rx, inactivity_timeout)? { + ByteEvent::Byte(byte) if byte == *expected_byte => {} + ByteEvent::Byte(byte) => bail!( + "tinySA emitted text after a scan frame: 0x{byte:02x} before the shell prompt" + ), + ByteEvent::Command(command) => { + return Ok(ScanResult::Interrupted( + command, + abort_active_scan(port, ScanDrain::FrameClosed, inactivity_timeout), + )) + } + } + } + Ok(ScanResult::Complete { + frequencies_hz: protocol::scan_frequencies( + segment.start_hz, + segment.stop_hz, + segment.points, + ), + levels_dbm, + effective_center_hz: window_center(segment.start_hz, segment.stop_hz), + effective_span_hz: segment.stop_hz - segment.start_hz, + }) +} + +fn next_scan_byte( + port: &mut dyn SerialPort, + command_rx: &Receiver, + inactivity_timeout: Duration, +) -> anyhow::Result { + let last_byte = Instant::now(); + loop { + if let Some(command) = poll_scan_command(command_rx)? { + return Ok(ByteEvent::Command(command)); + } + let mut byte = [0u8; 1]; + match port.read(&mut byte) { + Ok(1) => return Ok(ByteEvent::Byte(byte[0])), + Ok(0) => bail!("tinySA disconnected during a scan"), + Ok(_) => unreachable!(), + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { + if last_byte.elapsed() >= inactivity_timeout { + bail!("tinySA scan timed out"); + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error).context("failed to read tinySA scan"), + } + } +} + +fn poll_scan_command(command_rx: &Receiver) -> anyhow::Result> { + loop { + match command_rx.try_recv() { + Ok(Command::IsStreaming(reply)) => { + let _ = reply.send(Ok(true)); + } + Ok(command) => return Ok(Some(command)), + Err(TryRecvError::Disconnected) => bail!("tinySA command channel disconnected"), + Err(TryRecvError::Empty) => return Ok(None), + } + } +} + +fn next_port_byte(port: &mut dyn SerialPort, inactivity_timeout: Duration) -> anyhow::Result { + let last_byte = Instant::now(); + loop { + let mut byte = [0u8; 1]; + match port.read(&mut byte) { + Ok(1) => return Ok(byte[0]), + Ok(0) => bail!("tinySA disconnected during a scan"), + Ok(_) => unreachable!(), + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { + if last_byte.elapsed() >= inactivity_timeout { + bail!("tinySA scan timed out"); + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error).context("failed to read tinySA scan"), + } + } +} + +fn abort_active_scan

( + port: &mut P, + progress: ScanDrain, + inactivity_timeout: Duration, +) -> anyhow::Result<()> +where + P: Read + Write + ?Sized, +{ + const ABORT_REPLY: &[u8] = b"abort\r\nch> "; + + port.write_all(b"abort\r") + .context("failed to send tinySA scan abort")?; + port.flush().context("failed to flush tinySA scan abort")?; + let mut records = match progress { + ScanDrain::Records(records) => Some(records), + ScanDrain::FrameClosed => None, + }; + let mut abort_reply_at = 0; + let mut bytes_read = 0usize; + let mut last_byte = Instant::now(); + + while records.is_some() || abort_reply_at < ABORT_REPLY.len() { + let byte = read_abort_byte(port, inactivity_timeout, &mut last_byte, &mut bytes_read)?; + if let Some(remaining) = records { + match byte { + b'x' if remaining > 0 => { + read_abort_byte(port, inactivity_timeout, &mut last_byte, &mut bytes_read)?; + read_abort_byte(port, inactivity_timeout, &mut last_byte, &mut bytes_read)?; + records = Some(remaining - 1); + } + b'}' => records = None, + b'a' => { + for expected in &ABORT_REPLY[1..] { + let byte = read_abort_byte( + port, + inactivity_timeout, + &mut last_byte, + &mut bytes_read, + )?; + if byte != *expected { + bail!("tinySA abort acknowledgement was malformed"); + } + } + abort_reply_at = ABORT_REPLY.len(); + } + _ => bail!("tinySA aborted scan frame was malformed"), + } + } else if byte == ABORT_REPLY[abort_reply_at] { + abort_reply_at += 1; + } else { + abort_reply_at = usize::from(byte == ABORT_REPLY[0]); + } + } + Ok(()) +} + +fn read_abort_byte

( + port: &mut P, + inactivity_timeout: Duration, + last_byte: &mut Instant, + bytes_read: &mut usize, +) -> anyhow::Result +where + P: Read + ?Sized, +{ + loop { + let mut byte = [0u8; 1]; + match port.read(&mut byte) { + Ok(1) => { + *last_byte = Instant::now(); + *bytes_read += 1; + if *bytes_read > MAX_RESPONSE_BYTES { + bail!("tinySA abort drain exceeded {MAX_RESPONSE_BYTES} bytes"); + } + return Ok(byte[0]); + } + Ok(0) => bail!("tinySA disconnected while aborting a scan"), + Ok(_) => unreachable!(), + Err(error) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { + if last_byte.elapsed() >= inactivity_timeout { + bail!("timed out draining the tinySA aborted scan"); + } + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error).context("failed to drain tinySA aborted scan"), + } + } +} + +fn reject_command(command: Command, error: anyhow::Error) { + let message = error.to_string(); + match command { + Command::Start(_, reply) + | Command::Stop(reply) + | Command::SetFrequency(_, reply) + | Command::NoOp(reply) + | Command::Shutdown(reply) => { + let _ = reply.send(Err(anyhow!(message))); + } + Command::IsStreaming(reply) => { + let _ = reply.send(Err(anyhow!(message))); + } + Command::SetSpan(_, reply) => { + let _ = reply.send(Err(anyhow!(message))); + } + } +} + +fn capabilities(model: Model, basic_input: BasicInput) -> DeviceCapabilities { + let (minimum_hz, maximum_hz) = frequency_range(model, basic_input); + DeviceCapabilities { + acquisition: AcquisitionKind::PowerTrace, + sample_rate_is_span: true, + level_unit: LevelUnit::Dbm, + level_min_db: -120.0, + level_max_db: 20.0, + trace_stale_ms: 5_000, + freq_min_hz: minimum_hz, + freq_max_hz: maximum_hz, + sample_rate_min_hz: DEFAULT_POINTS as f64, + sample_rate_max_hz: (maximum_hz - minimum_hz) as f64, + default_frequency_hz: default_frequency(model, basic_input), + default_sample_rate_hz: DEFAULT_SPAN_HZ as f64, + sample_geometry: SampleGeometry { + format: SampleFormat::Int8, + full_scale: 1.0, + }, + gain: GainModel::new(Vec::new(), "RF", "RF").with_gauge_fallback(0), + samples_per_transfer: 0, + has_bb_filter: true, + friis_applicable: false, + delivery: DeliveryModel::Pull, + } +} + +fn frequency_range(model: Model, basic_input: BasicInput) -> (u64, u64) { + if model.is_ultra() { + (MIN_FREQUENCY_HZ, model.maximum_hz()) + } else { + basic_input.range() + } +} + +fn default_frequency(model: Model, basic_input: BasicInput) -> u64 { + let (minimum_hz, maximum_hz) = frequency_range(model, basic_input); + DEFAULT_FREQUENCY_HZ.clamp(minimum_hz, maximum_hz) +} + +fn centered_window(center_hz: u64, span_hz: u64, minimum_hz: u64, maximum_hz: u64) -> (u64, u64) { + let span_hz = span_hz.min(maximum_hz - minimum_hz); + let mut start_hz = center_hz.saturating_sub(span_hz / 2); + let mut stop_hz = start_hz.saturating_add(span_hz); + if start_hz < minimum_hz { + start_hz = minimum_hz; + stop_hz = minimum_hz + span_hz; + } + + if stop_hz > maximum_hz { + stop_hz = maximum_hz; + start_hz = maximum_hz - span_hz; + } + (start_hz, stop_hz) +} + +fn window_center(start_hz: u64, stop_hz: u64) -> u64 { + start_hz + (stop_hz - start_hz) / 2 +} + +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"); + } + Ok((hz.round() as u64).clamp(points as u64, maximum_hz)) +} + +fn display_frequencies(measured_hz: &[u64]) -> anyhow::Result> { + let first = *measured_hz + .first() + .context("tinySA scan returned no frequencies")?; + let last = *measured_hz + .last() + .context("tinySA scan returned no frequencies")?; + if measured_hz.windows(2).any(|pair| pair[1] <= pair[0]) { + bail!("tinySA scan returned duplicate or descending frequencies"); + } + let intervals = measured_hz.len().saturating_sub(1) as u64; + let span = last - first; + if intervals == 0 || span < intervals { + bail!( + "tinySA scan span is too narrow for {} points", + measured_hz.len() + ); + } + Ok((0..measured_hz.len()) + .map(|index| { + let offset = (span as u128 * index as u128 + intervals as u128 / 2) / intervals as u128; + first + offset as u64 + }) + .collect()) +} + +fn startup_settings(model: Model) -> (ScanSettings, Vec<&'static str>) { + let spur = if model.is_ultra() { + SpurMode::Auto + } else { + SpurMode::On + }; + let mut commands = Vec::new(); + if model.is_ultra() { + commands.extend(["ultra on", "ultra auto"]); + } + commands.extend(["rbw auto", "attenuate auto"]); + if model.is_ultra() { + commands.extend(["lna off", "lna2 auto", "agc auto", "spur auto"]); + } else { + commands.push("spur on"); + } + ( + ScanSettings { + points: DEFAULT_POINTS, + rbw_khz: None, + spur, + }, + commands, + ) +} + +fn scan_inactivity_timeout( + segment: Segment, + model: Model, + settings: ScanSettings, +) -> anyhow::Result { + let span_hz = segment.stop_hz.saturating_sub(segment.start_hz) as f64; + let (minimum_rbw, maximum_rbw) = if model.is_ultra() { + (0.2, 850.0) + } else { + (3.0, 600.0) + }; + let rbw_khz = settings + .rbw_khz + .unwrap_or_else(|| (span_hz * 7e-6).clamp(minimum_rbw, maximum_rbw)); + let points = segment.points.max(1) as f64; + let mut total_seconds = (span_hz / 20_000.0) / rbw_khz.powi(2) + points / 500.0; + if (settings.spur == SpurMode::On && segment.stop_hz > 800_000_000) + || settings.spur == SpurMode::Auto + { + total_seconds *= 2.0; + } + let block_seconds = total_seconds * 20.0 / points + 1.0; + Ok(Duration::from_secs_f64( + block_seconds.max(RESPONSE_TIMEOUT.as_secs_f64()), + )) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::io; + + use super::*; + + enum ReadStep { + Bytes(VecDeque), + Delay(Duration), + FrameClose, + } + + struct ScriptedPeer { + reads: VecDeque, + writes: Vec, + frame_closed: bool, + } + + impl ScriptedPeer { + fn new(steps: impl IntoIterator) -> Self { + Self { + reads: steps.into_iter().collect(), + writes: Vec::new(), + frame_closed: false, + } + } + } + + impl Read for ScriptedPeer { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + loop { + match self.reads.front_mut() { + Some(ReadStep::Bytes(bytes)) => { + let Some(byte) = bytes.pop_front() else { + self.reads.pop_front(); + continue; + }; + buffer[0] = byte; + return Ok(1); + } + Some(ReadStep::Delay(duration)) => { + let duration = *duration; + self.reads.pop_front(); + std::thread::sleep(duration); + return Err(io::Error::new(ErrorKind::TimedOut, "scripted delay")); + } + Some(ReadStep::FrameClose) => { + self.reads.pop_front(); + self.frame_closed = true; + buffer[0] = b'}'; + return Ok(1); + } + None => return Err(io::Error::new(ErrorKind::TimedOut, "script exhausted")), + } + } + } + } + + impl Write for ScriptedPeer { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if buffer == b"next\r" { + assert!( + self.frame_closed, + "next request preceded the old frame close" + ); + } + self.writes.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn bytes(value: &[u8]) -> ReadStep { + ReadStep::Bytes(value.iter().copied().collect()) + } + + #[test] + fn cancellation_waits_for_the_delayed_frame_close_before_the_next_request() { + let mut peer = ScriptedPeer::new([ + bytes(b"x}xxch"), + bytes(b"abort\r\nch> "), + ReadStep::Delay(Duration::from_millis(225)), + ReadStep::FrameClose, + ]); + let started = Instant::now(); + + abort_active_scan(&mut peer, ScanDrain::Records(2), Duration::from_secs(1)).unwrap(); + peer.write_all(b"next\r").unwrap(); + + assert!(started.elapsed() >= Duration::from_millis(200)); + assert_eq!(peer.writes, b"abort\rnext\r"); + } + + #[test] + fn cancellation_after_the_frame_waits_only_for_the_abort_reply() { + let mut peer = ScriptedPeer::new([ + bytes(PROMPT), + ReadStep::Delay(Duration::from_millis(225)), + bytes(b"abort\r\nch> "), + ]); + let started = Instant::now(); + + abort_active_scan(&mut peer, ScanDrain::FrameClosed, Duration::from_secs(1)).unwrap(); + + assert!(started.elapsed() >= Duration::from_millis(200)); + assert_eq!(peer.writes, b"abort\r"); + } + + #[test] + fn cancellation_times_out_when_the_active_frame_never_closes() { + let mut peer = ScriptedPeer::new([bytes(b"abort\r\nch> ")]); + + assert!( + abort_active_scan(&mut peer, ScanDrain::Records(1), Duration::from_millis(10),) + .unwrap_err() + .to_string() + .contains("timed out") + ); + } + + #[test] + fn basic_inputs_expose_only_their_physical_connector_range() { + assert_eq!( + frequency_range(Model::Basic, BasicInput::Low), + (100_000, 350_000_000) + ); + assert_eq!( + frequency_range(Model::Basic, BasicInput::High), + (240_000_000, 959_000_000) + ); + assert_eq!( + default_frequency(Model::Basic, BasicInput::High), + 240_000_000 + ); + } + + #[test] + fn startup_uses_safe_automatic_controls() { + let (ultra, commands) = startup_settings(Model::Zs407); + assert_eq!(ultra.points, DEFAULT_POINTS); + assert_eq!(ultra.rbw_khz, None); + assert_eq!( + commands, + [ + "ultra on", + "ultra auto", + "rbw auto", + "attenuate auto", + "lna off", + "lna2 auto", + "agc auto", + "spur auto", + ] + ); + let (basic, commands) = startup_settings(Model::Basic); + assert_eq!(basic.spur, SpurMode::On); + assert_eq!(commands, ["rbw auto", "attenuate auto", "spur on"]); + } + + #[test] + fn centered_windows_keep_the_requested_span_inside_the_model_range() { + let low_edge = centered_window(100_000, 10_000_000, 100_000, 960_000_000); + assert_eq!(low_edge, (100_000, 10_100_000)); + assert_eq!(low_edge.0 + (low_edge.1 - low_edge.0) / 2, 5_100_000); + assert_eq!( + centered_window(960_000_000, 10_000_000, 100_000, 960_000_000), + (950_000_000, 960_000_000) + ); + } + + #[test] + fn a_span_too_narrow_for_the_point_count_is_clamped() { + assert_eq!( + normalize_span(1.0, 959_900_000, DEFAULT_POINTS).unwrap(), + DEFAULT_POINTS as u64 + ); + let frequencies = + protocol::scan_frequencies(100_000, 100_000 + DEFAULT_POINTS as u64, DEFAULT_POINTS); + assert!(frequencies.windows(2).all(|pair| pair[1] > pair[0])); + } + + #[test] + fn firmware_frequencies_use_an_endpoint_preserving_display_grid() { + let measured = protocol::scan_frequencies(100_000_000, 200_000_000, 450); + assert!( + measured.windows(2).map(|pair| pair[1] - pair[0]).min() + != measured.windows(2).map(|pair| pair[1] - pair[0]).max() + ); + let displayed = display_frequencies(&measured).unwrap(); + assert_eq!(displayed.first(), measured.first()); + assert_eq!(displayed.last(), measured.last()); + let span = displayed.last().unwrap() - displayed.first().unwrap(); + let intervals = displayed.len() as u64 - 1; + let lower_step = span / intervals; + let upper_step = span.div_ceil(intervals); + assert!(displayed.windows(2).all(|pair| { + let step = pair[1] - pair[0]; + step == lower_step || step == upper_step + })); + } + + #[test] + fn display_grid_rejects_duplicate_firmware_frequencies() { + assert!(display_frequencies(&[100_000, 100_000, 100_001]).is_err()); + assert!(display_frequencies(&[100_002, 100_001, 100_000]).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); + let effective_center = window_center(start, stop); + assert_eq!(effective_center, 5_100_000); + assert_eq!( + centered_window(effective_center, 1_000_000, 100_000, 960_000_000), + (4_600_000, 5_600_000) + ); + } + + #[test] + fn unknown_ultra_uses_the_conservative_zs405_range() { + assert_eq!(Model::UltraUnknown.maximum_hz(), 6_000_000_000); + } + + #[test] + fn startup_forces_every_model_into_input_mode() { + assert_eq!( + input_mode_command(Model::Basic, BasicInput::Low), + "mode low input" + ); + assert_eq!( + input_mode_command(Model::Basic, BasicInput::High), + "mode high input" + ); + for model in [ + Model::Zs405, + Model::Zs406, + Model::Zs407, + Model::UltraUnknown, + ] { + assert_eq!(input_mode_command(model, BasicInput::High), "mode input"); + } + } + + #[test] + fn selector_keeps_the_path_and_basic_input_separate() { + assert_eq!( + parse_selector("/dev/ttyACM2").unwrap(), + (Some(PathBuf::from("/dev/ttyACM2")), None) + ); + assert_eq!( + parse_selector("/dev/ttyACM2?input=high").unwrap(), + (Some(PathBuf::from("/dev/ttyACM2")), Some(BasicInput::High)) + ); + assert_eq!( + parse_selector("?input=high").unwrap(), + (None, Some(BasicInput::High)) + ); + assert!(parse_selector("/dev/ttyACM2?input=other").is_err()); + } + + #[test] + fn only_an_explicit_selector_overrides_the_basic_input() { + let bare = list(Some("/dev/ttyACM2")); + assert_eq!(bare.len(), 1); + assert_eq!(bare[0].tiny_sa_input, None); + + let high = list(Some("/dev/ttyACM2?input=high")); + assert_eq!(high.len(), 1); + assert_eq!(high[0].tiny_sa_input, Some(BasicInput::High)); + } + + #[test] + fn basic_capabilities_match_the_selected_input() { + let low = capabilities(Model::Basic, BasicInput::Low); + assert_eq!((low.freq_min_hz, low.freq_max_hz), (100_000, 350_000_000)); + let high = capabilities(Model::Basic, BasicInput::High); + assert_eq!( + (high.freq_min_hz, high.freq_max_hz), + (240_000_000, 959_000_000) + ); + } + + #[test] + fn narrow_rbw_expands_the_scan_deadline() { + let settings = ScanSettings { + points: DEFAULT_POINTS, + rbw_khz: Some(0.2), + spur: SpurMode::Auto, + }; + let segment = Segment { + start_hz: 400_000_000, + stop_hz: 500_000_000, + points: 450, + }; + let timeout = scan_inactivity_timeout(segment, Model::Zs405, settings).unwrap(); + assert!(timeout > Duration::from_secs(120), "{timeout:?}"); + } + + #[cfg(test)] + mod hardware_tests { + use super::*; + + #[test] + #[ignore = "requires SDRTOP_TINYSA_TEST to name a connected serial port"] + fn connected_device_streams_spectrum_frames() { + let path = std::env::var("SDRTOP_TINYSA_TEST").expect("SDRTOP_TINYSA_TEST is not set"); + let device = TinySaDevice::open(Path::new(&path), BasicInput::Low).unwrap(); + assert!(device + .info() + .board_name + .to_ascii_lowercase() + .contains("tinysa")); + + let state = Arc::new(Mutex::new(crate::state::SdrMetrics::fixture())); + let (sample_tx, _) = crossbeam_channel::bounded(1); + let (demod_tx, _) = crossbeam_channel::bounded(1); + let (net_tx, _) = crossbeam_channel::bounded(1); + let (power_tx, power_rx) = crossbeam_channel::bounded(4); + let context = Arc::new(RxContext { + metrics: Arc::clone(&state), + sample_tx, + fft_feed: crate::hardware::FeedHealth::default(), + demod_tx, + net_tx, + net_feed: crate::hardware::FeedHealth::default(), + power_tx, + geometry: device.capabilities().sample_geometry, + }); + + device.start_rx(context).unwrap(); + let spectrum = power_rx.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(spectrum.frequencies_hz.len(), spectrum.levels_dbm.len()); + assert!(!spectrum.frequencies_hz.is_empty()); + device.stop_rx().unwrap(); + assert!(!device.is_streaming()); + } + } +} diff --git a/src/hardware/tinysa/protocol.rs b/src/hardware/tinysa/protocol.rs new file mode 100644 index 0000000..15928ff --- /dev/null +++ b/src/hardware/tinysa/protocol.rs @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +use anyhow::{bail, Context}; + +pub(super) const PROMPT: &[u8] = b"ch> "; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Model { + Basic, + Zs405, + Zs406, + Zs407, + UltraUnknown, +} + +impl Model { + pub(super) fn is_ultra(self) -> bool { + self != Self::Basic + } + + pub(super) fn maximum_hz(self) -> u64 { + match self { + Self::Basic => 959_000_000, + Self::Zs405 | Self::Zs406 | Self::UltraUnknown => 6_000_000_000, + Self::Zs407 => 7_300_000_000, + } + } + + pub(super) fn name(self) -> &'static str { + match self { + Self::Basic => "tinySA", + Self::Zs405 => "tinySA Ultra ZS405", + Self::Zs406 => "tinySA Ultra ZS406", + Self::Zs407 => "tinySA Ultra ZS407", + Self::UltraUnknown => "tinySA Ultra", + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct Identity { + pub(super) firmware: String, + pub(super) hardware: Option, + pub(super) board: String, + pub(super) zero_dbm: i32, + pub(super) model: Model, +} + +pub(super) fn parse_text_frame(frame: &[u8], command: &str) -> anyhow::Result> { + if !frame.ends_with(PROMPT) { + bail!("tinySA response did not end at the shell prompt"); + } + let without_prompt = &frame[..frame.len() - PROMPT.len()]; + let mut echo = command.as_bytes().to_vec(); + echo.extend_from_slice(b"\r\n"); + if !without_prompt.starts_with(&echo) { + bail!("tinySA response did not echo the command"); + } + let body = &without_prompt[echo.len()..]; + let token = command.split_ascii_whitespace().next().unwrap_or(command); + let unknown = format!("{token}?"); + if String::from_utf8_lossy(body) + .lines() + .any(|line| line.trim() == unknown) + { + bail!("tinySA does not support the {token} command"); + } + Ok(body.to_vec()) +} + +pub(super) fn parse_identity( + version: &[u8], + info: &[u8], + help: &[u8], + zero: &[u8], +) -> anyhow::Result { + let version = std::str::from_utf8(version).context("tinySA version response was not text")?; + let info = std::str::from_utf8(info).context("tinySA info response was not text")?; + std::str::from_utf8(help).context("tinySA help response was not text")?; + let firmware = first_line(version) + .filter(|line| line.to_ascii_lowercase().contains("tinysa")) + .context("serial device did not identify itself as a tinySA")? + .to_string(); + let combined = format!("{version}\n{info}"); + let upper = combined.to_ascii_uppercase(); + let ultra = upper.contains("TINYSA4") || upper.contains("TINYSA ULTRA"); + let model = if !ultra { + Model::Basic + } else if upper.contains("ZS405") || upper.contains("V0.4.5.1.1") || upper.contains("V0.4.5.1") + { + Model::Zs405 + } else if upper.contains("ZS406") || upper.contains("V0.4.6") { + Model::Zs406 + } else if upper.contains("ZS407") || upper.contains("V0.5.4") || upper.contains("MAX2871") { + Model::Zs407 + } else { + Model::UltraUnknown + }; + let hardware = version + .lines() + .map(str::trim) + .find(|line| line.starts_with("HW Version:")) + .map(str::to_string); + let board = first_line(info).unwrap_or_else(|| model.name()).to_string(); + Ok(Identity { + firmware, + hardware, + board, + zero_dbm: parse_zero(zero)?, + model, + }) +} + +fn first_line(value: &str) -> Option<&str> { + value.lines().map(str::trim).find(|line| !line.is_empty()) +} + +pub(super) fn parse_zero(response: &[u8]) -> anyhow::Result { + let response = std::str::from_utf8(response).context("tinySA zero response was not text")?; + response + .lines() + .map(str::trim) + .find_map(|line| { + let number = line + .strip_suffix("dBm") + .or_else(|| line.strip_suffix("DBM"))? + .trim(); + number.parse::().ok() + }) + .context("tinySA zero response did not contain a dBm value") +} + +pub(super) fn parse_scan_frame( + frame: &[u8], + points: u32, + zero_dbm: i32, +) -> anyhow::Result> { + let expected = 1usize + .checked_add(points as usize * 3) + .and_then(|size| size.checked_add(1)) + .context("tinySA scan point count is too large")?; + if frame.first() != Some(&b'{') { + bail!("tinySA scan frame is missing its opening tag"); + } + let mut levels = Vec::with_capacity(points as usize); + let mut offset = 1; + for index in 0..points { + let Some(&tag) = frame.get(offset) else { + bail!("tinySA scan ended after {index} of {points} records"); + }; + if tag == b'}' { + bail!("tinySA scan closed after {index} of {points} records"); + } + if tag != b'x' { + bail!("tinySA scan record {index} has malformed tag 0x{tag:02x}"); + } + let Some(bytes) = frame.get(offset + 1..offset + 3) else { + bail!("tinySA scan record {index} ended early"); + }; + let raw = i16::from_le_bytes([bytes[0], bytes[1]]); + levels.push(raw as f32 / 32.0 - zero_dbm as f32); + offset += 3; + } + if frame.get(offset) != Some(&b'}') { + bail!("tinySA scan frame is missing its closing tag"); + } + if frame.len() != expected { + bail!("tinySA scan frame has trailing data"); + } + Ok(levels) +} + +pub(super) fn scan_frequencies(start_hz: u64, stop_hz: u64, points: u32) -> Vec { + if points == 0 || stop_hz < start_hz { + return Vec::new(); + } + let step = ((stop_hz - start_hz) / points as u64) as f32; + (0..points) + .map(|index| start_hz + (step * index as f32) as u64) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_frames_require_the_echo_and_prompt() { + assert_eq!( + parse_text_frame(b"version\r\ntinySA_v1.4\r\nch> ", "version").unwrap(), + b"tinySA_v1.4\r\n" + ); + assert!(parse_text_frame(b"tinySA_v1.4\r\nch> ", "version").is_err()); + assert!(parse_text_frame(b"version\r\ntinySA_v1.4\r\n", "version").is_err()); + assert!(parse_text_frame(b"frobnicate\r\nfrobnicate?\r\nch> ", "frobnicate").is_err()); + } + + #[test] + fn basic_identity_does_not_need_a_hardware_line() { + let identity = parse_identity( + b"tinySA_v1.4-1-gabc\r\n", + b"tinySA v0.3ZS304\r\nVersion: tinySA_v1.4-1-gabc\r\n", + b"commands: version info help zero scanraw\r\n", + b"zero {level}\r\n128dBm\r\n", + ) + .unwrap(); + assert_eq!(identity.model, Model::Basic); + assert_eq!(identity.hardware, None); + assert_eq!(identity.zero_dbm, 128); + } + + #[test] + fn ultra_models_are_read_from_the_info_response() { + for (suffix, expected) in [ + ("ZS405", Model::Zs405), + ("ZS406", Model::Zs406), + ("ZS407", Model::Zs407), + ("prototype", Model::UltraUnknown), + ] { + let info = format!("tinySA ULTRA {suffix}\r\n"); + let identity = parse_identity( + b"tinySA4_v1.4\r\nHW Version:future\r\n", + info.as_bytes(), + b"commands: scanraw\r\n", + b"174dBm\r\n", + ) + .unwrap(); + assert_eq!(identity.model, expected); + assert_eq!(identity.hardware.as_deref(), Some("HW Version:future")); + } + } + + #[test] + fn ultra_models_fall_back_to_the_hardware_revision() { + for (hardware, expected) in [ + ("V0.4.5.1.1", Model::Zs405), + ("V0.4.6", Model::Zs406), + ("V0.5.4 max2871", Model::Zs407), + ] { + let version = format!("tinySA4_v1.4\r\nHW Version:{hardware}\r\n"); + let identity = parse_identity( + version.as_bytes(), + b"tinySA ULTRA\r\n", + b"commands: scanraw\r\n", + b"174dBm\r\n", + ) + .unwrap(); + assert_eq!(identity.model, expected, "{hardware}"); + } + } + + #[test] + fn zero_parser_accepts_signed_values() { + assert_eq!(parse_zero(b"zero {level}\r\n174dBm\r\n").unwrap(), 174); + assert_eq!(parse_zero(b"-174dBm\r\n").unwrap(), -174); + assert!(parse_zero(b"zero unavailable\r\n").is_err()); + } + + #[test] + fn scan_payload_bytes_are_never_treated_as_tags() { + let frame = [b'{', b'x', b'{', 0, b'x', 0, b'}', b'x', b'x', 0, b'}']; + let levels = parse_scan_frame(&frame, 3, 0).unwrap(); + assert_eq!(levels, vec![123.0 / 32.0, 32000.0 / 32.0, 120.0 / 32.0]); + } + + #[test] + fn negative_raw_levels_keep_their_sign() { + let levels = parse_scan_frame(&[b'{', b'x', 0x40, 0xff, b'}'], 1, 174).unwrap(); + assert_eq!(levels, vec![-180.0]); + } + + #[test] + fn scan_parser_rejects_bad_tags_and_truncation() { + assert!(parse_scan_frame(b"{q\0\0}", 1, 0) + .unwrap_err() + .to_string() + .contains("malformed tag")); + assert!(parse_scan_frame(b"{}", 1, 0) + .unwrap_err() + .to_string() + .contains("closed after")); + assert!(parse_scan_frame(b"{x\0\0", 1, 0) + .unwrap_err() + .to_string() + .contains("closing tag")); + assert!(parse_scan_frame(b"{x\0", 1, 0).is_err()); + assert!(parse_scan_frame(b"{x\0\0}extra", 1, 0) + .unwrap_err() + .to_string() + .contains("trailing data")); + } + + #[test] + fn frequency_mapping_matches_scanraw_integer_then_float_math() { + assert_eq!( + scan_frequencies(100_000_003, 100_001_006, 4), + vec![100_000_003, 100_000_253, 100_000_503, 100_000_753] + ); + let large = scan_frequencies(6_000_000_001, 6_000_100_101, 3); + let step = ((100_100u64 / 3) as f32) as u64; + assert_eq!( + large, + vec![ + 6_000_000_001, + 6_000_000_001 + step, + 6_000_000_001 + 2 * step + ] + ); + assert!(scan_frequencies(10, 9, 3).is_empty()); + assert!(scan_frequencies(10, 20, 0).is_empty()); + } +} diff --git a/src/hardware/traits.rs b/src/hardware/traits.rs index bd0f04e..46d093c 100644 --- a/src/hardware/traits.rs +++ b/src/hardware/traits.rs @@ -12,6 +12,42 @@ use std::sync::{Arc, Mutex}; use crate::state::SdrMetrics; +/// How a backend acquires spectrum data. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcquisitionKind { + /// Complex time-domain samples feed the FFT and diagnostic workers. + IqSamples, + /// The backend publishes calibrated power-spectrum traces. + PowerTrace, +} + +/// Unit carried by spectral levels from a backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LevelUnit { + Dbfs, + #[allow(dead_code)] + Dbm, +} + +pub const IQ_TRACE_STALE_MS: u128 = 500; + +impl LevelUnit { + pub fn label(self) -> &'static str { + match self { + Self::Dbfs => "dBFS", + Self::Dbm => "dBm", + } + } +} + +/// One calibrated power-spectrum trace. +#[derive(Debug)] +pub struct PowerTrace { + pub frequencies_hz: Vec, + pub levels_dbm: Vec, + pub rbw_hz: Option, +} + /// How raw USB bytes encode each I/Q component. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SampleFormat { @@ -616,6 +652,17 @@ pub enum DeliveryModel { /// truth for every clamp, default, and UI capability check. Built once at open. #[derive(Clone, Debug)] pub struct DeviceCapabilities { + pub acquisition: AcquisitionKind, + /// `set_sample_rate` controls a swept span for this device. + pub sample_rate_is_span: bool, + /// Spectral levels and display bounds use this unit + pub level_unit: LevelUnit, + /// The finite display floor must be below `level_max_db` + pub level_min_db: f32, + /// The finite display ceiling must form a finite span with `level_min_db` + pub level_max_db: f32, + /// A trace becomes stale after this positive millisecond budget + pub trace_stale_ms: u128, pub freq_min_hz: u64, pub freq_max_hz: u64, pub sample_rate_min_hz: f64, @@ -650,6 +697,27 @@ pub struct DeviceCapabilities { pub delivery: DeliveryModel, } +impl DeviceCapabilities { + /// Reject invalid display bounds or a zero trace-age budget before initializing views + pub fn validate_display(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.level_min_db.is_finite() + && self.level_max_db.is_finite() + && self.level_min_db < self.level_max_db + && (self.level_max_db - self.level_min_db).is_finite(), + "Invalid display level bounds: {}..{} {}; expected finite ordered bounds with a finite span", + self.level_min_db, + self.level_max_db, + self.level_unit.label(), + ); + anyhow::ensure!( + self.trace_stale_ms > 0, + "Invalid trace_stale_ms: expected a positive millisecond budget", + ); + Ok(()) + } +} + /// The software layer between sdrtop and a radio that has no firmware of its /// own, for the header's firmware field. /// @@ -715,6 +783,9 @@ pub struct RxContext { pub net_tx: crossbeam_channel::Sender, /// What the NET feed did with the blocks handed to it, for the poll task. pub net_feed: FeedHealth, + /// Direct power-spectrum traces from backends that do not publish IQ. + #[allow(dead_code)] + pub power_tx: crossbeam_channel::Sender, pub geometry: SampleGeometry, } diff --git a/src/main.rs b/src/main.rs index c21def0..00da3f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,7 +138,7 @@ async fn main() -> Result<()> { let devices = hardware::list_all_devices(want, soapy_filter.as_deref()); if devices.is_empty() { eprintln!( - "No SDR device found. Connect a HackRF or RTL-SDR and try again.{}", + "No device found. Connect a HackRF, RTL-SDR, or tinySA and try again.{}", hardware::discovery::no_device_hint() ); std::process::exit(1); diff --git a/src/signal/fft/analysis.rs b/src/signal/fft/analysis.rs index 83732d2..47201ee 100644 --- a/src/signal/fft/analysis.rs +++ b/src/signal/fft/analysis.rs @@ -52,12 +52,7 @@ pub(super) fn carrier(linear: &[f32], sample_rate: f64, noise_floor_db: f32) -> /// /// `scratch` is the worker's reused buffer; nothing is allocated here. pub(super) fn noise_floor(smoothed: &[f32], scratch: &mut [f32]) -> f32 { - scratch.copy_from_slice(smoothed); - let count = (smoothed.len() / NOISE_FLOOR_FRACTION).max(1); - scratch.select_nth_unstable_by(count - 1, |a, b| { - a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) - }); - scratch[..count].iter().sum::() / count as f32 + crate::signal::stats::quietest_mean(smoothed, scratch, NOISE_FLOOR_FRACTION) } /// The SNR the worker publishes: the strongest real bin *near centre*, minus the @@ -192,6 +187,18 @@ mod tests { assert_eq!(noise_floor(&[-42.0], &mut scratch), -42.0); } + #[test] + fn finite_iq_noise_floor_keeps_the_quietest_tenth_mean() { + let mut bins = [-20.0; 29]; + bins[7] = -100.0; + bins[19] = -90.0; + bins[25] = -80.0; + let mut scratch = [0.0; 29]; + assert_eq!(noise_floor(&bins, &mut scratch), -95.0); + bins.reverse(); + assert_eq!(noise_floor(&bins, &mut scratch), -95.0); + } + /// `level_db` is the carrier's **total** power, spread evenly across the bins it /// covers - not a per-bin level. That distinction is the whole point: a fixed /// per-bin level would make the carrier's power scale with how many bins the diff --git a/src/signal/fft/frame.rs b/src/signal/fft/frame.rs index 50e3871..b36bc08 100644 --- a/src/signal/fft/frame.rs +++ b/src/signal/fft/frame.rs @@ -125,19 +125,8 @@ pub(super) fn average( decay_db: f32, initialized: &mut bool, ) { - if !*initialized { - smoothed.copy_from_slice(shifted); - peak.copy_from_slice(shifted); - *initialized = true; - return; - } - let one_minus = 1.0 - alpha; - for (s, &new) in smoothed.iter_mut().zip(shifted.iter()) { - *s = alpha * new + one_minus * *s; - } - for (p, &s) in peak.iter_mut().zip(smoothed.iter()) { - *p = (*p - decay_db).max(s); - } + crate::signal::stats::average_and_peak(shifted, smoothed, peak, alpha, decay_db, *initialized); + *initialized = true; } #[cfg(test)] @@ -299,4 +288,45 @@ mod tests { } assert!(s > 0.99, "EMA should converge to target, got {}", s); } + + #[test] + fn finite_iq_sequence_keeps_the_existing_average_and_peak_law() { + let mut smoothed = [0.0]; + let mut peak = [0.0]; + let mut initialized = false; + for sample in [-90.0, -80.0, -100.0] { + average( + &[sample], + &mut smoothed, + &mut peak, + 0.2, + 0.5, + &mut initialized, + ); + } + + assert!((smoothed[0] - -90.4).abs() < 1e-4); + assert!((peak[0] - -88.5).abs() < 1e-4); + } + + #[test] + fn finite_iq_first_frame_and_reset_seed_both_traces() { + let mut smoothed = [-120.0; 2]; + let mut peak = [0.0; 2]; + let mut initialized = false; + for samples in [[-90.0, -80.0], [-100.0, -110.0]] { + average( + &samples, + &mut smoothed, + &mut peak, + 0.2, + 0.5, + &mut initialized, + ); + assert!(initialized); + assert_eq!(smoothed, samples); + assert_eq!(peak, samples); + initialized = false; + } + } } diff --git a/src/signal/fft/publish.rs b/src/signal/fft/publish.rs index 7120753..11ee63b 100644 --- a/src/signal/fft/publish.rs +++ b/src/signal/fft/publish.rs @@ -13,7 +13,7 @@ //! comment, the way `tasks/rx/` makes its two lock blocks visible. use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::state::{FftFrame, SdrMetrics}; @@ -25,10 +25,6 @@ use super::analysis::Reading; /// lockstep. Signal metrics stay at full rate. const ROWS_PER_WATERFALL_LINE: u32 = 2; -/// Never let the drawn frame age past the panels' 500 ms STALE threshold, even at -/// large frames or row strides. -const SPECTRUM_STALE_GUARD: Duration = Duration::from_millis(400); - /// How much of a marker's channel the measured-bandwidth cut-offs bracket. const MARKER_BW_LOW: f32 = 0.005; const MARKER_BW_HIGH: f32 = 0.995; @@ -36,30 +32,32 @@ const MARKER_BW_HIGH: f32 = 0.995; /// The display-paced spectrum refresh's state, carried between frames. pub(super) struct Pacing { rows_since_spectrum: u32, - last_update: Instant, + last_update: Option, } impl Pacing { pub(super) fn new() -> Self { Self { rows_since_spectrum: 0, - last_update: Instant::now() - .checked_sub(SPECTRUM_STALE_GUARD) - .unwrap_or_else(Instant::now), + last_update: None, } } /// Whether to redraw the spectrum: there is none yet, a visible waterfall line /// has gone by, or it would otherwise age toward `[STALE]`. - fn due(&self, has_frame: bool) -> bool { + fn due(&self, has_frame: bool, trace_stale_ms: u128, now: Instant) -> bool { + // Reserve one fifth of the trace-age budget for display scheduling + let guard_ms = trace_stale_ms - trace_stale_ms.div_ceil(5); !has_frame || self.rows_since_spectrum >= ROWS_PER_WATERFALL_LINE - || self.last_update.elapsed() >= SPECTRUM_STALE_GUARD + || self + .last_update + .is_none_or(|last| now.duration_since(last).as_millis() >= guard_ms) } - fn mark(&mut self) { + fn mark(&mut self, now: Instant) { self.rows_since_spectrum = 0; - self.last_update = Instant::now(); + self.last_update = Some(now); } } @@ -118,8 +116,9 @@ pub(super) fn publish( pacing.rows_since_spectrum += 1; } - if pacing.due(m.waterfall.last_fft.is_some()) { - pacing.mark(); + let now = Instant::now(); + if pacing.due(m.waterfall.last_fft.is_some(), m.caps.trace_stale_ms, now) { + pacing.mark(now); refresh_spectrum(&mut m, &snap); } Some(alpha) @@ -248,6 +247,7 @@ fn refresh_spectrum(m: &mut SdrMetrics, snap: &Snapshot<'_>) { channel_power_dbfs: r.channel_power_dbfs, occupied_bw_hz: r.occupied_bw_hz, enbw_hz: snap.enbw_hz, + bin_axis: crate::state::BinAxis::FftBins, }); } @@ -255,6 +255,37 @@ fn refresh_spectrum(m: &mut SdrMetrics, snap: &Snapshot<'_>) { mod tests { use super::*; use crate::state::SpectrumMarker; + use std::time::Duration; + + #[test] + fn pacing_reserves_one_fifth_of_each_trace_age_budget() { + let now = Instant::now(); + let mut pacing = Pacing::new(); + for (budget, guard_ms) in [(500, 400), (100, 80), (5_000, 4_000), (2, 1)] { + pacing.mark(now); + assert!(!pacing.due(true, budget, now + Duration::from_millis(guard_ms - 1))); + assert!(pacing.due(true, budget, now + Duration::from_millis(guard_ms))); + } + assert!(pacing.due(true, 1, now)); + assert!(!pacing.due(true, u128::MAX, now + Duration::from_secs(1))); + } + + #[test] + fn pacing_still_refreshes_initial_frames_and_visible_waterfall_lines() { + let now = Instant::now(); + let mut pacing = Pacing::new(); + assert!(pacing.due(true, 500, now)); + pacing.mark(now); + assert!(pacing.due(false, 500, now)); + assert!(!pacing.due(true, 500, now)); + pacing.rows_since_spectrum = 1; + assert!(!pacing.due(true, 500, now)); + pacing.rows_since_spectrum = 2; + assert!(pacing.due(true, 500, now)); + pacing.mark(now); + assert_eq!(pacing.rows_since_spectrum, 0); + assert!(!pacing.due(true, 500, now)); + } fn marker(freq_hz: u64, channel_bw_hz: u64, measured_bw_hz: Option) -> SpectrumMarker { SpectrumMarker { @@ -404,6 +435,8 @@ mod tests { let published = m.waterfall.last_fft.expect("a frame was published"); assert_eq!(published.bins_dbfs[0], -70.0, "the new trace"); + assert_eq!(published.bin_axis, crate::state::BinAxis::FftBins); + assert_eq!(published.frequency_of_bin(48), Some(100_250_000.0)); let held = being_drawn.waterfall.last_fft.expect("the UI's copy"); assert_eq!(held.bins_dbfs[0], -40.0, "still the frame it was drawing"); } diff --git a/src/signal/mod.rs b/src/signal/mod.rs index aa319e3..99e859c 100644 --- a/src/signal/mod.rs +++ b/src/signal/mod.rs @@ -10,11 +10,14 @@ pub mod fft; pub mod iq; pub mod net; pub mod noise_slope; +pub mod power; pub mod rds; pub mod rds_demod; +mod stats; pub mod stream; pub use demod::DemodWorker; pub use fft::FftWorker; pub use iq::{corrected_moments, image_rejection_db, iq_correction_coeffs}; pub use net::worker::NetWorker; +pub use power::PowerWorker; diff --git a/src/signal/power.rs b/src/signal/power.rs new file mode 100644 index 0000000..a3be84f --- /dev/null +++ b/src/signal/power.rs @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crossbeam_channel::Receiver; + +use crate::hardware::PowerTrace; +use crate::state::{FftFrame, SdrMetrics}; + +const EMA_ALPHA: f32 = 0.2; +const PEAK_DECAY_DB: f32 = 0.5; +const REJECTION_REPORT_INTERVAL: Duration = Duration::from_secs(5); + +pub struct PowerWorker { + trace_rx: Receiver, + state: Arc>, +} + +impl PowerWorker { + pub fn new(trace_rx: Receiver, state: Arc>) -> Self { + Self { trace_rx, state } + } + + pub fn run(self) { + let mut accumulator = SpectrumAccumulator::default(); + let mut rejection_reporter = RejectionReporter::default(); + while let Ok(trace) = self.trace_rx.recv() { + if let Err(reason) = accumulator.publish(&self.state, trace) { + rejection_reporter.report(&self.state, reason, Instant::now()); + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TraceRejection { + Empty, + LengthMismatch, + NonUniformGrid, +} + +impl TraceRejection { + fn message(self) -> &'static str { + match self { + Self::Empty => "empty trace", + Self::LengthMismatch => "frequency and level counts differ", + Self::NonUniformGrid => "frequency grid is not uniform and ascending", + } + } +} + +#[derive(Default)] +struct RejectionReporter { + last_report: Option, + suppressed: usize, +} + +impl RejectionReporter { + fn report(&mut self, state: &Arc>, reason: TraceRejection, now: Instant) { + let should_report = self + .last_report + .is_none_or(|last| now.duration_since(last) >= REJECTION_REPORT_INTERVAL); + if !should_report { + self.suppressed += 1; + return; + } + + let suffix = match self.suppressed { + 0 => String::new(), + 1 => "; 1 additional trace rejected".to_string(), + count => format!("; {count} additional traces rejected"), + }; + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.push_log(format!( + "Power trace rejected: {}{suffix}", + reason.message() + )); + self.last_report = Some(now); + self.suppressed = 0; + } +} + +#[derive(Default)] +struct SpectrumAccumulator { + start_hz: u64, + stop_hz: u64, + rbw_hz: Option, + smoothed: Vec, + peak: Vec, + noise_scratch: Vec, +} + +impl SpectrumAccumulator { + fn publish( + &mut self, + state: &Arc>, + trace: PowerTrace, + ) -> Result<(), TraceRejection> { + if trace.frequencies_hz.is_empty() { + return Err(TraceRejection::Empty); + } + if trace.frequencies_hz.len() != trace.levels_dbm.len() { + return Err(TraceRejection::LengthMismatch); + } + let Some((center_freq_hz, sample_rate)) = trace_window(&trace.frequencies_hz) else { + return Err(TraceRejection::NonUniformGrid); + }; + let start_hz = trace.frequencies_hz[0]; + let stop_hz = *trace.frequencies_hz.last().unwrap_or(&start_hz); + let reset = self.start_hz != start_hz + || self.stop_hz != stop_hz + || self.rbw_hz != trace.rbw_hz + || self.smoothed.len() != trace.levels_dbm.len(); + if reset { + self.start_hz = start_hz; + self.stop_hz = stop_hz; + self.rbw_hz = trace.rbw_hz; + self.smoothed + .resize(trace.levels_dbm.len(), f32::NEG_INFINITY); + self.peak.resize(trace.levels_dbm.len(), f32::NEG_INFINITY); + } + crate::signal::stats::average_and_peak( + &trace.levels_dbm, + &mut self.smoothed, + &mut self.peak, + EMA_ALPHA, + PEAK_DECAY_DB, + !reset, + ); + + self.noise_scratch.resize(self.smoothed.len(), 0.0); + let noise_floor = + crate::signal::stats::quietest_mean(&self.smoothed, &mut self.noise_scratch, 10); + let strongest = self + .smoothed + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold(f32::NEG_INFINITY, f32::max); + let prominence = if strongest.is_finite() && noise_floor.is_finite() { + strongest - noise_floor + } else { + 0.0 + }; + + let bins = Arc::new(self.smoothed.clone()); + let peak = Arc::new(self.peak.clone()); + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.waterfall.buffer.push(&bins); + metrics.signal.peak_to_nf_db = prominence; + metrics.waterfall.last_fft = Some(FftFrame { + bins_dbfs: bins, + peak_hold: peak, + noise_floor, + center_freq_hz, + sample_rate, + timestamp: Instant::now(), + peak_to_nf_db: prominence, + channel_power_dbfs: f32::NEG_INFINITY, + occupied_bw_hz: 0, + enbw_hz: trace.rbw_hz.unwrap_or(0) as f64, + bin_axis: crate::state::BinAxis::MeasuredPoints, + }); + Ok(()) + } +} + +pub(crate) fn trace_window(frequencies_hz: &[u64]) -> Option<(u64, f64)> { + let start = *frequencies_hz.first()?; + let stop = *frequencies_hz.last()?; + let intervals = frequencies_hz.len().checked_sub(1)?; + let span = stop.checked_sub(start)?; + if span == 0 { + return None; + } + let low_step = span / intervals as u64; + let high_step = span.div_ceil(intervals as u64); + if !frequencies_hz.windows(2).all(|pair| { + pair[1] + .checked_sub(pair[0]) + .is_some_and(|step| step > 0 && (low_step..=high_step).contains(&step)) + }) { + return None; + } + Some((start.saturating_add(span / 2), span as f64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_worker_publishes_spectrum_and_waterfall_frames() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let (tx, rx) = crossbeam_channel::bounded(1); + let worker_state = Arc::clone(&state); + let worker = std::thread::spawn(move || PowerWorker::new(rx, worker_state).run()); + + tx.send(PowerTrace { + 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), + }) + .unwrap(); + drop(tx); + worker.join().unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs.as_slice(), &[-90.0, -45.0, -80.0]); + assert_eq!(frame.peak_hold.as_slice(), &[-90.0, -45.0, -80.0]); + assert_eq!(frame.center_freq_hz, 101_000_000); + assert_eq!(frame.sample_rate, 2_000_000.0); + assert_eq!(frame.enbw_hz, 10_000.0); + assert_eq!(metrics.waterfall.buffer.rows.len(), 1); + assert_eq!(metrics.signal.peak_to_nf_db, 45.0); + assert_eq!(metrics.radio.frequency, 100_000_000); + assert_eq!(metrics.signal.channel_power_dbfs, f32::NEG_INFINITY); + assert_eq!(frame.channel_power_dbfs, f32::NEG_INFINITY); + assert!(metrics + .ui + .log + .iter() + .all(|entry| !entry.text.contains("Power trace rejected"))); + } + + #[test] + fn noise_floor_uses_the_quiet_part_of_the_trace() { + let mut levels = vec![-100.0; 80]; + levels.extend([-30.0; 20]); + let mut scratch = vec![0.0; levels.len()]; + assert_eq!( + crate::signal::stats::quietest_mean(&levels, &mut scratch, 10), + -100.0 + ); + } + + #[test] + fn smoothing_and_peak_hold_stay_ordered() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + for levels in [[-90.0, -80.0], [-91.0, -70.0], [f32::NAN, -72.0]] { + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: levels.to_vec(), + rbw_hz: None, + }, + ) + .unwrap(); + } + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert!((frame.bins_dbfs[0] - -90.2).abs() < 1e-4); + assert!((frame.bins_dbfs[1] - -76.8).abs() < 1e-4); + assert!((frame.peak_hold[0] - -90.2).abs() < 1e-4); + assert!((frame.peak_hold[1] - -76.8).abs() < 1e-4); + for (peak, smoothed) in frame.peak_hold.iter().zip(frame.bins_dbfs.iter()) { + assert!(peak >= smoothed); + } + } + + #[test] + fn a_finite_level_recovers_a_non_finite_bin() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![f32::NAN, -90.0], + rbw_hz: None, + }, + ) + .unwrap(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![-70.0, -80.0], + rbw_hz: None, + }, + ) + .unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs[0], -70.0); + assert!(frame.bins_dbfs[1].is_finite()); + assert!(frame.peak_hold[0] >= frame.bins_dbfs[0]); + } + + #[test] + fn non_finite_initial_levels_publish_as_unavailable() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![f32::NAN, f32::INFINITY], + rbw_hz: None, + }, + ) + .unwrap(); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!( + frame.bins_dbfs.as_slice(), + &[f32::NEG_INFINITY, f32::NEG_INFINITY] + ); + assert_eq!(frame.peak_hold.as_slice(), frame.bins_dbfs.as_slice()); + assert_eq!(frame.noise_floor, f32::NEG_INFINITY); + } + + #[test] + fn an_rbw_change_resets_smoothing() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + for (rbw_hz, levels) in [(Some(10_000), -90.0), (Some(20_000), -40.0)] { + accumulator + .publish( + &state, + PowerTrace { + frequencies_hz: vec![100_000_000, 101_000_000], + levels_dbm: vec![levels; 2], + rbw_hz, + }, + ) + .unwrap(); + } + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + let frame = metrics.waterfall.last_fft.as_ref().unwrap(); + assert_eq!(frame.bins_dbfs.as_slice(), &[-40.0, -40.0]); + assert_eq!(frame.peak_hold.as_slice(), &[-40.0, -40.0]); + assert_eq!(frame.enbw_hz, 20_000.0); + } + + #[test] + fn rejected_traces_are_reported_at_a_bounded_rate() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut reporter = RejectionReporter::default(); + let now = Instant::now(); + reporter.report(&state, TraceRejection::Empty, now); + reporter.report( + &state, + TraceRejection::LengthMismatch, + now + Duration::from_secs(1), + ); + reporter.report( + &state, + TraceRejection::NonUniformGrid, + now + REJECTION_REPORT_INTERVAL, + ); + + let metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(metrics.ui.log.len(), 2); + assert!(metrics.ui.log[0].text.contains("empty trace")); + assert!(metrics.ui.log[1] + .text + .contains("1 additional trace rejected")); + } + + #[test] + fn invalid_traces_return_specific_rejections() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + let mut accumulator = SpectrumAccumulator::default(); + let cases = [ + ( + PowerTrace { + frequencies_hz: vec![], + levels_dbm: vec![], + rbw_hz: None, + }, + TraceRejection::Empty, + ), + ( + PowerTrace { + frequencies_hz: vec![100, 200], + levels_dbm: vec![-90.0], + rbw_hz: None, + }, + TraceRejection::LengthMismatch, + ), + ( + PowerTrace { + frequencies_hz: vec![100, 200, 350], + levels_dbm: vec![-90.0; 3], + rbw_hz: None, + }, + TraceRejection::NonUniformGrid, + ), + ]; + for (trace, expected) in cases { + assert_eq!(accumulator.publish(&state, trace), Err(expected)); + } + } + + #[test] + fn trace_window_uses_the_measured_edges() { + assert_eq!( + trace_window(&[100_000, 200_000, 300_000]), + Some((200_000, 200_000.0)) + ); + assert_eq!(trace_window(&[100_000]), None); + assert_eq!(trace_window(&[100_000, 200_000, 350_000]), None); + assert_eq!(trace_window(&[300_000, 200_000, 100_000]), None); + assert_eq!( + trace_window(&[100_000, 133_333, 166_667, 200_000]), + Some((150_000, 100_000.0)) + ); + } +} diff --git a/src/signal/stats.rs b/src/signal/stats.rs new file mode 100644 index 0000000..b4662f0 --- /dev/null +++ b/src/signal/stats.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 MusiThang + +//! These statistics operate on finite spectrum measurements in a common dB unit. +//! Non-finite inputs represent unavailable measurements. +//! `NEG_INFINITY` represents an unavailable output. +//! A finite floor such as the FFT's `DB_FLOOR` remains a measurement. + +/// Compute an EMA from the available values. +/// +/// A finite sample seeds unavailable history regardless of `alpha`. +/// An unavailable sample preserves finite history. +/// Two unavailable values produce `NEG_INFINITY`. +/// The caller must supply a finite `alpha` in `0..=1`. +/// With two finite values, zero retains history and one selects the sample. +pub(crate) fn finite_ema(previous: f32, sample: f32, alpha: f32) -> f32 { + match (previous.is_finite(), sample.is_finite()) { + (true, true) => alpha * sample + (1.0 - alpha) * previous, + (false, true) => sample, + (true, false) => previous, + (false, false) => f32::NEG_INFINITY, + } +} + +/// Smooth each bin and decay its peak toward the smoothed value. +/// +/// `has_history = false` discards both traces before seeding from the samples. +/// With history, each bin follows [`finite_ema`]'s availability rules. +/// Non-finite previous peaks are unavailable. +/// The caller must supply a finite `alpha` in `0..=1`. +/// `decay_db` must be finite and nonnegative. +/// It sets the peak reduction in dB per call, including calls with unavailable samples. +/// Zero holds the peak. +/// +/// # Panics +/// +/// All three slices must have equal lengths. +/// A mismatch panics before either output is mutated. +pub(crate) fn average_and_peak( + samples: &[f32], + smoothed: &mut [f32], + peak: &mut [f32], + alpha: f32, + decay_db: f32, + has_history: bool, +) { + assert_eq!( + samples.len(), + smoothed.len(), + "samples/smoothed length mismatch" + ); + assert_eq!(samples.len(), peak.len(), "samples/peak length mismatch"); + for ((smoothed, peak), sample) in smoothed + .iter_mut() + .zip(peak.iter_mut()) + .zip(samples.iter().copied()) + { + if !has_history { + *smoothed = f32::NEG_INFINITY; + *peak = f32::NEG_INFINITY; + } + *smoothed = finite_ema(*smoothed, sample, alpha); + let decayed_peak = if peak.is_finite() { + *peak - decay_db + } else { + f32::NEG_INFINITY + }; + *peak = if smoothed.is_finite() { + decayed_peak.max(*smoothed) + } else { + decayed_peak + }; + } +} + +/// Average the quietest fraction of the finite dB measurements. +/// +/// The selected count is the finite count divided by `fraction`, rounded down. +/// At least one measurement is selected from nonempty finite input. +/// A zero fraction selects all finite measurements. +/// Empty or all-non-finite input produces `NEG_INFINITY`. +/// `scratch` is reusable workspace with unspecified contents after the call. +/// +/// # Panics +/// +/// `scratch` must hold at least `values.len()` entries, including unavailable values. +/// Insufficient capacity panics before `scratch` is mutated. +pub(crate) fn quietest_mean(values: &[f32], scratch: &mut [f32], fraction: usize) -> f32 { + assert!( + scratch.len() >= values.len(), + "scratch is shorter than values" + ); + let mut finite_count = 0; + for value in values.iter().copied().filter(|value| value.is_finite()) { + scratch[finite_count] = value; + finite_count += 1; + } + if finite_count == 0 { + return f32::NEG_INFINITY; + } + + let count = (finite_count / fraction.max(1)).max(1); + scratch[..finite_count].select_nth_unstable_by(count - 1, f32::total_cmp); + scratch[..count].iter().sum::() / count as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::panic::{catch_unwind, AssertUnwindSafe}; + + #[test] + fn finite_ema_keeps_the_available_value() { + assert_eq!(finite_ema(-90.0, -80.0, 0.2), -88.0); + assert_eq!(finite_ema(f32::NAN, -80.0, 0.2), -80.0); + assert_eq!(finite_ema(-90.0, f32::NAN, 0.2), -90.0); + assert_eq!(finite_ema(f32::NAN, f32::INFINITY, 0.2), f32::NEG_INFINITY); + } + + #[test] + fn average_and_peak_handles_missing_samples() { + let mut smoothed = [0.0, 0.0]; + let mut peak = [0.0, 0.0]; + average_and_peak( + &[f32::NAN, -90.0], + &mut smoothed, + &mut peak, + 0.2, + 0.5, + false, + ); + average_and_peak(&[-70.0, f32::NAN], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [-70.0, -90.0]); + assert_eq!(peak, [-70.0, -90.0]); + } + + #[test] + fn quietest_mean_ignores_non_finite_values() { + let values = [-100.0, f32::NAN, -90.0, f32::INFINITY, -80.0]; + let mut scratch = vec![0.0; values.len()]; + assert_eq!(quietest_mean(&values, &mut scratch, 10), -100.0); + } + + #[test] + fn finite_ema_handles_all_unavailable_values_and_recovers() { + for unavailable in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(finite_ema(unavailable, -80.0, 0.2), -80.0); + assert_eq!(finite_ema(-90.0, unavailable, 0.2), -90.0); + for other in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let absent = finite_ema(unavailable, other, 0.2); + assert_eq!(absent, f32::NEG_INFINITY); + assert_eq!(finite_ema(absent, -80.0, 0.2), -80.0); + } + } + } + + #[test] + fn finite_ema_alpha_boundaries_preserve_seeding() { + assert_eq!(finite_ema(-90.0, -80.0, 0.0), -90.0); + assert_eq!(finite_ema(-90.0, -80.0, 1.0), -80.0); + for alpha in [0.0, 1.0] { + assert_eq!(finite_ema(f32::NEG_INFINITY, -80.0, alpha), -80.0); + assert_eq!(finite_ema(-90.0, f32::NAN, alpha), -90.0); + } + } + + #[test] + fn average_and_peak_accepts_empty_traces() { + for has_history in [false, true] { + average_and_peak(&[], &mut [], &mut [], 0.2, 0.5, has_history); + } + } + + #[test] + fn average_and_peak_recovers_from_all_unavailable_samples() { + let unavailable = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY]; + let mut smoothed = [-90.0; 3]; + let mut peak = [-80.0; 3]; + average_and_peak(&unavailable, &mut smoothed, &mut peak, 0.2, 0.5, false); + assert_eq!(smoothed, [f32::NEG_INFINITY; 3]); + assert_eq!(peak, [f32::NEG_INFINITY; 3]); + average_and_peak(&unavailable, &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [f32::NEG_INFINITY; 3]); + assert_eq!(peak, [f32::NEG_INFINITY; 3]); + average_and_peak(&[-70.0; 3], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [-70.0; 3]); + assert_eq!(peak, [-70.0; 3]); + } + + #[test] + fn average_and_peak_discards_non_finite_previous_peaks() { + for previous_peak in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for previous in [-90.0, f32::NEG_INFINITY] { + let mut smoothed = [previous]; + let mut peak = [previous_peak]; + average_and_peak(&[f32::NAN], &mut smoothed, &mut peak, 0.2, 0.5, true); + assert_eq!(smoothed, [previous]); + assert_eq!(peak, [previous]); + average_and_peak(&[-70.0], &mut smoothed, &mut peak, 1.0, 0.5, true); + assert_eq!(smoothed, [-70.0]); + assert_eq!(peak, [-70.0]); + } + } + } + + #[test] + fn average_and_peak_alpha_and_decay_boundaries() { + for (alpha, expected) in [(0.0, -90.0), (1.0, -80.0)] { + for decay in [0.0, 1.0] { + let mut smoothed = [-90.0]; + let mut peak = [-70.0]; + average_and_peak(&[-80.0], &mut smoothed, &mut peak, alpha, decay, true); + assert_eq!(smoothed, [expected]); + assert_eq!(peak, [-70.0 - decay]); + average_and_peak(&[f32::NAN], &mut smoothed, &mut peak, alpha, decay, true); + assert_eq!(smoothed, [expected]); + assert_eq!(peak, [-70.0 - 2.0 * decay]); + } + } + let mut smoothed = [-90.0]; + let mut peak = [-89.5]; + average_and_peak(&[-90.0], &mut smoothed, &mut peak, 1.0, 1.0, true); + assert_eq!(peak, smoothed); + } + + #[test] + fn average_and_peak_rejects_mismatches_before_mutating() { + for (samples_len, smoothed_len, peak_len) in + [(1, 2, 2), (2, 1, 2), (2, 2, 1), (2, 2, 3), (0, 1, 1)] + { + for sample in [-80.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for has_history in [false, true] { + let samples = vec![sample; samples_len]; + let mut smoothed = vec![-90.0; smoothed_len]; + let mut peak = vec![-70.0; peak_len]; + let result = catch_unwind(AssertUnwindSafe(|| { + average_and_peak(&samples, &mut smoothed, &mut peak, 0.2, 0.5, has_history); + })); + assert!(result.is_err()); + assert_eq!(smoothed, vec![-90.0; smoothed_len]); + assert_eq!(peak, vec![-70.0; peak_len]); + } + } + } + } + + #[test] + fn quietest_mean_empty_and_unavailable_inputs_have_no_measurement() { + assert_eq!(quietest_mean(&[], &mut [], 10), f32::NEG_INFINITY); + let mut scratch = [42.0; 3]; + assert_eq!( + quietest_mean( + &[f32::NAN, f32::INFINITY, f32::NEG_INFINITY], + &mut scratch, + 10, + ), + f32::NEG_INFINITY + ); + assert_eq!( + quietest_mean(&[-90.0, -80.0, -70.0], &mut scratch, 10), + -90.0 + ); + } + + #[test] + fn quietest_mean_zero_and_one_select_all_finite_values() { + let values = [-90.0, f32::NAN, -70.0, f32::INFINITY, f32::NEG_INFINITY]; + let mut scratch = [0.0; 8]; + for fraction in [0, 1] { + assert_eq!(quietest_mean(&values, &mut scratch, fraction), -80.0); + } + assert_eq!(quietest_mean(&values, &mut scratch, usize::MAX), -90.0); + } + + #[test] + fn quietest_mean_rejects_short_scratch_before_mutating() { + for values in [ + [-90.0, -80.0], + [-90.0, f32::NAN], + [f32::NAN, f32::INFINITY], + [f32::NEG_INFINITY, f32::NEG_INFINITY], + ] { + for fraction in [0, 1, 10] { + let mut scratch = [42.0]; + let result = catch_unwind(AssertUnwindSafe(|| { + quietest_mean(&values, &mut scratch, fraction); + })); + assert!(result.is_err()); + assert_eq!(scratch, [42.0]); + } + } + } +} diff --git a/src/state/fixture.rs b/src/state/fixture.rs index ccf6429..759a500 100644 --- a/src/state/fixture.rs +++ b/src/state/fixture.rs @@ -35,6 +35,7 @@ impl SdrMetrics { /// also the state most likely to be got wrong - every "waiting for RX" and /// `[STALE]` path runs through it. pub(crate) fn fixture() -> Self { + let caps = Arc::new(crate::hardware::native::hackrf::caps()); SdrMetrics { radio: RadioState { frequency: 100_000_000, @@ -74,15 +75,20 @@ impl SdrMetrics { observer: ObserverState::default(), spectrum: SpectrumState { step_hz: 100_000, - y_min: -120.0, - y_max: 0.0, + y_min: caps.level_min_db, + y_max: caps.level_max_db, hold: None, cursor_freq: None, markers: vec![], pending_marker: None, style: SpectrumStyle::default(), }, - waterfall: WaterfallState::new(512, crate::palette::WaterfallPalette::default()), + waterfall: WaterfallState::new( + 512, + crate::palette::WaterfallPalette::default(), + caps.level_min_db, + caps.level_max_db, + ), system: SystemState { // The HackRF fixture reports its own firmware, so no stack row. stack: None, @@ -101,7 +107,7 @@ impl SdrMetrics { lab: LabState::default(), demod: DemodState::default(), net: crate::state::NetState::default(), - caps: Arc::new(crate::hardware::native::hackrf::caps()), + caps, acc: Accumulators::default(), } } @@ -148,6 +154,7 @@ impl SdrMetrics { channel_power_dbfs: noise_floor + snr_db, occupied_bw_hz: 150_000, enbw_hz: sample_rate / N as f64, + bin_axis: crate::state::BinAxis::FftBins, }); self.signal.peak_to_nf_db = snr_db; self.signal.channel_power_dbfs = noise_floor + snr_db; @@ -159,12 +166,11 @@ impl SdrMetrics { self } - /// Age the newest FFT frame past [`crate::ui::panel::FFT_STALE_MS`], so the - /// staleness paths can be rendered without a test sleeping. + /// Age the newest FFT frame past the IQ trace limit. pub(crate) fn with_stale_fft(mut self) -> Self { if let Some(fr) = self.waterfall.last_fft.as_mut() { fr.timestamp = Instant::now() - - std::time::Duration::from_millis(crate::ui::panel::FFT_STALE_MS as u64 + 50); + - std::time::Duration::from_millis(crate::hardware::IQ_TRACE_STALE_MS as u64 + 50); } self } diff --git a/src/state/mod.rs b/src/state/mod.rs index 9fd9a79..b080e60 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -44,7 +44,7 @@ pub use ui::{ active_recall_slot, recall_from_hz, recall_to_hz, InputMode, LogEntry, LogLevel, MenuPane, MenuState, RailMode, UiState, RECALL_SLOTS, }; -pub use waterfall::{FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; +pub use waterfall::{BinAxis, BinWindow, FftFrame, WaterfallState, WATERFALL_MIN_ROWS}; pub const THROUGHPUT_HISTORY_LEN: usize = 64; /// Depth of the per-callback gap ring feeding the `lab_timing` strip chart. ~256 diff --git a/src/state/waterfall.rs b/src/state/waterfall.rs index 13b41ee..c2e4928 100644 --- a/src/state/waterfall.rs +++ b/src/state/waterfall.rs @@ -5,6 +5,118 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::Instant; +/// Define how bins cover a frequency span +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BinAxis { + /// Each FFT bin owns one interval starting at its frequency + #[default] + FftBins, + MeasuredPoints, +} + +/// A nonempty centre slice retains the full frame's bin spacing +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BinWindow { + pub first_bin: usize, + pub bin_count: usize, + pub left_hz: f64, + pub span_hz: f64, +} + +impl BinAxis { + /// Return the number of frequency intervals, or `None` for an empty axis + pub fn interval_count(self, bin_count: usize) -> Option { + match self { + Self::FftBins => (bin_count > 0).then_some(bin_count), + Self::MeasuredPoints => bin_count.checked_sub(1).filter(|count| *count > 0), + } + } + + /// Select a centre slice with at least one bin. Zero zoom means full span. + /// Empty axes and non-positive or non-finite spans have no window. + pub fn window( + self, + center_hz: u64, + span_hz: f64, + bin_count: usize, + zoom: usize, + ) -> Option { + if bin_count == 0 || !span_hz.is_finite() || span_hz <= 0.0 { + return None; + } + + let minimum = if self == Self::MeasuredPoints && bin_count > 1 { + 2 + } else { + 1 + }; + let visible = (bin_count / zoom.max(1)).max(minimum).min(bin_count); + let first = (bin_count / 2) + .saturating_sub(visible / 2) + .min(bin_count - visible); + let intervals = self.interval_count(bin_count)?; + let bin_hz = span_hz / intervals as f64; + let visible_intervals = match self { + Self::FftBins => visible, + Self::MeasuredPoints => visible.saturating_sub(1), + }; + + Some(BinWindow { + first_bin: first, + bin_count: visible, + left_hz: center_hz as f64 - span_hz / 2.0 + first as f64 * bin_hz, + span_hz: visible_intervals as f64 * bin_hz, + }) + } + + /// Return a bin's frequency + /// + /// The last FFT bin starts below the right edge. + pub fn frequency_of_bin( + self, + left_hz: f64, + span_hz: f64, + bin_count: usize, + index: usize, + ) -> Option { + if bin_count == 0 || index >= bin_count { + return None; + } + let intervals = self.interval_count(bin_count)?; + if !left_hz.is_finite() || !span_hz.is_finite() || span_hz <= 0.0 { + return None; + } + Some(left_hz + index as f64 * span_hz / intervals as f64) + } + + /// Look up the FFT interval containing `frequency_hz` + /// + /// The right edge reads the last bin. Frequencies outside the window return `None`. + pub fn nearest_bin( + self, + left_hz: f64, + span_hz: f64, + bin_count: usize, + frequency_hz: f64, + ) -> Option { + if !left_hz.is_finite() + || !span_hz.is_finite() + || span_hz <= 0.0 + || !frequency_hz.is_finite() + || !(left_hz..=left_hz + span_hz).contains(&frequency_hz) + { + return None; + } + let intervals = self.interval_count(bin_count)?; + let position = (frequency_hz - left_hz) * intervals as f64 / span_hz; + let index = match self { + Self::FftBins => position.floor() as usize, + Self::MeasuredPoints => position.round() as usize, + }; + Some(index.min(bin_count.saturating_sub(1))) + } +} + #[derive(Clone)] #[allow(dead_code)] pub struct FftFrame { @@ -18,6 +130,24 @@ pub struct FftFrame { pub channel_power_dbfs: f32, pub occupied_bw_hz: u64, pub enbw_hz: f64, + pub bin_axis: BinAxis, +} + +impl FftFrame { + pub fn window(&self, zoom: usize) -> Option { + self.bin_axis.window( + self.center_freq_hz, + self.sample_rate, + self.bins_dbfs.len(), + zoom, + ) + } + + pub fn frequency_of_bin(&self, index: usize) -> Option { + let window = self.window(1)?; + self.bin_axis + .frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, index) + } } pub struct WaterfallBuffer { @@ -121,6 +251,7 @@ impl WaterfallBuffer { #[derive(Clone)] pub struct WaterfallState { pub db_min: f32, + pub db_max: f32, pub scroll_offset: usize, pub cursor_freq: Option, pub hz_zoom: u32, @@ -131,9 +262,16 @@ pub struct WaterfallState { } impl WaterfallState { - pub fn new(max_rows: usize, palette: crate::palette::WaterfallPalette) -> Self { + /// Initialize the view with finite ordered bounds from validated device capabilities + pub fn new( + max_rows: usize, + palette: crate::palette::WaterfallPalette, + db_min: f32, + db_max: f32, + ) -> Self { Self { - db_min: -120.0, + db_min, + db_max, scroll_offset: 0, cursor_freq: None, hz_zoom: 1, @@ -175,6 +313,106 @@ mod min_rows_tests { mod tests { use super::*; + #[test] + fn measured_points_keep_endpoints_across_zoom() { + let full = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 1) + .unwrap(); + assert_eq!(full.left_hz, 100_000_000.0); + assert_eq!(full.span_hz, 63_000_000.0); + assert_eq!( + BinAxis::MeasuredPoints.frequency_of_bin( + full.left_hz, + full.span_hz, + full.bin_count, + 63, + ), + Some(163_000_000.0) + ); + + let zoomed = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 4) + .unwrap(); + assert_eq!(zoomed.first_bin, 24); + assert_eq!(zoomed.bin_count, 16); + assert_eq!(zoomed.left_hz, 124_000_000.0); + assert_eq!(zoomed.span_hz, 15_000_000.0); + } + + #[test] + fn fft_bins_keep_n_intervals() { + let axis = BinAxis::FftBins; + let window = axis.window(100_000_000, 64_000_000.0, 64, 1).unwrap(); + assert_eq!(axis.interval_count(64), Some(64)); + assert_eq!(window.first_bin, 0); + assert_eq!(window.bin_count, 64); + assert_eq!(window.left_hz, 68_000_000.0); + assert_eq!(window.span_hz, 64_000_000.0); + assert_eq!( + axis.frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, 63), + Some(131_000_000.0) + ); + for (frequency, index) in [ + (68_000_000.0, 0), + (100_000_000.0, 32), + (131_000_000.0, 63), + (132_000_000.0, 63), + ] { + assert_eq!( + axis.nearest_bin(window.left_hz, window.span_hz, window.bin_count, frequency), + Some(index) + ); + } + } + + #[test] + fn fft_zoom_preserves_bin_spacing_and_clamps_to_one_bin() { + let axis = BinAxis::FftBins; + assert_eq!( + axis.window(100, 10.0, 10, 3), + Some(BinWindow { + first_bin: 4, + bin_count: 3, + left_hz: 99.0, + span_hz: 3.0, + }) + ); + assert_eq!(axis.window(100, 10.0, 10, 0), axis.window(100, 10.0, 10, 1)); + for count in [1, 10] { + let window = axis.window(100, 10.0, count, usize::MAX).unwrap(); + assert_eq!(window.bin_count, 1); + assert_eq!(window.span_hz, 10.0 / count as f64); + assert_eq!( + axis.nearest_bin(window.left_hz, window.span_hz, 1, window.left_hz), + Some(0) + ); + } + } + + #[test] + fn bin_axis_rejects_invalid_bounds() { + for axis in [BinAxis::FftBins, BinAxis::MeasuredPoints] { + assert_eq!(axis.interval_count(0), None); + assert!(axis.window(100, 10.0, 0, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 0, 0).is_none()); + assert!(axis.frequency_of_bin(0.0, 10.0, 4, 4).is_none()); + assert!(axis.nearest_bin(0.0, 10.0, 0, 0.0).is_none()); + for span in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(axis.window(100, span, 32, 1).is_none()); + assert!(axis.frequency_of_bin(0.0, span, 4, 0).is_none()); + assert!(axis.nearest_bin(0.0, span, 4, 0.0).is_none()); + } + for left in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(axis.frequency_of_bin(left, 10.0, 4, 0).is_none()); + assert!(axis.nearest_bin(left, 10.0, 4, 0.0).is_none()); + } + for frequency in [-1.0, 11.0, f64::NAN, f64::INFINITY] { + assert!(axis.nearest_bin(0.0, 10.0, 4, frequency).is_none()); + } + } + assert!(BinAxis::MeasuredPoints.window(100, 10.0, 1, 1).is_none()); + } + #[test] fn push_adds_newest_row_first() { let mut buf = WaterfallBuffer::new(4); diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index a8d20b6..f873040 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -9,7 +9,7 @@ mod system; pub use net::spawn_net_survey_task; pub use observer::spawn_observer_task; -pub use rx::spawn_rx_task; +pub use rx::{spawn_power_rx_task, spawn_rx_task}; pub use sweep::spawn_sweep_task; pub use system::spawn_sys_resource_task; diff --git a/src/tasks/rx/control.rs b/src/tasks/rx/control.rs index c8e8e6c..dd4223b 100644 --- a/src/tasks/rx/control.rs +++ b/src/tasks/rx/control.rs @@ -23,6 +23,37 @@ use crate::state::{SdrMetrics, ADC_COMFORT_DBFS as AUTOGAIN_COMFORT_DBFS}; use super::metrics::RateTracker; use super::publish::Throughput; +pub(super) enum RxRequestTransition { + Unchanged(bool), + Started, + StartFailed(anyhow::Error), + Stopped(anyhow::Result<()>), +} + +pub(super) fn request_transition( + rx_enabled: bool, + hw_rx_active: bool, + start: impl FnOnce() -> anyhow::Result<()>, + stop: impl FnOnce() -> anyhow::Result<()>, +) -> RxRequestTransition { + match (rx_enabled, hw_rx_active) { + (true, false) => match start() { + Ok(()) => RxRequestTransition::Started, + Err(error) => RxRequestTransition::StartFailed(error), + }, + (false, true) => RxRequestTransition::Stopped(stop()), + (_, active) => RxRequestTransition::Unchanged(active), + } +} + +pub(super) fn unexpected_stop( + hw_rx_active: bool, + hw_streaming: bool, + cleanup: impl FnOnce() -> anyhow::Result<()>, +) -> Option> { + (hw_rx_active && !hw_streaming).then(cleanup) +} + /// Notice that the radio stopped streaming without being asked, and say so. /// /// Returns the new `hw_rx_active`. The device is told to stop as well: it has @@ -34,10 +65,9 @@ pub(super) fn note_unexpected_stop( hw_rx_active: bool, hw_streaming: bool, ) -> bool { - if !hw_rx_active || hw_streaming { + let Some(_) = unexpected_stop(hw_rx_active, hw_streaming, || device.stop_rx()) else { return hw_rx_active; - } - let _ = device.stop_rx(); + }; let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); m.radio.rx_enabled = false; m.radio.hw_streaming = false; @@ -57,30 +87,29 @@ pub(super) fn apply_rx_request( rx_enabled: bool, hw_rx_active: bool, ) -> bool { - match (rx_enabled, hw_rx_active) { - (true, false) => match device.start_rx(Arc::clone(rx_ctx)) { - Ok(()) => { - // Fresh per-session throughput statistics, and a rate baseline - // that does not span the stop. Averaging across one would mix a - // silent stretch into the sample-rate offset. - tp.reset(); - rate.reset(); - let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); - m.radio.rx_start_time = Some(Instant::now()); - m.timing.jitter_session_max_us = 0; - m.push_log("RX streaming started"); - true - } - Err(e) => { - let msg = format!("Error starting RX: {}", e); - let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); - m.radio.rx_enabled = false; - m.push_log(msg); - false - } - }, - (false, true) => { - let result = device.stop_rx(); + match request_transition( + rx_enabled, + hw_rx_active, + || device.start_rx(Arc::clone(rx_ctx)), + || device.stop_rx(), + ) { + RxRequestTransition::Started => { + // Keep the sample-rate baseline within one RX session + tp.reset(); + rate.reset(); + let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); + m.radio.rx_start_time = Some(Instant::now()); + m.timing.jitter_session_max_us = 0; + m.push_log("RX streaming started"); + true + } + RxRequestTransition::StartFailed(error) => { + let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); + m.radio.rx_enabled = false; + m.push_log(format!("Error starting RX: {error}")); + false + } + RxRequestTransition::Stopped(result) => { rate.reset(); let mut m = state.lock().unwrap_or_else(|e| e.into_inner()); m.radio.rx_start_time = None; @@ -90,7 +119,7 @@ pub(super) fn apply_rx_request( } false } - (_, active) => active, + RxRequestTransition::Unchanged(active) => active, } } @@ -252,6 +281,7 @@ pub(super) fn advance_noise_sweep(state: &Arc>, device: &Arc>, + device: Arc, + rx_ctx: Arc, +) { + tokio::spawn(async move { + let mut active = false; + loop { + active = power_control_step(&state, &device, &rx_ctx, active); + tokio::time::sleep(POLL_INTERVAL).await; + } + }); +} + +fn power_control_step( + state: &Arc>, + device: &Arc, + rx_ctx: &Arc, + active: bool, +) -> bool { + let requested = state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .radio + .rx_enabled; + let transition = control::request_transition( + requested, + active, + || device.start_rx(Arc::clone(rx_ctx)), + || device.stop_rx(), + ); + let unchanged_active = matches!(transition, control::RxRequestTransition::Unchanged(true)); + let active = match transition { + control::RxRequestTransition::Started => { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_start_time = Some(Instant::now()); + metrics.radio.hw_streaming = true; + metrics.push_log("Power trace acquisition started"); + true + } + control::RxRequestTransition::StartFailed(error) => { + let mut metrics = state.lock().unwrap_or_else(|e| e.into_inner()); + metrics.radio.rx_enabled = false; + metrics.radio.hw_streaming = false; + metrics.push_log(format!("Error starting power trace acquisition: {error}")); + false + } + control::RxRequestTransition::Stopped(result) => { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_start_time = None; + metrics.radio.hw_streaming = false; + match result { + Ok(()) => metrics.push_log("Power trace acquisition stopped"), + Err(error) => { + metrics.push_log(format!("Error stopping power trace acquisition: {error}")) + } + } + false + } + control::RxRequestTransition::Unchanged(active) => active, + }; + + if unchanged_active { + let streaming = device.is_streaming(); + if let Some(cleanup) = control::unexpected_stop(active, streaming, || device.stop_rx()) { + let mut metrics = state.lock().unwrap_or_else(|error| error.into_inner()); + metrics.radio.rx_enabled = false; + metrics.radio.hw_streaming = false; + metrics.radio.rx_start_time = None; + metrics.push_log("WARNING: Power trace acquisition stopped unexpectedly"); + if let Err(error) = cleanup { + metrics.push_log(format!( + "Error cleaning up power trace acquisition: {error}" + )); + } + return false; + } + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .radio + .hw_streaming = streaming; + } + active +} + /// Turn the read loop's cumulative clock into this window's occupancy. /// /// The counters are cumulative and never reset, so the first poll of a session @@ -158,3 +245,166 @@ fn window_occupancy(current: Option<(u64, u64)>, last: &mut Option<(u64, u64)>) work.saturating_sub(prev_work), ) } + +#[cfg(test)] +mod power_control_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use super::*; + use crate::hardware::{DeviceCapabilities, DeviceInfo, FeedHealth, RateSet, SampleGeometry}; + + struct TestDevice { + caps: DeviceCapabilities, + streaming: AtomicBool, + fail_start: AtomicBool, + fail_stop: AtomicBool, + starts: AtomicUsize, + stops: AtomicUsize, + } + + impl TestDevice { + fn new() -> Self { + let mut caps = crate::hardware::native::hackrf::caps(); + caps.acquisition = crate::hardware::AcquisitionKind::PowerTrace; + Self { + caps, + streaming: AtomicBool::new(false), + fail_start: AtomicBool::new(false), + fail_stop: AtomicBool::new(false), + starts: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + } + } + } + + impl SdrDevice for TestDevice { + fn capabilities(&self) -> &DeviceCapabilities { + &self.caps + } + + fn info(&self) -> DeviceInfo { + DeviceInfo::default() + } + + fn start_rx(&self, _ctx: Arc) -> anyhow::Result<()> { + self.starts.fetch_add(1, Ordering::Relaxed); + if self.fail_start.load(Ordering::Relaxed) { + anyhow::bail!("start failed") + } + self.streaming.store(true, Ordering::Relaxed); + Ok(()) + } + + fn stop_rx(&self) -> anyhow::Result<()> { + self.stops.fetch_add(1, Ordering::Relaxed); + self.streaming.store(false, Ordering::Relaxed); + if self.fail_stop.load(Ordering::Relaxed) { + anyhow::bail!("stop failed") + } + Ok(()) + } + + fn is_streaming(&self) -> bool { + self.streaming.load(Ordering::Relaxed) + } + + fn set_frequency(&self, _hz: u64) -> anyhow::Result<()> { + Ok(()) + } + + fn set_sample_rate(&self, hz: f64) -> anyhow::Result { + Ok(RateSet::new(hz, Some(hz), 0)) + } + + fn set_lna_gain(&self, _db: u32) -> anyhow::Result<()> { + Ok(()) + } + } + + fn context(state: &Arc>) -> Arc { + let (sample_tx, _) = crossbeam_channel::bounded(1); + let (demod_tx, _) = crossbeam_channel::bounded(1); + let (net_tx, _) = crossbeam_channel::bounded(1); + let (power_tx, _) = crossbeam_channel::bounded(1); + Arc::new(RxContext { + metrics: Arc::clone(state), + sample_tx, + fft_feed: FeedHealth::default(), + demod_tx, + net_tx, + net_feed: FeedHealth::default(), + power_tx, + geometry: SampleGeometry::default(), + }) + } + + #[test] + fn a_power_start_failure_clears_the_request() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + state.lock().unwrap().radio.rx_enabled = true; + let device = Arc::new(TestDevice::new()); + device.fail_start.store(true, Ordering::Relaxed); + let dyn_device: Arc = device; + + assert!(!power_control_step( + &state, + &dyn_device, + &context(&state), + false + )); + let metrics = state.lock().unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert!(metrics.ui.log.back().unwrap().text.contains("start failed")); + } + + #[test] + fn a_requested_power_stop_closes_the_active_session() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + state.lock().unwrap().radio.rx_enabled = true; + let device = Arc::new(TestDevice::new()); + let dyn_device: Arc = device.clone(); + let ctx = context(&state); + let active = power_control_step(&state, &dyn_device, &ctx, false); + assert!(active); + + state.lock().unwrap().radio.rx_enabled = false; + assert!(!power_control_step(&state, &dyn_device, &ctx, active)); + assert_eq!(device.stops.load(Ordering::Relaxed), 1); + assert!(!state.lock().unwrap().radio.hw_streaming); + } + + #[test] + fn an_unexpected_power_stop_reports_cleanup_failure() { + let state = Arc::new(Mutex::new(SdrMetrics::fixture())); + { + let mut metrics = state.lock().unwrap(); + metrics.radio.rx_enabled = true; + metrics.radio.hw_streaming = true; + } + let device = Arc::new(TestDevice::new()); + device.fail_stop.store(true, Ordering::Relaxed); + let dyn_device: Arc = device.clone(); + + assert!(!power_control_step( + &state, + &dyn_device, + &context(&state), + true + )); + let metrics = state.lock().unwrap(); + assert!(!metrics.radio.rx_enabled); + assert!(!metrics.radio.hw_streaming); + assert_eq!(device.stops.load(Ordering::Relaxed), 1); + assert!(metrics + .ui + .log + .iter() + .any(|entry| entry.text.contains("stopped unexpectedly"))); + assert!(metrics + .ui + .log + .iter() + .any(|entry| entry.text.contains("cleaning up") && entry.text.contains("stop failed"))); + } +} diff --git a/src/tasks/rx/poll.rs b/src/tasks/rx/poll.rs index ed2922c..d4e6137 100644 --- a/src/tasks/rx/poll.rs +++ b/src/tasks/rx/poll.rs @@ -204,6 +204,7 @@ mod tests { let (sample_tx, sample_rx) = crossbeam_channel::bounded(4); let (demod_tx, demod_rx) = crossbeam_channel::bounded(2); let (net_tx, net_rx) = crossbeam_channel::bounded(4); + let (power_tx, _) = crossbeam_channel::bounded(1); let ctx = RxContext { metrics: Arc::clone(&state), sample_tx, @@ -211,6 +212,7 @@ mod tests { demod_tx, net_tx, net_feed: FeedHealth::default(), + power_tx, geometry: SampleGeometry { format: SampleFormat::Int8, full_scale: 128.0, diff --git a/src/ui/engine.rs b/src/ui/engine.rs index 0a13577..9d55ea9 100644 --- a/src/ui/engine.rs +++ b/src/ui/engine.rs @@ -18,6 +18,7 @@ use crate::ui::registry::PanelRegistry; pub struct LayoutEngine { pub config: LayoutConfig, + saved_active_preset: String, registry: PanelRegistry, focused_panel: Option, hidden_panels: HashSet, @@ -33,20 +34,37 @@ pub struct LayoutEngine { /// nothing does that outside the tests, and a preset added that way would be /// absent from the menu rather than break it. menu: menu::model::Menu, + startup_warnings: Vec, } impl LayoutEngine { + #[cfg_attr(not(test), allow(dead_code))] pub fn new(config: LayoutConfig, registry: PanelRegistry) -> Self { + let saved_active_preset = config.active_preset.clone(); + Self::new_with_saved_preset(config, registry, saved_active_preset) + } + + pub fn new_with_saved_preset( + config: LayoutConfig, + registry: PanelRegistry, + saved_active_preset: String, + ) -> Self { let menu = menu::model::build(&config.presets); Self { config, + saved_active_preset, registry, focused_panel: None, hidden_panels: HashSet::new(), menu, + startup_warnings: Vec::new(), } } + pub fn set_startup_warnings(&mut self, warnings: Vec) { + self.startup_warnings = warnings; + } + /// Anything odd found while building the menu, for the caller to log once at /// startup. Collected rather than logged in `model` so that module needs no /// mutex and stays testable as a pure function. @@ -54,6 +72,10 @@ impl LayoutEngine { &self.menu.warnings } + pub fn startup_warnings(&self) -> &[String] { + &self.startup_warnings + } + /// The section table the menu draws. pub fn menu(&self) -> &menu::model::Menu { &self.menu @@ -114,6 +136,10 @@ impl LayoutEngine { &self.config.active_preset } + pub fn saved_active_preset(&self) -> &str { + &self.saved_active_preset + } + /// Names of every panel the registry knows. /// /// Nothing draws this. It exists so `builder.rs` can check the built-in @@ -141,6 +167,7 @@ impl LayoutEngine { pub fn set_preset(&mut self, name: &str) { if self.config.presets.contains_key(name) { self.config.active_preset = name.to_string(); + self.saved_active_preset = name.to_string(); } } diff --git a/src/ui/menu/keys.rs b/src/ui/menu/keys.rs index 7ca82c6..82cb92f 100644 --- a/src/ui/menu/keys.rs +++ b/src/ui/menu/keys.rs @@ -21,7 +21,7 @@ use ratatui::{ Frame, }; -use crate::hardware::GainModel; +use crate::hardware::{DeviceCapabilities, GainModel}; use crate::ui::chrome; /// How a row reads on a single-knob device: RTL-SDR has one stepped tuner gain @@ -157,7 +157,17 @@ pub const GLOBAL: &[(&str, &[Binding])] = &[ /// /// Separate from drawing so the scroll arithmetic and the row count can be /// tested without a terminal. +#[cfg(test)] fn lines(model: &GainModel, iw: usize, theme: &crate::Theme) -> Vec> { + lines_for(model, false, iw, theme) +} + +fn lines_for( + model: &GainModel, + sample_rate_is_span: bool, + iw: usize, + theme: &crate::Theme, +) -> Vec> { let single = model.is_single(); let key_style = Style::default() .fg(theme.border_accent) @@ -166,11 +176,19 @@ fn lines(model: &GainModel, iw: usize, theme: &crate::Theme) -> Vec = bindings.iter().filter(|b| b.needs.met(model)).collect(); + let shown: Vec<&Binding> = bindings + .iter() + .filter(|binding| { + binding.needs.met(model) && !(sample_rate_is_span && binding.ch == Some('m')) + }) + .collect(); if shown.is_empty() { continue; } @@ -179,8 +197,9 @@ fn lines(model: &GainModel, iw: usize, theme: &crate::Theme) -> Vec *text, + let what = match (sample_rate_is_span, binding.ch, single, &binding.single) { + (true, Some('s'), _, _) => "type a span", + (_, _, true, OnSingle::Reword(text)) => *text, _ => binding.what, }; out.push(Line::from(vec![ @@ -192,11 +211,22 @@ fn lines(model: &GainModel, iw: usize, theme: &crate::Theme) -> Vec = all.into_iter().skip(first).take(visible).collect(); @@ -205,8 +235,14 @@ pub fn render(f: &mut Frame, area: Rect, model: &GainModel, scroll: usize, theme /// How many rows the reference needs, so the caller can tell whether scrolling /// is possible at all. -pub fn row_count(model: &GainModel) -> usize { - lines(model, 40, &crate::Theme::sdr()).len() +pub fn row_count_for(caps: &DeviceCapabilities) -> usize { + lines_for( + &caps.gain, + caps.sample_rate_is_span, + 40, + &crate::Theme::sdr(), + ) + .len() } #[cfg(test)] @@ -321,4 +357,23 @@ mod tests { let rtl = lines(&rtlsdr::gain_model(&[0, 10, 20]), 40, &theme).len(); assert_eq!(rtl, hackrf - 2, "the two VGA rows should be gone"); } + + #[test] + fn power_trace_reference_keeps_only_supported_controls() { + let theme = crate::Theme::sdr(); + let lines = lines_for( + &GainModel::new(Vec::new(), "RF", "RF").with_gauge_fallback(0), + true, + 80, + &theme, + ); + let text = lines + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + assert!(text.contains("type a span")); + assert!(!text.contains("GAIN")); + assert!(!text.contains("survey the band")); + } } diff --git a/src/ui/menu/mod.rs b/src/ui/menu/mod.rs index 924c5fd..2c775ca 100644 --- a/src/ui/menu/mod.rs +++ b/src/ui/menu/mod.rs @@ -158,7 +158,7 @@ fn right_pane( match state.pane { MenuPane::Views => entries::render(f, body, &menu.sections[section], cursor, theme), - MenuPane::Keys => keys::render(f, body, &m.caps.gain, state.scroll, theme), + MenuPane::Keys => keys::render(f, body, &m.caps, state.scroll, theme), MenuPane::Options => options::render(f, body, theme), } } diff --git a/src/ui/panel.rs b/src/ui/panel.rs index 2efce55..4c91b95 100644 --- a/src/ui/panel.rs +++ b/src/ui/panel.rs @@ -26,37 +26,37 @@ pub enum Staleness { /// Stale whenever the radio is not streaming. For anything read from /// hardware counters: timing, drops, gain staging, IQ balance. NotStreaming, - /// Stale when the newest FFT frame has aged past [`FFT_STALE_MS`], or there - /// is no frame yet. For anything derived from the spectrum. + /// Stale when the newest FFT frame exceeds the device's trace-age limit, or + /// there is no frame yet. For anything derived from the spectrum. FftAge, /// Never stale. For panels that show configuration rather than measurement. Never, } -/// How old the newest FFT frame may get before an [`Staleness::FftAge`] panel -/// calls its readings stale. Shared with `widgets::micro_common::fft_stale`. -pub const FFT_STALE_MS: u128 = 500; - impl Staleness { /// Resolve the rule against a metrics snapshot. pub fn resolve(self, state: &SdrMetrics) -> bool { - self.decide( - state.radio.hw_streaming, - state - .waterfall - .last_fft - .as_ref() - .map(|fr| fr.timestamp.elapsed().as_millis()), - ) + let age = state + .waterfall + .last_fft + .as_ref() + .map(|frame| frame.timestamp.elapsed().as_millis()); + let stale = self.decide(state.radio.hw_streaming, age, state.caps.trace_stale_ms); + stale + || (self == Staleness::FftAge + && state.caps.acquisition == crate::hardware::AcquisitionKind::PowerTrace + && !state.radio.hw_streaming) } /// The rule itself, on plain inputs: `fft_age_ms` is `None` when no frame has /// arrived yet. Split out from [`resolve`](Self::resolve) so the decision can /// be tested without building a whole metrics snapshot. - fn decide(self, streaming: bool, fft_age_ms: Option) -> bool { + fn decide(self, streaming: bool, fft_age_ms: Option, trace_stale_ms: u128) -> bool { match self { Staleness::NotStreaming => !streaming, - Staleness::FftAge => fft_age_ms.map(|ms| ms > FFT_STALE_MS).unwrap_or(true), + Staleness::FftAge => fft_age_ms + .map(|milliseconds| milliseconds > trace_stale_ms) + .unwrap_or(true), Staleness::Never => false, } } @@ -277,6 +277,10 @@ pub trait Panel: Send + Sync { #[allow(dead_code)] fn min_size(&self) -> (u16, u16); + fn supports_acquisition(&self, acquisition: crate::hardware::AcquisitionKind) -> bool { + acquisition == crate::hardware::AcquisitionKind::IqSamples + } + /// Draw the panel's contents into `area`. /// /// `area` is the **inner** rect the engine has already carved out, guaranteed @@ -374,20 +378,73 @@ mod tests { #[test] fn staleness_rules_are_independent_of_each_other() { // A dead radio staleness NotStreaming, and nothing else. - assert!(Staleness::NotStreaming.decide(false, Some(0))); + let stale_ms = crate::hardware::IQ_TRACE_STALE_MS; + assert!(Staleness::NotStreaming.decide(false, Some(0), stale_ms)); assert!( - !Staleness::FftAge.decide(false, Some(0)), + !Staleness::FftAge.decide(false, Some(0), stale_ms), "fresh frame, dead radio โ†’ live" ); - assert!(!Staleness::Never.decide(false, None), "Never means never"); + assert!( + !Staleness::Never.decide(false, None, stale_ms), + "Never means never" + ); // A streaming radio whose FFT has dried up staleness only FftAge. - assert!(!Staleness::NotStreaming.decide(true, None)); - assert!(Staleness::FftAge.decide(true, None), "no frame yet โ†’ stale"); - assert!(Staleness::FftAge.decide(true, Some(FFT_STALE_MS + 1))); + assert!(!Staleness::NotStreaming.decide(true, None, stale_ms)); assert!( - !Staleness::FftAge.decide(true, Some(FFT_STALE_MS)), + Staleness::FftAge.decide(true, None, stale_ms), + "no frame yet โ†’ stale" + ); + assert!(Staleness::FftAge.decide(true, Some(stale_ms + 1), stale_ms)); + assert!( + !Staleness::FftAge.decide(true, Some(stale_ms), stale_ms), "the threshold itself is live" ); } + + #[test] + fn power_trace_staleness_uses_the_device_limit_and_rx_state() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 20.0); + let mut caps = (*state.caps).clone(); + caps.acquisition = crate::hardware::AcquisitionKind::PowerTrace; + caps.trace_stale_ms = 100; + state.caps = std::sync::Arc::new(caps); + state.waterfall.last_fft.as_mut().unwrap().timestamp = + std::time::Instant::now() - std::time::Duration::from_millis(50); + + assert!(!Staleness::FftAge.resolve(&state)); + state.caps = { + let mut caps = (*state.caps).clone(); + caps.trace_stale_ms = 10; + std::sync::Arc::new(caps) + }; + assert!(Staleness::FftAge.resolve(&state)); + + state.waterfall.last_fft.as_mut().unwrap().timestamp = std::time::Instant::now(); + state.radio.hw_streaming = false; + assert!(Staleness::FftAge.resolve(&state)); + } + + #[test] + fn trace_staleness_uses_the_device_limit() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 20.0); + std::sync::Arc::make_mut(&mut state.caps).trace_stale_ms = 60_000; + state.waterfall.last_fft.as_mut().unwrap().timestamp = + std::time::Instant::now() - std::time::Duration::from_secs(1); + assert!(!Staleness::FftAge.resolve(&state)); + + std::sync::Arc::make_mut(&mut state.caps).trace_stale_ms = 100; + assert!(Staleness::FftAge.resolve(&state)); + + state.waterfall.last_fft = None; + assert!(Staleness::FftAge.resolve(&state)); + } + + #[test] + fn trace_age_threshold_is_inclusive_for_each_device() { + for limit in [0, 100, 500, 60_000] { + assert!(!Staleness::FftAge.decide(false, Some(limit), limit)); + assert!(Staleness::FftAge.decide(true, Some(limit + 1), limit)); + } + } } diff --git a/src/ui/panels/core/command_rail/modes.rs b/src/ui/panels/core/command_rail/modes.rs index 4917f3e..5406bd8 100644 --- a/src/ui/panels/core/command_rail/modes.rs +++ b/src/ui/panels/core/command_rail/modes.rs @@ -57,24 +57,15 @@ pub(super) fn mode_tabs_line(active: RailMode, iw: usize, theme: &crate::Theme) /// `(freq_hz, dbfs)`, strongest-first. A thin wrapper over the spectrum panel's /// [`detect_peaks`] (shared prominence โ‰ฅ NF+10 dB + min-separation logic) plus /// the binโ†’Hz map, so HUNT and the MONITOR activity count agree with the markers. -pub(super) fn rail_peaks( - bins: &[f32], - noise_floor: f32, - center_hz: u64, - sample_rate: f64, - n: usize, -) -> Vec<(u64, f32)> { - if sample_rate <= 0.0 || bins.is_empty() { - return Vec::new(); - } +pub(super) fn rail_peaks(frame: &crate::state::FftFrame, n: usize) -> Vec<(u64, f32)> { + let bins = &frame.bins_dbfs; let len = bins.len(); let sep = (len / 48).max(2); - let left_hz = center_hz as f64 - sample_rate / 2.0; - detect_peaks(bins, noise_floor, n, sep) + detect_peaks(bins, frame.noise_floor, n, sep) .into_iter() - .map(|i| { - let hz = (left_hz + i as f64 / len as f64 * sample_rate).max(0.0) as u64; - (hz, bins[i]) + .filter_map(|i| { + let hz = frame.frequency_of_bin(i)?.max(0.0) as u64; + Some((hz, bins[i])) }) .collect() } @@ -140,13 +131,7 @@ pub(super) fn mode_card_lines( let Some(fr) = fft else { return vec![dim("scanningโ€ฆ".into())]; }; - let peaks = rail_peaks( - &fr.bins_dbfs, - fr.noise_floor, - state.radio.frequency, - fr.sample_rate, - 3, - ); + let peaks = rail_peaks(fr, 3); if peaks.is_empty() { return vec![dim("no peaks".into())]; } @@ -192,16 +177,7 @@ pub(super) fn mode_card_lines( .last_fft .as_ref() .filter(|_| !stale) - .map_or(0, |fr| { - rail_peaks( - &fr.bins_dbfs, - fr.noise_floor, - state.radio.frequency, - fr.sample_rate, - 8, - ) - .len() - }); + .map_or(0, |fr| rail_peaks(fr, 8).len()); vec![ Line::from(vec![ Span::raw(" "), @@ -278,6 +254,16 @@ pub(super) fn mode_card_lines( mod tests { use super::*; + fn frame(bins: &[f32], noise_floor: f32, sample_rate: f64) -> crate::state::FftFrame { + let state = SdrMetrics::fixture().with_carrier(0.0, 20.0); + let mut frame = state.waterfall.last_fft.unwrap(); + frame.bins_dbfs = std::sync::Arc::new(bins.to_vec()); + frame.noise_floor = noise_floor; + frame.center_freq_hz = 100_000_000; + frame.sample_rate = sample_rate; + frame + } + #[test] fn rail_peaks_maps_bins_to_frequency_strongest_first() { // Two lobes above the โˆ’80 dB noise floor: a tall one left of centre @@ -285,7 +271,7 @@ mod tests { let bins = [ -90.0, -40.0, -10.0, -40.0, -80.0, -50.0, -25.0, -50.0, -90.0, ]; - let peaks = rail_peaks(&bins, -80.0, 100_000_000, 10_000_000.0, 3); + let peaks = rail_peaks(&frame(&bins, -80.0, 10_000_000.0), 3); assert_eq!(peaks.len(), 2, "two distinct lobes above NF+10"); assert!(peaks[0].1 > peaks[1].1, "strongest first"); assert!((peaks[0].1 - (-10.0)).abs() < 1e-3); @@ -296,9 +282,9 @@ mod tests { #[test] fn rail_peaks_empty_without_signal_or_rate() { // All near the floor โ†’ nothing clears NF+10 dB. - assert!(rail_peaks(&[-90.0, -88.0, -90.0], -90.0, 100_000_000, 6_000_000.0, 3).is_empty()); + assert!(rail_peaks(&frame(&[-90.0, -88.0, -90.0], -90.0, 6_000_000.0), 3).is_empty()); // No sample rate โ†’ no usable frequency map. - assert!(rail_peaks(&[-90.0, -10.0, -90.0], -90.0, 100_000_000, 0.0, 3).is_empty()); + assert!(rail_peaks(&frame(&[-90.0, -10.0, -90.0], -90.0, 0.0), 3).is_empty()); } #[test] diff --git a/src/ui/panels/core/command_rail/recall.rs b/src/ui/panels/core/command_rail/recall.rs index bf857d7..a9fc26e 100644 --- a/src/ui/panels/core/command_rail/recall.rs +++ b/src/ui/panels/core/command_rail/recall.rs @@ -22,12 +22,14 @@ fn recall_pip(slot_hz: u64, state: &SdrMetrics, stale: bool) -> Option<(&'static return None; } let fr = state.waterfall.last_fft.as_ref()?; - let half_sr = (fr.sample_rate / 2.0) as u64; - let center = state.radio.frequency; - if slot_hz < center.saturating_sub(half_sr) || slot_hz > center + half_sr { - return None; - } - let peaks = rail_peaks(&fr.bins_dbfs, fr.noise_floor, center, fr.sample_rate, 8); + let window = fr.window(1)?; + fr.bin_axis.nearest_bin( + window.left_hz, + window.span_hz, + window.bin_count, + slot_hz as f64, + )?; + let peaks = rail_peaks(fr, 8); let close = peaks.iter().any(|&(f, _)| f.abs_diff(slot_hz) < 250_000); let strong = peaks .iter() @@ -102,3 +104,24 @@ pub(super) fn lines(state: &SdrMetrics, stale: bool, theme: &crate::Theme) -> Ve out.push(Line::raw("")); out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recall_uses_the_captured_frame_after_retuning() { + let mut state = SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + assert_eq!(recall_pip(108_000_000, &state, false), Some(("โฃฟโก‡", true))); + assert_eq!(recall_pip(208_000_000, &state, false), None); + assert_eq!(recall_pip(108_000_000, &state, true), None); + assert_eq!( + rail_peaks(state.waterfall.last_fft.as_ref().unwrap(), 3), + vec![(108_000_000, -30.0)] + ); + } +} diff --git a/src/ui/panels/core/footer.rs b/src/ui/panels/core/footer.rs index a0a57d8..5d341d2 100644 --- a/src/ui/panels/core/footer.rs +++ b/src/ui/panels/core/footer.rs @@ -188,6 +188,30 @@ fn normal_items( items } +fn normal_items_for( + active_preset: &str, + scope: &[(Option, String)], + micro_view: MicroView, + available_width: u16, + gm: &GainModel, + sample_rate_is_span: bool, +) -> Vec { + if sample_rate_is_span { + let narrow = available_width < NARROW_COLS; + return vec![ + "[Q] Quit".into(), + "[Space] RX".into(), + "[F] Freq".into(), + "[S] Span".into(), + "[R] Reset".into(), + "[Esc] Menu".into(), + "[Tab] Hide".into(), + format!("[P] {}", preset_label(active_preset, narrow)), + ]; + } + normal_items(active_preset, scope, micro_view, available_width, gm) +} + /// Break `items` into lines (groups) where no line exceeds `inner_w` display /// columns. Returns the items per line, preserving boundaries so the renderer /// can style each key/description independently. @@ -312,12 +336,13 @@ pub fn compute_footer_height(available_width: u16, state: &SdrMetrics) -> u16 { count_lines(&focus_items(state), FOCUS_SEP, inner_w) } else { count_lines( - &normal_items( + &normal_items_for( &state.ui.active_preset, &state.ui.scope, state.ui.micro_view(), available_width, &state.caps.gain, + state.caps.sample_rate_is_span, ), NORMAL_SEP, inner_w, @@ -332,6 +357,9 @@ impl Panel for FooterPanel { fn name(&self) -> &'static str { "footer" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 3) } @@ -385,7 +413,12 @@ impl Panel for FooterPanel { m.ui.input_buf )), InputMode::SampleRateInput => prompt(format!( - " Sample rate ({:.1}โ€“{:.1} MHz): [{}โ–Œ] [Enter] Confirm [Esc] Cancel", + " {} ({:.1}โ€“{:.1} MHz): [{}โ–Œ] [Enter] Confirm [Esc] Cancel", + if m.caps.sample_rate_is_span { + "Span" + } else { + "Sample rate" + }, m.caps.sample_rate_min_hz / 1e6, m.caps.sample_rate_max_hz / 1e6, m.ui.input_buf @@ -422,12 +455,13 @@ impl Panel for FooterPanel { } wrapped } else { - let items = normal_items( + let items = normal_items_for( &m.ui.active_preset, &m.ui.scope, m.ui.micro_view(), frame::outer_of(inner).width, &m.caps.gain, + m.caps.sample_rate_is_span, ); let groups = wrap_items_grouped(&items, NORMAL_SEP, inner_w); styled_lines(groups, NORMAL_SEP, theme, max_lines) @@ -624,6 +658,27 @@ mod tests { assert_eq!(items.len(), NORMAL_ITEMS.len() + 1); } + #[test] + fn power_trace_footer_keeps_only_supported_radio_controls() { + let items = normal_items_for( + "spectrum_waterfall", + &[], + MicroView::Main, + 120, + &hackrf::gain_model(), + true, + ); + let text = items.join(" "); + assert!(text.contains("[Space] RX")); + assert!(text.contains("[F] Freq")); + assert!(text.contains("[S] Span")); + assert!(text.contains("[R] Reset")); + assert!(!text.contains("Gain")); + assert!(!text.contains("LNA")); + assert!(!text.contains("VGA")); + assert!(!text.contains("AMP")); + } + #[test] fn normal_items_uses_short_preset_when_narrow() { let items = normal_items( diff --git a/src/ui/panels/core/header.rs b/src/ui/panels/core/header.rs index bcac22c..f3e6598 100644 --- a/src/ui/panels/core/header.rs +++ b/src/ui/panels/core/header.rs @@ -577,8 +577,12 @@ fn bottom_band_line(state: &SdrMetrics, theme: &crate::Theme, inner_width: u16) let active = state.radio.hw_streaming && !state.observer.active; let gm = &state.caps.gain; - // Sample rate: right-padded to 4 chars let sr_str = format!("{:4.1}", state.radio.config_sample_rate / 1_000_000.0); + let (bandwidth_label, bandwidth_unit) = if state.caps.sample_rate_is_span { + ("SPAN ", " MHz") + } else { + ("SR ", " Msps") + }; let freq_color = if state.observer.active { theme.label @@ -617,12 +621,18 @@ fn bottom_band_line(state: &SdrMetrics, theme: &crate::Theme, inner_width: u16) Span::raw(" "), Span::styled("MHz", Style::default().fg(theme.label)), Span::raw(" "), - Span::styled("SR ", Style::default().fg(theme.label)), + Span::styled(bandwidth_label, Style::default().fg(theme.label)), Span::styled(sr_str, Style::default().fg(val_color)), - Span::styled(" Msps", Style::default().fg(theme.label)), + Span::styled(bandwidth_unit, Style::default().fg(theme.label)), ]); let left_w: usize = left_spans.iter().map(|s| s.width()).sum(); + if state.caps.sample_rate_is_span { + let gap = (inner_width as usize).saturating_sub(left_w); + left_spans.push(leader(gap, theme.border_dim)); + return Line::from(left_spans); + } + // right: primary "LNA/TUN "(4) + bar(8) + " "(1) + val(2) + " dB"(3) + " "(4) = 22 // + second stage "VGA "(4) + bar(8) + " "(1) + val(2) + " dB"(3) + " "(2) = 20 (blank on RTL) let right = 22 + 20; @@ -676,6 +686,9 @@ impl Panel for HeaderPanel { fn name(&self) -> &'static str { "header" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (60, 5) } @@ -748,6 +761,9 @@ impl Panel for SlimHeaderPanel { fn name(&self) -> &'static str { "header_slim" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (60, 4) } diff --git a/src/ui/panels/core/log.rs b/src/ui/panels/core/log.rs index 10c1788..e39d87b 100644 --- a/src/ui/panels/core/log.rs +++ b/src/ui/panels/core/log.rs @@ -85,6 +85,9 @@ impl Panel for LogPanel { fn name(&self) -> &'static str { "log" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (20, 7) } diff --git a/src/ui/panels/core/spectrum/axes.rs b/src/ui/panels/core/spectrum/axes.rs index 5ba614d..fdbe460 100644 --- a/src/ui/panels/core/spectrum/axes.rs +++ b/src/ui/panels/core/spectrum/axes.rs @@ -49,12 +49,15 @@ pub(super) fn tuning( freq_hz: u64, step_hz: u64, cursor: Option<(f64, f32)>, + unit: &str, theme: &crate::Theme, ) { let step_str = fmt_spectrum_step(step_hz); let freq_str = format!(" {:.3} MHz ", freq_hz as f64 / 1_000_000.0); let readout = match cursor { - Some((mhz, pwr)) => format!(" cur: {mhz:.3} MHz {pwr:.1} dBFS step {step_str} J/K"), + Some((mhz, pwr)) => { + format!(" cur: {mhz:.3} MHz {pwr:.1} {unit} step {step_str} J/K") + } None => format!(" step {step_str} [/]"), }; diff --git a/src/ui/panels/core/spectrum/labels.rs b/src/ui/panels/core/spectrum/labels.rs index 07795d4..a856bdc 100644 --- a/src/ui/panels/core/spectrum/labels.rs +++ b/src/ui/panels/core/spectrum/labels.rs @@ -285,7 +285,11 @@ pub(super) fn signal_annotations( // Noise-floor label - near the left edge, on the row the line actually sits. let nf_row = ((vert.frac_down_to(noise_floor) * (ch - 1) as f32) as u16).min(ch.saturating_sub(2)); - let nf_label = format!("noise floor {:.0} dBFS", noise_floor); + let nf_label = format!( + "noise floor {:.0} {}", + noise_floor, + state.caps.level_unit.label() + ); let nf_lw = nf_label.chars().count() as u16; if nf_lw < area.width { f.render_widget( diff --git a/src/ui/panels/core/spectrum/mod.rs b/src/ui/panels/core/spectrum/mod.rs index 7ca6b00..e0beef9 100644 --- a/src/ui/panels/core/spectrum/mod.rs +++ b/src/ui/panels/core/spectrum/mod.rs @@ -51,6 +51,9 @@ impl Panel for SpectrumPanel { fn name(&self) -> &'static str { "spectrum" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 10) } @@ -166,6 +169,7 @@ fn contents( fft.center_freq_hz, fft.sample_rate, zoom, + fft.bin_axis, ) else { return; }; @@ -367,6 +371,7 @@ fn draw_instrument( state.radio.frequency, state.spectrum.step_hz, cursor, + state.caps.level_unit.label(), theme, ); } @@ -376,6 +381,7 @@ fn draw_instrument( #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; #[test] fn bond_below_drops_bottom_border() { @@ -418,6 +424,27 @@ mod tests { assert_eq!(full.gutter.height, full.canvas.height); assert_eq!(full.gutter.width, 6); } + + #[test] + fn direct_trace_coordinates_map_through_the_spectrum_view() { + let frequencies = [100_u64, 200, 300]; + let (center_hz, span_hz) = crate::signal::power::trace_window(&frequencies).unwrap(); + let bins = Arc::new(vec![-90.0, -60.0, -80.0]); + let view = SpectrumView::new( + &bins, + &Arc::clone(&bins), + None, + center_hz, + span_hz, + 1, + crate::state::BinAxis::MeasuredPoints, + ) + .unwrap(); + + for (index, frequency) in frequencies.into_iter().enumerate() { + assert_eq!(view.freq_of_bin(index), frequency as f64); + } + } } #[cfg(test)] @@ -430,6 +457,26 @@ mod zoom_tests { SdrMetrics::fixture().streaming().with_carrier(0.0, 40.0) } + #[test] + fn level_axis_and_noise_floor_keep_the_iq_readout() { + let mut state = tuned(); + state.ui.active_preset = "lab_signal".into(); + for (min, max) in [(-120.0, 0.0), (-110.0, -10.0)] { + state.spectrum.y_min = min; + state.spectrum.y_max = max; + let rows = draw(SpectrumPanel, 100, 20, &state); + let gutter: String = rows + .iter() + .skip(1) + .take(18) + .flat_map(|row| row.chars().skip(1).take(6)) + .collect(); + assert!(gutter.contains(&format!("{min:.0}")), "{gutter}"); + assert!(gutter.contains(&format!("{max:.0}")), "{gutter}"); + assert!(rows.join("\n").contains("noise floor -100 dBFS")); + } + } + /// The frequency axis has to narrow with the zoom even when the spectrum is /// standalone. /// diff --git a/src/ui/panels/core/spectrum/scale.rs b/src/ui/panels/core/spectrum/scale.rs index da58afa..c03adc5 100644 --- a/src/ui/panels/core/spectrum/scale.rs +++ b/src/ui/panels/core/spectrum/scale.rs @@ -4,7 +4,7 @@ //! Coordinate mapping and the frequency ruler. //! //! The spectrum works in three coordinate systems at once and they are easy to -//! confuse: hertz, canvas x (`0..n-1`, as wide as the FFT has bins) and terminal +//! confuse: hertz, canvas x (one unit per bin interval) and terminal //! columns (bounded by the panel). Everything that converts between them lives //! here, alone, with tests. @@ -25,33 +25,27 @@ pub(super) fn dim(c: Color, f: f32) -> Color { } } -/// Map a frequency to a canvas x-coordinate in `[0, n-1]`, or `None` if out of view. -pub(super) fn freq_to_canvas_x(freq_hz: f64, left_hz: f64, bw: f64, n: f64) -> Option { - if bw <= 0.0 { +/// Map a frequency to a canvas x-coordinate, or `None` if out of view +pub(super) fn freq_to_canvas_x(freq_hz: f64, left_hz: f64, bw: f64, max_x: f64) -> Option { + if bw <= 0.0 || max_x <= 0.0 { return None; } let frac = (freq_hz - left_hz) / bw; if (0.0..=1.0).contains(&frac) { - Some(frac * (n - 1.0)) + Some(frac * max_x) } else { None } } -/// A canvas x - the units [`freq_to_canvas_x`] returns, spanning `0..n-1` - as a -/// terminal column inside `width`. -/// -/// The two coordinate systems cost nothing to mix up silently: the canvas is as -/// wide as the spectrum has bins, typically 2048, while the column is bounded by -/// the panel, typically under 200. Using one as the other clamps every position -/// to the right-hand edge, which is exactly what the OBW label did at every -/// terminal size it was tried at. -pub(super) fn canvas_x_to_col(x: f64, n: f64, width: u16) -> u16 { - if n <= 1.0 || width == 0 { +/// Map a canvas x-coordinate to the column painted by the Braille canvas +pub(super) fn canvas_x_to_col(x: f64, max_x: f64, width: u16) -> u16 { + if max_x <= 0.0 || width == 0 { return 0; } - let frac = (x / (n - 1.0)).clamp(0.0, 1.0); - ((frac * width as f64).round() as u16).min(width - 1) + let x = x.clamp(0.0, max_x); + let dot = (x * (2.0 * width as f64 - 1.0) / max_x) as usize; + (dot / 2) as u16 } /// The occupied-bandwidth window as a symmetric span around `center_hz` - the @@ -157,26 +151,59 @@ pub fn freq_scale_spans( mod tests { use super::*; + #[test] + fn columns_match_the_rendered_braille_dot() { + use ratatui::{ + buffer::Buffer, + layout::Rect, + widgets::{ + canvas::{Canvas, Points}, + Widget, + }, + }; + for width in [1, 2, 40, 160] { + for x in [0.0, 0.1, 7.9, 8.0, 16.0, 24.0, 31.9, 32.0] { + let area = Rect::new(0, 0, width, 1); + let mut buffer = Buffer::empty(area); + Canvas::default() + .x_bounds([0.0, 32.0]) + .y_bounds([0.0, 1.0]) + .paint(|ctx| { + ctx.draw(&Points { + coords: &[(x, 0.0)], + color: Color::Red, + }) + }) + .render(area, &mut buffer); + let painted = (0..width) + .find(|col| buffer.get(*col, 0).fg == Color::Red) + .unwrap(); + assert_eq!( + canvas_x_to_col(x, 32.0, width), + painted, + "width {width} x {x}" + ); + } + } + } + #[test] fn canvas_x_and_column_are_not_interchangeable() { - // The bug this guards: a 2048-bin canvas x used directly as a column. - // Three quarters across a 2048-bin canvas is column 120 of 160, not 160. - let n = 2048.0; - assert_eq!(canvas_x_to_col(0.0, n, 160), 0); + let max_x = 2048.0; + assert_eq!(canvas_x_to_col(0.0, max_x, 160), 0); assert_eq!( - canvas_x_to_col(n - 1.0, n, 160), + canvas_x_to_col(max_x, max_x, 160), 159, - "the last bin is the last column" + "the right edge is the last column" ); - assert_eq!(canvas_x_to_col((n - 1.0) * 0.75, n, 160), 120); - // Degenerate geometry answers 0 rather than dividing by zero. - assert_eq!(canvas_x_to_col(500.0, 1.0, 160), 0); - assert_eq!(canvas_x_to_col(500.0, n, 0), 0); + assert_eq!(canvas_x_to_col(max_x * 0.75, max_x, 160), 119); + assert_eq!(canvas_x_to_col(500.0, 0.0, 160), 0); + assert_eq!(canvas_x_to_col(500.0, max_x, 0), 0); } #[test] fn freq_to_canvas_x_rejects_what_is_off_screen() { - let (left, bw, n) = (92_000_000.0, 2_000_000.0, 1001.0); + let (left, bw, n) = (92_000_000.0, 2_000_000.0, 1000.0); assert_eq!(freq_to_canvas_x(92_000_000.0, left, bw, n), Some(0.0)); assert_eq!(freq_to_canvas_x(94_000_000.0, left, bw, n), Some(1000.0)); assert_eq!(freq_to_canvas_x(93_000_000.0, left, bw, n), Some(500.0)); diff --git a/src/ui/panels/core/spectrum/trace.rs b/src/ui/panels/core/spectrum/trace.rs index b0772c0..bbe0dcb 100644 --- a/src/ui/panels/core/spectrum/trace.rs +++ b/src/ui/panels/core/spectrum/trace.rs @@ -151,6 +151,26 @@ pub(super) struct Layers { pub noise_floor: f32, } +fn series_segments( + values: &[f32], + tail_width: f64, +) -> impl Iterator + '_ { + let joined = values + .windows(2) + .enumerate() + .map(|(i, pair)| (i as f64, pair[0], (i + 1) as f64, pair[1])); + // Extend the last FFT sample across its final interval + let tail = values.last().filter(|_| tail_width > 0.0).map(|&last| { + ( + (values.len() - 1) as f64, + last, + (values.len() - 1) as f64 + tail_width, + last, + ) + }); + joined.chain(tail) +} + /// Paint the trace and everything drawn on the canvas itself. pub(super) fn draw( f: &mut Frame, @@ -168,6 +188,7 @@ pub(super) fn draw( } = layers; let pal = Palette::new(theme); let n = view.n(); + let tail_width = view.bin_end(0); let (y_min, y_max) = (vert.min as f64, vert.max as f64); // The closure takes ownership, so everything it needs is moved in. @@ -183,7 +204,7 @@ pub(super) fn draw( f.render_widget( Canvas::default() - .x_bounds([0.0, (n - 1.0).max(0.0)]) + .x_bounds([0.0, n.max(0.0)]) .y_bounds([y_min, y_max]) .paint(move |ctx| { let bright_at = |level: f32| band_bright[band_of(level, v_min, span, steps)]; @@ -201,19 +222,19 @@ pub(super) fn draw( ctx.draw(&CanvasLine { x1: 0.0, y1: y, - x2: n - 1.0, + x2: n, y2: y, color, }); }; let series = |ctx: &mut ratatui::widgets::canvas::Context, v: &[f32], color: Color| { - for i in 1..v.len() { + for (x1, y1, x2, y2) in series_segments(v, tail_width) { ctx.draw(&CanvasLine { - x1: (i - 1) as f64, - y1: v[i - 1].clamp(v_min, v_max) as f64, - x2: i as f64, - y2: v[i].clamp(v_min, v_max) as f64, + x1, + y1: y1.clamp(v_min, v_max) as f64, + x2, + y2: y2.clamp(v_min, v_max) as f64, color, }); } @@ -223,7 +244,7 @@ pub(super) fn draw( // so only the parts above the signal show through. for i in 0..=4 { level_line(ctx, y_min + (y_max - y_min) * (i as f64 / 4.0), pal.grid); - rule(ctx, (n - 1.0).max(0.0) * (i as f64 / 4.0), pal.grid); + rule(ctx, n.max(0.0) * (i as f64 / 4.0), pal.grid); } // 1. Hold ghost - the entire frozen spectrum as a soft outline. if let Some(ref h) = held { @@ -251,7 +272,7 @@ pub(super) fn draw( ctx.draw(&CanvasLine { x1: start as f64, y1: yb as f64, - x2: (i - 1) as f64, + x2: view.bin_end(i - 1), y2: yb as f64, color, }); @@ -265,13 +286,12 @@ pub(super) fn draw( // height. Only Braille draws it: Fill's bright body is its own // edge, Scatter has no line. if style == SpectrumStyle::Braille { - for i in 1..bins.len() { - let (y0, y1) = - (bins[i - 1].clamp(v_min, v_max), bins[i].clamp(v_min, v_max)); + for (x0, y0, x1, y1) in series_segments(&bins, tail_width) { + let (y0, y1) = (y0.clamp(v_min, v_max), y1.clamp(v_min, v_max)); ctx.draw(&CanvasLine { - x1: (i - 1) as f64, + x1: x0, y1: y0 as f64, - x2: i as f64, + x2: x1, y2: y1 as f64, color: bright_at((y0 + y1) * 0.5), }); @@ -336,6 +356,95 @@ pub(super) fn draw( mod tests { use super::*; + #[test] + fn series_keep_connected_samples_and_extend_only_the_final_interval() { + assert_eq!( + series_segments(&[-80.0, -20.0], 1.0).collect::>(), + vec![(0.0, -80.0, 1.0, -20.0), (1.0, -20.0, 2.0, -20.0)] + ); + assert_eq!( + series_segments(&[-80.0, -20.0], 0.0).collect::>(), + vec![(0.0, -80.0, 1.0, -20.0)] + ); + assert_eq!(series_segments(&[], 1.0).count(), 0); + assert_eq!( + series_segments(&[-20.0], 1.0).collect::>(), + vec![(0.0, -20.0, 1.0, -20.0)] + ); + } + + fn render_style( + style: SpectrumStyle, + bins: Vec, + held: Option>>, + ) -> ratatui::buffer::Buffer { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 10)).unwrap(); + let theme = crate::theme::Theme::sdr(); + let bins = Arc::new(bins); + let view = SpectrumView::new( + &bins, + &Arc::new(Vec::new()), + held, + 100_000_000, + 32_000_000.0, + 1, + crate::state::BinAxis::FftBins, + ) + .unwrap(); + terminal + .draw(|f| { + draw( + f, + f.size(), + &view, + &Vertical::new(-100.0, 0.0, 10, &theme), + Layers { + rules: Rules { + markers: vec![], + obw: (None, None), + cursor: None, + }, + ghosts: LabGhosts { + trace: None, + ref_dbfs: None, + }, + style, + noise_floor: -100.0, + }, + &theme, + ); + }) + .unwrap(); + terminal.backend().buffer().clone() + } + + #[test] + fn final_fft_interval_is_filled_without_turning_scatter_into_a_line() { + for bins in [vec![-100.0, -20.0], vec![-20.0]] { + let braille = render_style(SpectrumStyle::Braille, bins.clone(), None); + let fill = render_style(SpectrumStyle::Fill, bins.clone(), None); + let scatter = render_style(SpectrumStyle::Scatter, bins.clone(), None); + let background = render_style(SpectrumStyle::Scatter, vec![-100.0; bins.len()], None); + // Column 35 lies inside the final FFT interval at both bin counts + assert_ne!(braille.get(35, 4), background.get(35, 4)); + assert_ne!(fill.get(35, 4), background.get(35, 4)); + assert_eq!(scatter.get(35, 4), background.get(35, 4)); + assert_ne!(braille.get(35, 1), background.get(35, 1)); + } + } + + #[test] + fn held_trace_reaches_the_end_of_its_last_interval() { + let background = render_style(SpectrumStyle::Scatter, vec![-100.0; 2], None); + let held = render_style( + SpectrumStyle::Scatter, + vec![-100.0; 2], + Some(Arc::new(vec![-20.0; 2])), + ); + assert_ne!(held.get(35, 1), background.get(35, 1)); + } + #[test] fn band_of_is_monotone_and_never_indexes_past_the_palette() { let t = crate::theme::Theme::sdr(); diff --git a/src/ui/panels/core/spectrum/view.rs b/src/ui/panels/core/spectrum/view.rs index 1012b90..cddd54f 100644 --- a/src/ui/panels/core/spectrum/view.rs +++ b/src/ui/panels/core/spectrum/view.rs @@ -15,6 +15,8 @@ use std::sync::Arc; +use crate::state::BinAxis; + /// The window the panel is actually drawing: the bins in view and the frequency /// span they cover. pub(super) struct SpectrumView { @@ -30,6 +32,7 @@ pub(super) struct SpectrumView { pub left_hz: f64, /// Width of the window in hertz. pub bw: f64, + bin_axis: BinAxis, } impl SpectrumView { @@ -47,41 +50,36 @@ impl SpectrumView { center_hz: u64, sample_rate: f64, zoom: usize, + bin_axis: BinAxis, ) -> Option { let full_n = bins.len(); - if full_n == 0 || sample_rate <= 0.0 { - return None; - } - let full_left = center_hz as f64 - sample_rate / 2.0; - let zoom = zoom.max(1); + let window = bin_axis.window(center_hz, sample_rate, full_n, zoom)?; + let lo = window.first_bin; + let hi = lo + window.bin_count; - if zoom == 1 { + if lo == 0 && hi == full_n { // Arc::clone is O(1) - no data copied. return Some(Self { bins: Arc::clone(bins), peaks: Arc::clone(peaks), held, n_bins: full_n, - left_hz: full_left, - bw: sample_rate, + left_hz: window.left_hz, + bw: window.span_hz, + bin_axis, }); } - let visible_n = (full_n / zoom).max(1); - let lo = (full_n / 2) - .saturating_sub(visible_n / 2) - .min(full_n - visible_n); - let hi = lo + visible_n; - let bin_hz = sample_rate / full_n as f64; let win = |v: &[f32]| Arc::new(v[lo.min(v.len())..hi.min(v.len())].to_vec()); Some(Self { bins: win(bins), peaks: win(peaks), held: held.map(|h| win(&h)), - n_bins: visible_n, - left_hz: full_left + lo as f64 * bin_hz, - bw: visible_n as f64 * bin_hz, + n_bins: window.bin_count, + left_hz: window.left_hz, + bw: window.span_hz, + bin_axis, }) } @@ -90,24 +88,31 @@ impl SpectrumView { self.left_hz + self.bw } - /// Canvas width in the units the paint closure works in: `0..n-1`. + /// Right edge of the canvas in bin-interval units. pub fn n(&self) -> f64 { - self.n_bins as f64 + self.bin_axis.interval_count(self.n_bins).unwrap_or(1) as f64 + } + + pub fn bin_end(&self, index: usize) -> f64 { + match self.bin_axis { + BinAxis::FftBins => (index + 1) as f64, + BinAxis::MeasuredPoints => index as f64, + } } /// The level at `freq_hz`, or `None` when it falls outside the window. pub fn level_at(&self, freq_hz: u64) -> Option { - let frac = (freq_hz as f64 - self.left_hz) / self.bw; - if !(0.0..=1.0).contains(&frac) { - return None; - } - let idx = (frac * (self.n_bins - 1) as f64).round() as usize; - self.bins.get(idx.min(self.n_bins - 1)).copied() + let idx = self + .bin_axis + .nearest_bin(self.left_hz, self.bw, self.n_bins, freq_hz as f64)?; + self.bins.get(idx).copied() } /// The centre frequency of bin `idx`. pub fn freq_of_bin(&self, idx: usize) -> f64 { - self.left_hz + self.bw * (idx as f64 / (self.n_bins - 1).max(1) as f64) + self.bin_axis + .frequency_of_bin(self.left_hz, self.bw, self.n_bins, idx) + .unwrap_or(self.left_hz) } } @@ -123,7 +128,16 @@ mod tests { fn zoom_one_shows_the_whole_span_without_copying() { let bins = ramp(1024); let peaks = ramp(1024); - let v = SpectrumView::new(&bins, &peaks, None, 92_800_000, 2_000_000.0, 1).unwrap(); + let v = SpectrumView::new( + &bins, + &peaks, + None, + 92_800_000, + 2_000_000.0, + 1, + BinAxis::FftBins, + ) + .unwrap(); assert_eq!(v.n_bins, 1024); assert_eq!(v.left_hz, 91_800_000.0); assert_eq!(v.bw, 2_000_000.0); @@ -136,7 +150,16 @@ mod tests { #[test] fn zoom_takes_the_centre_slice_around_the_tuned_frequency() { let bins = ramp(1000); - let v = SpectrumView::new(&bins, &ramp(1000), None, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1000), + None, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); assert_eq!(v.n_bins, 250); assert_eq!(v.bins[0], 375.0, "starts a quarter of the way in, not at 0"); assert_eq!(v.bw, 500_000.0, "a quarter of the span"); @@ -144,21 +167,90 @@ mod tests { assert!(v.left_hz < 92_800_000.0 && v.right_hz() > 92_800_000.0); } + #[test] + fn measured_point_zoom_keeps_the_slice_endpoints() { + let bins = ramp(64); + let v = SpectrumView::new( + &bins, + &ramp(64), + None, + 131_500_000, + 63_000_000.0, + 4, + BinAxis::MeasuredPoints, + ) + .unwrap(); + + assert_eq!(v.bins.first(), Some(&24.0)); + assert_eq!(v.bins.last(), Some(&39.0)); + assert_eq!(v.freq_of_bin(0), 124_000_000.0); + assert_eq!(v.freq_of_bin(15), 139_000_000.0); + assert_eq!(v.left_hz, 124_000_000.0); + assert_eq!(v.right_hz(), 139_000_000.0); + } + + #[test] + fn bin_frequencies_map_to_their_canvas_positions() { + for axis in [BinAxis::FftBins, BinAxis::MeasuredPoints] { + let bins = ramp(32); + let view = + SpectrumView::new(&bins, &ramp(32), None, 100_000_000, 32_000_000.0, 1, axis) + .unwrap(); + for index in [0, 8, 16, 24, 31] { + let x = super::super::scale::freq_to_canvas_x( + view.freq_of_bin(index), + view.left_hz, + view.bw, + view.n(), + ) + .unwrap(); + assert!((x - index as f64).abs() < 1e-9, "{axis:?} bin {index}"); + } + } + } + #[test] fn a_hold_captured_at_another_bin_count_does_not_panic() { // The user changed sample rate while holding: the snapshot is shorter // than the live frame, so the window has to clamp to its own length. let bins = ramp(1024); let held = Some(ramp(200)); - let v = SpectrumView::new(&bins, &ramp(1024), held, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1024), + held, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); assert!(v.held.unwrap().len() <= 200); } #[test] fn an_empty_frame_yields_no_view() { - assert!(SpectrumView::new(&ramp(0), &ramp(0), None, 92_800_000, 2_000_000.0, 1).is_none()); + assert!(SpectrumView::new( + &ramp(0), + &ramp(0), + None, + 92_800_000, + 2_000_000.0, + 1, + BinAxis::FftBins, + ) + .is_none()); assert!( - SpectrumView::new(&ramp(64), &ramp(64), None, 92_800_000, 0.0, 1).is_none(), + SpectrumView::new( + &ramp(64), + &ramp(64), + None, + 92_800_000, + 0.0, + 1, + BinAxis::FftBins, + ) + .is_none(), "a zero sample rate has no span to draw" ); } @@ -166,9 +258,94 @@ mod tests { #[test] fn level_at_reads_the_window_not_the_frame() { let bins = ramp(1000); - let v = SpectrumView::new(&bins, &ramp(1000), None, 92_800_000, 2_000_000.0, 4).unwrap(); + let v = SpectrumView::new( + &bins, + &ramp(1000), + None, + 92_800_000, + 2_000_000.0, + 4, + BinAxis::FftBins, + ) + .unwrap(); // Mid-window is bin 125 of the slice, which held the value 500. assert_eq!(v.level_at((v.left_hz + v.bw / 2.0) as u64), Some(500.0)); assert!(v.level_at(90_000_000).is_none(), "outside the window"); } + + #[test] + fn fft_bin_24_maps_to_canvas_24() { + let bins = ramp(32); + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + 1, + BinAxis::FftBins, + ) + .unwrap(); + assert_eq!(view.freq_of_bin(24), 108_000_000.0); + assert_eq!(view.level_at(108_000_000), Some(24.0)); + assert_eq!( + super::super::scale::freq_to_canvas_x(108_000_000.0, view.left_hz, view.bw, view.n(),), + Some(24.0) + ); + } + + #[test] + fn sub_bin_lookup_stays_in_its_fft_interval_at_high_zoom() { + let bins = ramp(256); + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + 32, + BinAxis::FftBins, + ) + .unwrap(); + for i in 0..view.n_bins { + let hz = view.freq_of_bin(i) as u64; + for offset in [0, 62_500, 100_000, 124_999] { + assert_eq!(view.level_at(hz + offset), Some(view.bins[i])); + } + } + } + + #[test] + fn bin_frequencies_map_to_their_canvas_positions_across_zoom() { + let bins = ramp(32); + for zoom in [0, 1, 2, 3, 4, 32, 64] { + let view = SpectrumView::new( + &bins, + &bins, + None, + 100_000_000, + 32_000_000.0, + zoom, + BinAxis::FftBins, + ) + .unwrap(); + for index in 0..view.n_bins { + let frequency = 84_000_000.0 + view.bins[index] as f64 * 1_000_000.0; + assert_eq!(view.freq_of_bin(index), frequency); + assert_eq!(view.level_at(frequency as u64), Some(view.bins[index])); + let x = super::super::scale::freq_to_canvas_x( + frequency, + view.left_hz, + view.bw, + view.n(), + ) + .unwrap(); + assert!((x - index as f64).abs() < 1e-9, "zoom {zoom} bin {index}"); + } + assert_eq!( + view.level_at(view.right_hz() as u64), + view.bins.last().copied() + ); + } + } } diff --git a/src/ui/panels/core/system_resources.rs b/src/ui/panels/core/system_resources.rs index bbc62bb..ecda773 100644 --- a/src/ui/panels/core/system_resources.rs +++ b/src/ui/panels/core/system_resources.rs @@ -18,6 +18,9 @@ impl Panel for SystemResourcesPanel { fn name(&self) -> &'static str { "system_resources" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (30, 10) } diff --git a/src/ui/panels/core/waterfall/axes.rs b/src/ui/panels/core/waterfall/axes.rs index 9f339bb..991a377 100644 --- a/src/ui/panels/core/waterfall/axes.rs +++ b/src/ui/panels/core/waterfall/axes.rs @@ -23,7 +23,7 @@ use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; use crate::state::SdrMetrics; use crate::ui::panels::core::spectrum::fmt_spectrum_step; -use super::cells::{band_max, Columns, DB_MAX}; +use super::cells::{band_max, Columns}; /// Width of the dB gutter. Matches the spectrum's label column so the two plots /// start at the same x. @@ -35,6 +35,7 @@ pub(super) fn db_legend( f: &mut Frame, area: Rect, db_min: f32, + db_max: f32, palette: WaterfallPalette, theme: &crate::Theme, ) { @@ -46,9 +47,9 @@ pub(super) fn db_legend( let steps = (h * 2).max(2); let at = |t: f32| { magnitude_to_color_palette( - DB_MAX + (db_min - DB_MAX) * t, + db_max + (db_min - db_max) * t, db_min, - DB_MAX, + db_max, depth, theme, palette, @@ -60,9 +61,9 @@ pub(super) fn db_legend( let top = at((row * 2) as f32 / (steps - 1) as f32); let bot = at((row * 2 + 1) as f32 / (steps - 1) as f32); let label = match row { - 0 => format!("{:>+4} ", DB_MAX as i32), + 0 => format!("{:>+4} ", db_max as i32), r if r == h.saturating_sub(1) => format!("{:>4} ", db_min as i32), - r if r == h / 2 => format!("{:>4} ", ((DB_MAX + db_min) / 2.0) as i32), + r if r == h / 2 => format!("{:>4} ", ((db_max + db_min) / 2.0) as i32), _ => " ".to_string(), }; Line::from(vec![ @@ -82,14 +83,21 @@ pub(super) fn indicator( area: Rect, state: &SdrMetrics, rows: &VecDeque<(Instant, Arc>)>, - columns: &Columns, + columns: Option<&Columns>, skip_data: usize, cursor_col: Option, stride: usize, theme: &crate::Theme, ) { - let text = match (state.waterfall.cursor_freq, cursor_col) { - (Some(cf), Some(col)) => cursor_readout(cf, col, rows, columns, skip_data), + let text = match (state.waterfall.cursor_freq, cursor_col.zip(columns)) { + (Some(cf), Some((col, columns))) => cursor_readout( + cf, + col, + rows, + columns, + skip_data, + state.caps.level_unit.label(), + ), // A cursor set outside the current zoom window: name it, but there is // nothing on screen to read a level from. (Some(cf), None) => format!(" cur: {:.3} MHz \u{2190} \u{2192} M", cf as f64 / 1e6), @@ -120,6 +128,7 @@ fn cursor_readout( rows: &VecDeque<(Instant, Arc>)>, columns: &Columns, skip_data: usize, + unit: &str, ) -> String { let mhz = freq_hz as f64 / 1e6; let Some((ts, row)) = rows.get(skip_data) else { @@ -129,10 +138,32 @@ fn cursor_readout( let db = band_max(row, lo, hi); if db.is_finite() { format!( - " cur: {mhz:.3} MHz {db:.1} dBFS {}s ago \u{2190} \u{2192} M", + " cur: {mhz:.3} MHz {db:.1} {unit} {}s ago \u{2190} \u{2192} M", ts.elapsed().as_secs() ) } else { format!(" cur: {mhz:.3} MHz \u{2190} \u{2192} M") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_readout_keeps_the_iq_unit_and_level() { + let rows = VecDeque::from([(Instant::now(), Arc::new(vec![-60.0]))]); + let window = crate::state::BinAxis::FftBins + .window(100_000_000, 1.0, 1, 1) + .unwrap(); + let text = cursor_readout( + 100_000_000, + 0, + &rows, + &Columns::new(window, 1, crate::state::BinAxis::FftBins), + 0, + crate::hardware::LevelUnit::Dbfs.label(), + ); + assert!(text.starts_with(" cur: 100.000 MHz -60.0 dBFS ")); + } +} diff --git a/src/ui/panels/core/waterfall/cells.rs b/src/ui/panels/core/waterfall/cells.rs index cd85f88..02fc20b 100644 --- a/src/ui/panels/core/waterfall/cells.rs +++ b/src/ui/panels/core/waterfall/cells.rs @@ -22,10 +22,7 @@ use ratatui::{ }; use crate::palette::{magnitude_to_color_palette, ColorDepth, WaterfallPalette}; - -/// Top of the colour scale. The waterfall is always referenced to full scale; -/// only the floor (`db_min`) moves, under `โ†‘`/`โ†“`. -pub(super) const DB_MAX: f32 = 0.0; +use crate::state::{BinAxis, BinWindow}; /// Max dB over the bin range `[start, end)` of one waterfall row, clamped to the /// row's own length. Rows are normally all the (fixed) FFT bin count, but reading @@ -47,34 +44,44 @@ pub(super) fn band_max(row: &[f32], start: usize, end: usize) -> f32 { /// Zoom keeps the centre `1/zoom` of the row's bins - the same slice the bonded /// spectrum above narrows to, so `+`/`-` zoom both plots as one instrument. pub(super) struct Columns { - lo_bin: usize, - visible_n: usize, - row_bins: usize, + pub window: BinWindow, cols: usize, + bin_axis: BinAxis, } impl Columns { - pub fn new(row_bins: usize, zoom: u32, cols: usize) -> Self { - // A zero-bin row would underflow the `row_bins - 1` clamp below. It should - // not happen, but a malformed row must not take the TUI down with it. - let row_bins = row_bins.max(1); - let visible_n = (row_bins / (zoom as usize).max(1)).max(1); + /// Use a window computed from the row being drawn + pub fn new(window: BinWindow, cols: usize, bin_axis: BinAxis) -> Self { Self { - lo_bin: (row_bins / 2).saturating_sub(visible_n / 2), - visible_n, - row_bins, + window, cols: cols.max(1), + bin_axis, } } /// The `[start, end)` bin span column `col` reads. Always non-empty, so a /// wide panel over few bins still gets one bin per column rather than none. pub fn range(&self, col: usize) -> (usize, usize) { - let start = (self.lo_bin + col * self.visible_n / self.cols).min(self.row_bins - 1); - let end = (self.lo_bin + ((col + 1) * self.visible_n) / self.cols) - .max(start + 1) - .min(self.row_bins); - (start, end) + let visible_n = self.window.bin_count; + let (start, end) = match self.bin_axis { + BinAxis::FftBins => ( + col * visible_n / self.cols, + (col + 1) * visible_n / self.cols, + ), + BinAxis::MeasuredPoints => { + let intervals = visible_n.saturating_sub(1); + let start = (col * intervals).div_ceil(self.cols); + let end = if col + 1 >= self.cols { + visible_n + } else { + ((col + 1) * intervals).div_ceil(self.cols) + }; + (start, end) + } + }; + let start = start.min(visible_n - 1); + let end = end.max(start + 1).min(visible_n); + (self.window.first_bin + start, self.window.first_bin + end) } } @@ -84,32 +91,42 @@ pub(super) fn draw( f: &mut Frame, area: Rect, rows: &VecDeque<(Instant, Arc>)>, - columns: &Columns, + columns_for_row: impl Fn(usize) -> Option, cursor_col: Option, skip_data: usize, db_min: f32, + db_max: f32, palette: WaterfallPalette, theme: &crate::Theme, ) { let cols = area.width as usize; let depth = ColorDepth::detect(); - let color = |db: f32| magnitude_to_color_palette(db, db_min, DB_MAX, depth, theme, palette); + let color = |db: f32| magnitude_to_color_palette(db, db_min, db_max, depth, theme, palette); let floor = color(f32::NEG_INFINITY); let mut lines: Vec = Vec::with_capacity(area.height as usize); let mut data = rows.iter().skip(skip_data).take(area.height as usize * 2); while let Some((_ts, top_row)) = data.next() { let bot_row = data.next().map(|(_ts, r)| r.as_ref()); + let top_columns = columns_for_row(top_row.len()); + let bot_columns = bot_row.and_then(|row| columns_for_row(row.len())); + let row_color = |row: &[f32], columns: Option<&Columns>, col| { + columns.map_or(floor, |columns| { + let (lo, hi) = columns.range(col); + color(band_max(row, lo, hi)) + }) + }; let spans: Vec = (0..cols) .map(|col| { - let (lo, hi) = columns.range(col); - let bot_color = bot_row.map(|r| color(band_max(r, lo, hi))).unwrap_or(floor); + let bot_color = bot_row + .map(|r| row_color(r, bot_columns.as_ref(), col)) + .unwrap_or(floor); // The cursor column keeps the background so the history still reads // through it, but takes a bright foreground as its marker. let top_color = if Some(col) == cursor_col { theme.value_hi } else { - color(band_max(top_row, lo, hi)) + row_color(top_row, top_columns.as_ref(), col) }; Span::styled("\u{2580}", Style::default().fg(top_color).bg(bot_color)) }) @@ -124,6 +141,51 @@ pub(super) fn draw( mod tests { use super::*; + fn columns(row_bins: usize, zoom: usize, cols: usize) -> Columns { + let axis = BinAxis::FftBins; + let window = axis + .window(100_000_000, 32_000_000.0, row_bins, zoom) + .unwrap(); + Columns::new(window, cols, axis) + } + #[test] + fn cell_colors_use_the_configured_ceiling() { + let theme = crate::Theme::sdr(); + let palette = WaterfallPalette::default(); + let rows = VecDeque::from([(Instant::now(), Arc::new(vec![-10.0]))]); + for max in [0.0, -10.0] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(1, 1)).unwrap(); + terminal + .draw(|f| { + draw( + f, + f.size(), + &rows, + |row_bins| Some(columns(row_bins, 1, 1)), + None, + 0, + -110.0, + max, + palette, + &theme, + ) + }) + .unwrap(); + assert_eq!( + terminal.backend().buffer().get(0, 0).fg, + magnitude_to_color_palette( + -10.0, + -110.0, + max, + ColorDepth::detect(), + &theme, + palette + ) + ); + } + } + #[test] fn band_max_reads_in_range() { let row = [-90.0, -50.0, -70.0, -60.0]; @@ -148,7 +210,7 @@ mod tests { #[test] fn unzoomed_columns_cover_every_bin_exactly_once() { - let c = Columns::new(1024, 1, 128); + let c = columns(1024, 1, 128); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 0, "the first column starts at the first bin"); @@ -165,18 +227,34 @@ mod tests { #[test] fn zoom_keeps_the_centre_slice() { - let c = Columns::new(1024, 4, 128); + let c = columns(1024, 4, 128); let (first, _) = c.range(0); let (_, last) = c.range(127); assert_eq!(first, 384, "a quarter of the way in"); assert_eq!(last, 640, "and out again: 256 bins around the centre"); } + #[test] + fn columns_follow_the_bin_window_at_uneven_zoom() { + let axis = BinAxis::FftBins; + let window = axis.window(100, 10.0, 10, 3).unwrap(); + let columns = Columns::new(window, 3, axis); + assert_eq!(columns.range(0), (4, 5)); + assert_eq!(columns.range(1), (5, 6)); + assert_eq!(columns.range(2), (6, 7)); + for col in 0..3 { + assert_eq!( + axis.frequency_of_bin(window.left_hz, window.span_hz, window.bin_count, col), + Some(99.0 + col as f64) + ); + } + } + #[test] fn a_column_is_never_empty_however_odd_the_geometry() { // More columns than bins: every column still reads at least one bin, // rather than an empty span that would paint the whole plot at the floor. - let c = Columns::new(8, 1, 200); + let c = columns(8, 1, 200); for col in 0..200 { let (lo, hi) = c.range(col); assert!(hi > lo, "column {col} is empty"); @@ -194,12 +272,12 @@ mod tests { let (row_bins, cols) = (1024usize, 128usize); let naive = |col: usize| col * row_bins / cols; - let unzoomed = Columns::new(row_bins, 1, cols); + let unzoomed = columns(row_bins, 1, cols); for col in 0..cols { assert_eq!(unzoomed.range(col).0, naive(col), "zoom 1 hides the bug"); } - let zoomed = Columns::new(row_bins, 4, cols); + let zoomed = columns(row_bins, 4, cols); assert_eq!(zoomed.range(0).0, 384); assert_eq!( naive(0), @@ -211,13 +289,40 @@ mod tests { } #[test] - fn degenerate_input_does_not_underflow() { + fn singleton_and_zero_columns_do_not_underflow() { // A zero-bin row and a zero zoom are both nonsense, and both used to be // one subtraction away from panicking. - let c = Columns::new(0, 0, 40); + let c = columns(1, 32, 40); let (lo, hi) = c.range(0); assert!(hi > lo); - let zero_cols = Columns::new(1024, 4, 0); + let zero_cols = columns(1024, 4, 0); let _ = zero_cols.range(0); } + + #[test] + fn high_zoom_sub_bin_frequencies_use_the_same_interval() { + let axis = BinAxis::FftBins; + let columns = columns(256, 32, 160); + let window = columns.window; + for col in 0..160 { + let frequency = window.left_hz + col as f64 * window.span_hz / 160.0; + let index = axis + .nearest_bin(window.left_hz, window.span_hz, window.bin_count, frequency) + .unwrap(); + assert_eq!( + columns.range(col).0, + window.first_bin + index, + "column {col}" + ); + } + } + #[test] + fn measured_point_columns_follow_endpoint_coordinates() { + let window = BinAxis::MeasuredPoints + .window(131_500_000, 63_000_000.0, 64, 1) + .unwrap(); + let columns = Columns::new(window, 40, BinAxis::MeasuredPoints); + assert_eq!(columns.range(0), (0, 2)); + assert_eq!(columns.range(39), (62, 64)); + } } diff --git a/src/ui/panels/core/waterfall/mod.rs b/src/ui/panels/core/waterfall/mod.rs index af7c59a..daf05d0 100644 --- a/src/ui/panels/core/waterfall/mod.rs +++ b/src/ui/panels/core/waterfall/mod.rs @@ -101,6 +101,9 @@ impl Panel for WaterfallPanel { fn name(&self) -> &'static str { "waterfall" } + fn supports_acquisition(&self, _acquisition: crate::hardware::AcquisitionKind) -> bool { + true + } fn min_size(&self) -> (u16, u16) { (40, 5) } @@ -208,13 +211,6 @@ fn contents( return; } - // The bonded status cap and the nameplate answer the same question, so they - // ask it once. This used to be a second copy of the rule against a local - // `STALE_MS = 500` sitting beside `panel::FFT_STALE_MS = 500`: the two agreed - // only by coincidence, and disagreed already on "no frame yet" (the copy said - // fresh, the plate said stale). Unreachable in practice - `contents` has - // returned by then if there are no rows - but two rules for one word is how - // the deck starts contradicting itself. let stale = Staleness::FftAge.resolve(state); // Clamp the reported scroll to what the buffer can actually give, so the // bonded status cap never promises history that is not there. The content @@ -252,16 +248,21 @@ fn contents( } let cols = plot.width as usize; - // The frequency window, narrowed by the shared zoom around the tuned centre. - let window = wf - .last_fft + // Two data rows occupy each character row + let data_rows = plot.height as usize * 2; + let max_scroll = buf.rows.len().saturating_sub(data_rows) / 2; + let skip_data = wf.scroll_offset.min(max_scroll) * 2; + + let columns_for_row = |row_bins| columns_for_row(wf, row_bins, cols); + let columns = buf + .rows + .get(skip_data) + .and_then(|(_, row)| columns_for_row(row.len())); + let window = columns .as_ref() - .map(|fr| { - let visible = fr.sample_rate / wf.hz_zoom as f64; - Window { - left_hz: fr.center_freq_hz as f64 - visible / 2.0, - bw: visible, - } + .map(|columns| Window { + left_hz: columns.window.left_hz, + bw: columns.window.span_hz, }) .unwrap_or(Window { left_hz: 0.0, @@ -279,17 +280,17 @@ fn contents( .then(|| ((frac * cols as f64) as usize).min(cols - 1)) }); - // Two rows of history per character cell, so every scroll figure exists in - // both units: `skip_chars` on screen, `skip_data` into the buffer. - let data_rows = plot.height as usize * 2; - let max_scroll = buf.rows.len().saturating_sub(data_rows) / 2; - let skip_data = wf.scroll_offset.min(max_scroll) * 2; - - let row_bins = buf.rows.front().map(|(_, r)| r.len()).unwrap_or(1); - let columns = Columns::new(row_bins, wf.hz_zoom, cols); - cells::draw( - f, plot, &buf.rows, &columns, cursor_col, skip_data, wf.db_min, wf.palette, theme, + f, + plot, + &buf.rows, + columns_for_row, + cursor_col, + skip_data, + wf.db_min, + wf.db_max, + wf.palette, + theme, ); // Bonded, the spectrum above already carries the band plan; twice is noise. @@ -305,6 +306,7 @@ fn contents( ..content }, wf.db_min, + wf.db_max, wf.palette, theme, ); @@ -314,7 +316,7 @@ fn contents( area, state, &buf.rows, - &columns, + columns.as_ref(), skip_data, cursor_col, status.stride, @@ -323,9 +325,106 @@ fn contents( } } +fn columns_for_row( + wf: &crate::state::WaterfallState, + row_bins: usize, + cols: usize, +) -> Option { + let (axis, center_hz, span_hz) = wf + .last_fft + .as_ref() + .map_or((crate::state::BinAxis::FftBins, 0, 1.0), |frame| { + (frame.bin_axis, frame.center_freq_hz, frame.sample_rate) + }); + let window = axis.window(center_hz, span_hz, row_bins, wf.hz_zoom as usize)?; + Some(Columns::new(window, cols, axis)) +} + #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + + #[test] + fn paused_history_keeps_its_own_bin_window_after_fft_size_changes() { + let mut state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + state.waterfall.hz_zoom = 4; + state.waterfall.buffer.paused = true; + let old_row = Arc::clone(&state.waterfall.buffer.rows.front().unwrap().1); + let frame = state.waterfall.last_fft.as_mut().unwrap(); + frame.bins_dbfs = Arc::new(vec![-90.0; 1024]); + assert!(!state.waterfall.buffer.push(&frame.bins_dbfs)); + + let columns = columns_for_row(&state.waterfall, old_row.len(), 64).unwrap(); + assert_eq!(columns.window.first_bin, 96); + assert_eq!(columns.window.bin_count, 64); + assert_eq!(columns.range(0), (96, 97)); + assert_eq!(columns.range(63), (159, 160)); + assert!(columns_for_row(&state.waterfall, 0, 64).is_none()); + } + + #[test] + fn mixed_size_history_rows_draw_their_own_centre_slices() { + use ratatui::{backend::TestBackend, Terminal}; + let state = crate::state::SdrMetrics::fixture().with_carrier(0.0, 70.0); + let mut wf = state.waterfall; + wf.hz_zoom = 4; + let mut top = vec![-120.0; 256]; + let mut bottom = vec![-120.0; 1024]; + top[96] = -20.0; + bottom[384] = -20.0; + wf.buffer.rows.clear(); + wf.buffer + .rows + .push_back((std::time::Instant::now(), Arc::new(top))); + wf.buffer + .rows + .push_back((std::time::Instant::now(), Arc::new(bottom))); + let mut terminal = Terminal::new(TestBackend::new(64, 1)).unwrap(); + let theme = crate::theme::Theme::sdr(); + terminal + .draw(|f| { + cells::draw( + f, + Rect::new(0, 0, 64, 1), + &wf.buffer.rows, + |n| columns_for_row(&wf, n, 64), + None, + 0, + wf.db_min, + wf.db_max, + wf.palette, + &theme, + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer.get(0, 0).fg, buffer.get(0, 0).bg); + assert_ne!(buffer.get(0, 0).fg, buffer.get(1, 0).fg); + assert_ne!(buffer.get(0, 0).bg, buffer.get(1, 0).bg); + } + + #[test] + fn the_legend_uses_both_level_bounds() { + let mut state = SdrMetrics::fixture().streaming().with_carrier(0.0, 40.0); + for (min, max, labels) in [ + (-120.0, 0.0, ["+0", "-60", "-120"]), + (-110.0, -10.0, ["-10", "-60", "-110"]), + ] { + state.waterfall.db_min = min; + state.waterfall.db_max = max; + let rows = crate::state::fixture::draw(WaterfallPanel, 100, 20, &state); + let gutter: String = rows + .iter() + .skip(1) + .take(18) + .flat_map(|row| row.chars().skip(1).take(DB_COL as usize)) + .collect(); + for label in labels { + assert!(gutter.contains(label), "{label} missing from {gutter}"); + } + } + } #[test] fn the_ladders_walk_and_stop_at_their_ends() { diff --git a/src/ui/panels/lab/bars.rs b/src/ui/panels/lab/bars.rs index 62b382d..86c4c46 100644 --- a/src/ui/panels/lab/bars.rs +++ b/src/ui/panels/lab/bars.rs @@ -82,17 +82,14 @@ fn fmt_delta(df_hz: u64, dl_db: Option) -> String { /// is no frame yet or the frequency is outside the captured span. fn level_at_freq(state: &SdrMetrics, freq_hz: u64) -> Option { let fr = state.waterfall.last_fft.as_ref()?; - let n = fr.bins_dbfs.len(); - if n == 0 { - return None; - } - let left = fr.center_freq_hz as f64 - fr.sample_rate / 2.0; - let frac = (freq_hz as f64 - left) / fr.sample_rate; - if !(0.0..=1.0).contains(&frac) { - return None; - } - let idx = (frac * (n - 1) as f64).round() as usize; - fr.bins_dbfs.get(idx.min(n - 1)).copied() + let window = fr.window(1)?; + let idx = fr.bin_axis.nearest_bin( + window.left_hz, + window.span_hz, + window.bin_count, + freq_hz as f64, + )?; + fr.bins_dbfs.get(idx).copied() } /// Display width (columns) of a span run - every glyph we use here is single-width. @@ -1050,6 +1047,19 @@ mod tests { use super::*; use crate::hardware::native::{hackrf, rtlsdr}; + #[test] + fn marker_level_uses_the_captured_fft_interval() { + let mut state = SdrMetrics::fixture(); + state.radio.frequency = 100_000_000; + state.radio.config_sample_rate = 32_000_000.0; + let mut state = state.with_carrier(8_000_000.0, 70.0); + state.radio.frequency = 200_000_000; + assert_eq!(level_at_freq(&state, 108_000_000), Some(-30.0)); + assert_eq!(level_at_freq(&state, 108_100_000), Some(-30.0)); + assert_eq!(level_at_freq(&state, 108_125_000), Some(-100.0)); + assert_eq!(level_at_freq(&state, 116_000_000), Some(-100.0)); + assert_eq!(level_at_freq(&state, 116_000_001), None); + } /// A signal state with real numbers in it, as if the FFT had just run. fn measured() -> crate::state::SignalState { crate::state::SignalState { diff --git a/src/ui/panels/lab/signal_characterization/metrics.rs b/src/ui/panels/lab/signal_characterization/metrics.rs index 0bac308..1d8c2d2 100644 --- a/src/ui/panels/lab/signal_characterization/metrics.rs +++ b/src/ui/panels/lab/signal_characterization/metrics.rs @@ -139,13 +139,7 @@ fn peak_bin(fr: &FftFrame) -> Option<(f32, u64)> { let n = bins.len(); let radius = crate::signal::fft::centre_radius_bins(n, fr.sample_rate); let (idx, best) = crate::signal::fft::strongest_real_bin(bins, Some(radius))?; - let left = fr.center_freq_hz as f64 - fr.sample_rate / 2.0; - let span_frac = if n > 1 { - idx as f64 / (n - 1) as f64 - } else { - 0.0 - }; - let freq = (left + span_frac * fr.sample_rate).max(0.0).round() as u64; + let freq = fr.frequency_of_bin(idx)?.max(0.0).round() as u64; Some((best, freq)) } @@ -167,6 +161,7 @@ mod tests { channel_power_dbfs: -22.0, occupied_bw_hz: 180_000, enbw_hz: 1_000.0, + bin_axis: crate::state::BinAxis::FftBins, } } @@ -205,7 +200,7 @@ mod tests { fn peak_bin_maps_index_to_frequency() { let (lvl, hz) = peak_bin(&peaked_at(75)).unwrap(); assert!((lvl + 10.0).abs() < 1e-6, "peak level is the max bin"); - assert_eq!(hz, 100_100_000, "three quarters across the span"); + assert_eq!(hz, 100_097_030); } #[test] @@ -221,7 +216,7 @@ mod tests { (lvl + 30.0).abs() < 1e-6, "reported a station out of channel: {lvl}" ); - assert_eq!(hz, 100_100_000); + assert_eq!(hz, 100_097_030); } #[test] @@ -234,7 +229,7 @@ mod tests { bins[60] = -30.0; // a real, weaker carrier let (lvl, hz) = peak_bin(&frame(bins, 100_000_000, 400_000.0)).unwrap(); assert!((lvl + 30.0).abs() < 1e-6, "reported the artefact: {lvl}"); - assert_eq!(hz, 100_040_000); + assert_eq!(hz, 100_037_624); } #[test] diff --git a/user_docs/hardware.md b/user_docs/hardware.md index 63afc8f..fa40ec3 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -10,12 +10,11 @@ |--------|--------| | 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 | | **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) | -The first two are built and tested on real hardware. Support for them was only -added after physical testing, never guessed from documentation. Datasheets have -been known to fib; an oscilloscope rarely does. +HackRF One, RTL-SDR and the tinySA Ultra ZS405 are tested on real hardware. The third row is a deliberate exception, and it gets its own section rather than a footnote, because you deserve to know which kind of "supported" you are @@ -38,6 +37,29 @@ The UI adapts rather than showing you fields that can't mean anything: Everything else, including every lab bench, the demodulator and the sweep scanner, works the same on both. +## tinySA + +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. + +```sh +sdrtop --device tinysa +sdrtop --device tinysa=/dev/ttyACM2 +sdrtop --device 'tinysa=/dev/ttyACM2?input=high' +``` + +Automatic discovery recognizes the official USB CDC identity on +`/dev/ttyACM*`. The explicit form selects a path when several devices are +present or discovery cannot inspect sysfs. A basic tinySA uses the LOW input by +default from 100 kHz to 350 MHz. Select `input=high` for the HIGH connector from +240 MHz to 959 MHz. The supported firmware defines 959 MHz as the HIGH input +limit. One session stays on the selected connector. + > **RTL clones vary.** Different tuners, different gain tables, different quirks, > and no single person owns them all. If yours behaves oddly, please > [open an issue](../../../issues) with the tuner type (`rtl_test -t` prints it)