From a723fa69640b8ccf419b3c086af1e14c45490a9d Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 13:16:58 +0200 Subject: [PATCH 1/6] feat: add tinySA device controls Expose model-aware analyzer settings in the Options pane and preserve them in the user configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/builder/mod.rs | 7 + src/app/mod.rs | 129 ++++- src/config.rs | 82 +++ src/hardware/discovery.rs | 6 +- src/hardware/tinysa/mod.rs | 957 ++++++++++++++++++++++++++++---- src/hardware/tinysa/protocol.rs | 20 + user_docs/config.md | 20 + user_docs/hardware.md | 11 +- 8 files changed, 1129 insertions(+), 103 deletions(-) diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index be5a0820..23db7909 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -38,6 +38,7 @@ impl App { cfg: AppConfig, config_path: Option, device: Arc, + device_kind: hardware::DeviceKind, ) -> anyhow::Result { let info = device.info(); let caps = Arc::new(device.capabilities().clone()); @@ -180,6 +181,7 @@ impl App { Some(Arc::clone(&device)), Some(Arc::clone(&rx_ctx)), None, + device_kind, )?; match caps.acquisition { @@ -222,6 +224,7 @@ impl App { config_path: Option, sysinfo: hardware::sysfs::HackRfSysInfo, profile: hardware::discovery::ObserverProfile, + device_kind: hardware::DeviceKind, ) -> anyhow::Result { let state = Arc::new(Mutex::new(initial_metrics( &cfg, @@ -245,6 +248,7 @@ impl App { None, None, Some("observer"), + device_kind, )?; tasks::spawn_observer_task(Arc::clone(&state), sysinfo.bus, sysinfo.dev, profile); tasks::spawn_sys_resource_task(Arc::clone(&state)); @@ -265,6 +269,7 @@ impl App { device: Option>, rx_ctx: Option>, preset_override: Option<&str>, + device_kind: hardware::DeviceKind, ) -> anyhow::Result { let themes_dir = config_path .as_deref() @@ -341,6 +346,8 @@ impl App { theme, focus_keys, theme_config: cfg.theme.clone(), + tinysa_config: cfg.tinysa.clone(), + device_kind, user_presets: cfg.presets, }) } diff --git a/src/app/mod.rs b/src/app/mod.rs index e4b9ebce..7c9b2532 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -47,6 +47,8 @@ pub struct App { /// and never touches again - so without a copy of them there is nothing left /// to write back. pub(super) theme_config: crate::config::ThemeConfig, + pub(super) tinysa_config: crate::config::TinySaSettings, + pub(super) device_kind: hardware::DeviceKind, } impl App { @@ -55,8 +57,8 @@ impl App { config_path: Option, listing: &hardware::DeviceListing, ) -> anyhow::Result { - match hardware::open_device(listing) { - Ok(device) => Self::new_normal(cfg, config_path, device), + match hardware::open_device(listing, &cfg.tinysa) { + Ok(device) => Self::new_normal(cfg, config_path, device, listing.kind), Err(open_err) => { // Device is present but couldn't be opened (e.g. busy) - fall back // to read-only observer mode via the matching backend's sysfs @@ -68,7 +70,7 @@ impl App { let Some(sysinfo) = (profile.scan)() else { return Err(open_err); }; - Self::new_observer(cfg, config_path, sysinfo, profile) + Self::new_observer(cfg, config_path, sysinfo, profile, listing.kind) } } } @@ -375,6 +377,11 @@ impl App { let Some(path) = &self.config_path else { return; }; + let tinysa_options = if self.device_kind == hardware::DeviceKind::TinySa { + self.device.as_ref().map(|device| device.options()) + } else { + None + }; let (freq, rate, gains, amp, wf_rows, wf_palette, spec_style, markers, sweep_cfg, recall) = { let m = self.state.lock().unwrap_or_else(|e| e.into_inner()); ( @@ -390,6 +397,11 @@ impl App { crate::state::recall_to_hz(&m.ui.recall), ) }; + let tinysa = persisted_tinysa_settings( + self.device_kind, + &self.tinysa_config, + tinysa_options.as_deref(), + ); let cfg = AppConfig { radio: RadioConfig { frequency_hz: freq, @@ -422,12 +434,47 @@ impl App { stop_hz: sweep_cfg.stop_hz, dwell_ms: sweep_cfg.dwell_ms, }, + tinysa, presets: self.user_presets.clone(), }; let _ = cfg.save(path); } } +fn persisted_tinysa_settings( + device_kind: hardware::DeviceKind, + loaded: &crate::config::TinySaSettings, + options: Option<&[crate::hardware::DeviceOption]>, +) -> crate::config::TinySaSettings { + if device_kind != hardware::DeviceKind::TinySa { + return loaded.clone(); + } + let mut settings = loaded.clone(); + for option in options.unwrap_or_default() { + let value = option.selected_choice.as_str(); + match option.id.as_str() { + "points" => { + if let Ok(points) = value.parse() { + settings.points = points; + } + } + "rbw" => settings.rbw = value.to_string(), + "attenuation" => settings.attenuation = value.to_string(), + "lna" => settings.lna = value == "on", + "lna2" => settings.lna2 = value.to_string(), + "agc" => settings.agc = value.to_string(), + "spur" => settings.spur = value.to_string(), + "ext_gain" => { + if let Ok(db) = value.parse() { + settings.ext_gain_db = db; + } + } + _ => {} + } + } + settings +} + #[cfg(test)] mod tests { use super::*; @@ -435,6 +482,82 @@ mod tests { use std::cell::Cell; use std::sync::{mpsc, Arc, Mutex}; + fn device_option(id: &str, choice: &str) -> crate::hardware::DeviceOption { + crate::hardware::DeviceOption { + id: id.into(), + label: id.into(), + choices: vec![choice.into()], + selected_choice: choice.into(), + } + } + + #[test] + fn non_tinysa_backends_preserve_loaded_tinysa_settings() { + let loaded = crate::config::TinySaSettings { + points: 1800, + rbw: "custom".into(), + attenuation: "31".into(), + lna: true, + lna2: "7".into(), + agc: "6".into(), + spur: "off".into(), + ext_gain_db: 99, + }; + let options = [device_option("points", "64")]; + assert_eq!( + persisted_tinysa_settings(hardware::DeviceKind::HackRf, &loaded, Some(&options),), + loaded + ); + } + + #[test] + fn tinysa_persistence_uses_authoritative_dependent_settings() { + let loaded = crate::config::TinySaSettings::default(); + let options = [ + device_option("points", "900"), + device_option("rbw", "0.2"), + device_option("attenuation", "0"), + device_option("lna", "on"), + device_option("lna2", "3"), + device_option("agc", "7"), + device_option("spur", "off"), + device_option("ext_gain", "-12"), + ]; + let saved = + persisted_tinysa_settings(hardware::DeviceKind::TinySa, &loaded, Some(&options)); + assert_eq!(saved.points, 900); + assert_eq!(saved.rbw, "0.2"); + assert_eq!(saved.attenuation, "0"); + assert!(saved.lna); + assert_eq!(saved.lna2, "3"); + assert_eq!(saved.agc, "7"); + assert_eq!(saved.spur, "off"); + assert_eq!(saved.ext_gain_db, -12); + } + + #[test] + fn basic_tinysa_persistence_preserves_ultra_fields() { + let loaded = crate::config::TinySaSettings { + lna: true, + lna2: "5".into(), + agc: "4".into(), + ..crate::config::TinySaSettings::default() + }; + let options = [ + device_option("points", "64"), + device_option("rbw", "3"), + device_option("attenuation", "12"), + device_option("spur", "on"), + device_option("ext_gain", "7"), + ]; + let saved = + persisted_tinysa_settings(hardware::DeviceKind::TinySa, &loaded, Some(&options)); + assert_eq!(saved.points, 64); + assert!(saved.lna); + assert_eq!(saved.lna2, "5"); + assert_eq!(saved.agc, "4"); + } + /// Quitting must give the tuner back before the config is written. /// /// `save_config` persists `radio.frequency`, and while a sweep is running diff --git a/src/config.rs b/src/config.rs index c8002b38..352b7aef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -161,6 +161,50 @@ impl Default for SweepSettings { } } +fn default_tinysa_points() -> u32 { + 450 +} + +fn default_auto() -> String { + "auto".into() +} + +/// Settings applied when a tinySA backend opens. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +pub struct TinySaSettings { + #[serde(default = "default_tinysa_points")] + pub points: u32, + #[serde(default = "default_auto")] + pub rbw: String, + #[serde(default = "default_auto")] + pub attenuation: String, + #[serde(default)] + pub lna: bool, + #[serde(default = "default_auto")] + pub lna2: String, + #[serde(default = "default_auto")] + pub agc: String, + #[serde(default = "default_auto")] + pub spur: String, + #[serde(default)] + pub ext_gain_db: i32, +} + +impl Default for TinySaSettings { + fn default() -> Self { + Self { + points: default_tinysa_points(), + rbw: default_auto(), + attenuation: default_auto(), + lna: false, + lna2: default_auto(), + agc: default_auto(), + spur: default_auto(), + ext_gain_db: 0, + } + } +} + #[derive(Deserialize, Serialize, Clone, Debug, Default)] pub struct AppConfig { #[serde(default)] @@ -171,6 +215,8 @@ pub struct AppConfig { pub theme: ThemeConfig, #[serde(default)] pub sweep: SweepSettings, + #[serde(default)] + pub tinysa: TinySaSettings, /// User-defined layout presets, merged into the built-in set at startup. /// A preset here with the same name as a built-in overrides it. Preserved /// verbatim across save so hand-written presets survive a quit. @@ -718,6 +764,42 @@ panels = [ assert_eq!(restored.display.active_preset, "spectrum"); } + #[test] + fn tinysa_settings_default_and_round_trip() { + let defaults: AppConfig = toml::from_str("").unwrap(); + assert_eq!(defaults.tinysa, TinySaSettings::default()); + + let source = r#" + [tinysa] + points = 900 + rbw = "0.2" + attenuation = "12" + lna = true + lna2 = "3" + agc = "7" + spur = "off" + ext_gain_db = -8 + "#; + let config: AppConfig = toml::from_str(source).unwrap(); + let serialized = toml::to_string_pretty(&config).unwrap(); + let restored: AppConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(restored.tinysa, config.tinysa); + assert!(serialized.contains("[tinysa]")); + } + + #[test] + fn partial_tinysa_settings_fill_field_defaults() { + let config: AppConfig = toml::from_str("[tinysa]\npoints = 64\n").unwrap(); + assert_eq!(config.tinysa.points, 64); + assert_eq!(config.tinysa.rbw, "auto"); + assert_eq!(config.tinysa.attenuation, "auto"); + assert!(!config.tinysa.lna); + assert_eq!(config.tinysa.lna2, "auto"); + assert_eq!(config.tinysa.agc, "auto"); + assert_eq!(config.tinysa.spur, "auto"); + assert_eq!(config.tinysa.ext_gain_db, 0); + } + #[test] fn spectrum_style_round_trips_and_defaults_braille() { let cfg: AppConfig = toml::from_str("[display]\nactive_preset = \"spectrum\"\n").unwrap(); diff --git a/src/hardware/discovery.rs b/src/hardware/discovery.rs index e70054a9..7b2ba975 100644 --- a/src/hardware/discovery.rs +++ b/src/hardware/discovery.rs @@ -246,7 +246,10 @@ fn offer_soapy( } /// Opens the device a listing points at, as a trait object. -pub fn open_device(listing: &DeviceListing) -> anyhow::Result> { +pub fn open_device( + listing: &DeviceListing, + tinysa_settings: &crate::config::TinySaSettings, +) -> anyhow::Result> { match listing.kind { DeviceKind::HackRf => Ok(Arc::new(hackrf::HackRfDevice::open(listing.index)?)), DeviceKind::RtlSdr => Ok(Arc::new(rtlsdr::RtlDevice::open(listing.index)?)), @@ -263,6 +266,7 @@ pub fn open_device(listing: &DeviceListing) -> anyhow::Result Ok(Arc::new(tinysa::TinySaDevice::open( path, listing.tiny_sa_input.unwrap_or_default(), + tinysa_settings, )?)) } } diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index ecb0c840..cc800f58 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -14,8 +14,9 @@ use anyhow::{anyhow, bail, Context}; use crossbeam_channel::{bounded, Receiver, Sender, TryRecvError}; use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; +use crate::config::TinySaSettings; use crate::hardware::{ - AcquisitionKind, DeliveryModel, DeviceCapabilities, DeviceInfo, DeviceListing, + AcquisitionKind, DeliveryModel, DeviceCapabilities, DeviceInfo, DeviceListing, DeviceOption, DirectSweepConfig, GainModel, LevelUnit, PowerTrace, PowerTraceTarget, RxContext, SampleFormat, SampleGeometry, SdrDevice, SoftwareStack, }; @@ -26,6 +27,7 @@ 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; +#[cfg(test)] const DEFAULT_POINTS: u32 = 450; const READ_TIMEOUT: Duration = Duration::from_millis(50); const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); @@ -75,19 +77,6 @@ impl BasicInput { } } -#[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) { @@ -140,12 +129,18 @@ pub struct TinySaDevice { caps: DeviceCapabilities, info: DeviceInfo, notes: Vec, + options: Arc>>, command_tx: Sender, worker: Mutex>>, } impl TinySaDevice { - pub fn open(path: &Path, basic_input: BasicInput) -> anyhow::Result { + pub fn open( + path: &Path, + basic_input: BasicInput, + settings: &TinySaSettings, + ) -> anyhow::Result { + validate_settings_shape(settings)?; let port = serialport::new(path.to_string_lossy(), 115_200) .data_bits(DataBits::Eight) .stop_bits(StopBits::One) @@ -156,9 +151,21 @@ impl TinySaDevice { .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 options = Arc::new(Mutex::new(Vec::new())); + let worker_options = Arc::clone(&options); + let settings = settings.clone(); let worker = thread::Builder::new() .name("tinysa-serial".to_string()) - .spawn(move || worker_entry(port, command_rx, init_tx, basic_input)) + .spawn(move || { + worker_entry( + port, + command_rx, + init_tx, + basic_input, + settings, + worker_options, + ) + }) .context("failed to start tinySA serial worker")?; let initialized = match init_rx.recv() { Ok(Ok(initialized)) => initialized, @@ -191,6 +198,7 @@ impl TinySaDevice { caps, info, notes, + options, command_tx, worker: Mutex::new(Some(worker)), }) @@ -247,6 +255,28 @@ impl SdrDevice for TinySaDevice { self.request(|reply| Command::SetDirectSweep(config, reply)) } + fn options(&self) -> Vec { + self.options + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + fn set_option(&self, id: &str, choice: &str) -> anyhow::Result<()> { + { + let options = self + .options + .lock() + .unwrap_or_else(|error| error.into_inner()); + validate_option_choice(&options, id, choice)?; + } + self.request(|reply| Command::SetOption { + id: id.to_string(), + choice: choice.to_string(), + reply, + }) + } + fn open_notes(&self) -> &[String] { &self.notes } @@ -276,6 +306,11 @@ enum Command { SetSpan(f64, Sender>), NoOp(UnitReply), SetDirectSweep(Option, UnitReply), + SetOption { + id: String, + choice: String, + reply: UnitReply, + }, Shutdown(UnitReply), } @@ -287,12 +322,14 @@ struct Worker { port: Box, command_rx: Receiver, identity: Identity, - settings: ScanSettings, + options: Vec, + option_state: Arc>>, basic_input: BasicInput, center_hz: u64, span_hz: u64, direct_sweep: Option, rx_context: Option>, + prompt_ready: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -328,14 +365,19 @@ fn worker_entry( command_rx: Receiver, init_tx: Sender>, basic_input: BasicInput, + settings: TinySaSettings, + option_state: Arc>>, ) { - let (identity, settings) = match initialize(&mut *port, basic_input) { + let (identity, options) = match initialize(&mut *port, basic_input, &settings) { Ok(value) => value, Err(error) => { let _ = init_tx.send(Err(error)); return; } }; + *option_state + .lock() + .unwrap_or_else(|error| error.into_inner()) = options.clone(); if init_tx .send(Ok(Initialized { identity: identity.clone(), @@ -349,12 +391,14 @@ fn worker_entry( port, command_rx, identity, - settings, + options, + option_state, + basic_input, center_hz, span_hz: DEFAULT_SPAN_HZ, - basic_input, direct_sweep: None, rx_context: None, + prompt_ready: true, } .run(); } @@ -404,10 +448,7 @@ impl Worker { .unwrap_or(0), frequencies_hz, levels_dbm, - rbw_hz: self - .settings - .rbw_khz - .map(|khz| (khz * 1_000.0).round() as u32), + rbw_hz: current_rbw_hz(&self.options), }) .is_ok(); if published && target == PowerTraceTarget::Spectrum { @@ -426,6 +467,7 @@ impl Worker { Ok(ScanResult::Interrupted(command, abort_result)) => { if let Err(error) = abort_result { let message = error.to_string(); + self.prompt_ready = false; self.stop_acquisition(&message); reject_command(command, anyhow!(message)); return; @@ -436,6 +478,7 @@ impl Worker { } Err(error) => { best_effort_abort(&mut *self.port); + self.prompt_ready = false; self.stop_acquisition(&error.to_string()); return; } @@ -455,7 +498,11 @@ impl Worker { fn handle_command(&mut self, command: Command) -> bool { match command { Command::Start(context, reply) => { - let result = if self.rx_context.is_some() { + let result = if !self.prompt_ready { + Err(anyhow!( + "tinySA serial state is unknown; reconnect the analyzer" + )) + } else if self.rx_context.is_some() { Err(anyhow!("tinySA acquisition is already running")) } else { self.rx_context = Some(context); @@ -478,16 +525,15 @@ impl Worker { 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 result = selected_points(&self.options).and_then(|points| { + normalize_span(hz, maximum, points).map(|span_hz| { + self.span_hz = span_hz; + RateSet::new( + hz, + Some(self.span_hz as f64), + current_rbw_hz(&self.options).unwrap_or(0), + ) + }) }); let _ = reply.send(result); } @@ -495,8 +541,16 @@ impl Worker { let _ = reply.send(Ok(())); } Command::SetDirectSweep(config, reply) => { - let result = validate_direct_sweep(config, self.identity.model, self.basic_input) - .map(|()| self.direct_sweep = config); + let result = selected_points(&self.options).and_then(|points| { + validate_direct_sweep(config, self.identity.model, self.basic_input, points) + .map(|()| { + self.direct_sweep = config; + }) + }); + let _ = reply.send(result); + } + Command::SetOption { id, choice, reply } => { + let result = self.apply_option(&id, &choice); let _ = reply.send(result); } Command::Shutdown(reply) => { @@ -519,6 +573,45 @@ impl Worker { } } + fn apply_option(&mut self, id: &str, choice: &str) -> anyhow::Result<()> { + if !self.prompt_ready { + bail!("tinySA serial state is unknown; reconnect the analyzer"); + } + let prepared = prepare_option_update( + &self.options, + self.identity.model, + id, + choice, + self.span_hz, + self.direct_sweep, + )?; + if let Err(error) = + execute_option_update(&mut self.options, prepared, id, choice, |command| { + send_setter_command(&mut *self.port, command) + }) + { + self.stop_acquisition(&error.to_string()); + let recovery = recover_option_state( + &mut *self.port, + self.identity.model, + self.basic_input, + &self.options, + ); + self.prompt_ready = recovery.is_ok(); + return match recovery { + Ok(()) => Err(error), + Err(recovery_error) => Err(error.context(format!( + "failed to restore tinySA controls after the error: {recovery_error}" + ))), + }; + } + *self + .option_state + .lock() + .unwrap_or_else(|error| error.into_inner()) = self.options.clone(); + Ok(()) + } + 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) = self @@ -527,7 +620,7 @@ impl Worker { .unwrap_or_else(|| { centered_window(self.center_hz, self.span_hz, minimum_hz, maximum_hz) }); - let points = self.settings.points; + let points = selected_points(&self.options)?; let scan_span_hz = stop_hz - start_hz; if scan_span_hz < points as u64 { bail!("tinySA scan span is too narrow for {points} points"); @@ -540,7 +633,7 @@ impl Worker { points, }; let inactivity_timeout = - scan_inactivity_timeout(segment, self.identity.model, self.settings)?; + scan_inactivity_timeout(segment, self.identity.model, &self.options)?; match scan_segment( &mut *self.port, &self.command_rx, @@ -577,7 +670,8 @@ impl Worker { fn initialize( port: &mut dyn SerialPort, basic_input: BasicInput, -) -> anyhow::Result<(Identity, ScanSettings)> { + settings: &TinySaSettings, +) -> anyhow::Result<(Identity, Vec)> { best_effort_abort(port); drain_startup(port)?; let mut last_error = None; @@ -599,7 +693,7 @@ fn initialize( } let version = version .ok_or_else(|| last_error.unwrap_or_else(|| anyhow!("tinySA version probe failed")))?; - send_text_command(port, "output off")?; + send_setter_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")?; @@ -607,18 +701,16 @@ fn initialize( 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)?; + let (options, saved_commands) = startup_options(identity.model, settings)?; + send_setter_command(port, input_mode_command(identity.model, basic_input))?; + send_setter_command(port, "abort on")?; + for command in baseline_commands(identity.model) { + send_setter_command(port, command)?; } - Ok(()) + for command in saved_commands { + send_setter_command(port, &command)?; + } + Ok((identity, options)) } fn best_effort_abort(port: &mut dyn SerialPort) { @@ -645,6 +737,11 @@ fn send_text_command(port: &mut dyn SerialPort, command: &str) -> anyhow::Result protocol::parse_text_frame(&frame, command) } +fn send_setter_command(port: &mut dyn SerialPort, command: &str) -> anyhow::Result<()> { + let body = send_text_command(port, command)?; + protocol::validate_setter_body(&body, command) +} + fn read_until_prompt( port: &mut dyn SerialPort, inactivity_timeout: Duration, @@ -951,6 +1048,7 @@ fn reject_command(command: Command, error: anyhow::Error) { | Command::SetFrequency(_, reply) | Command::NoOp(reply) | Command::SetDirectSweep(_, reply) + | Command::SetOption { reply, .. } | Command::Shutdown(reply) => { let _ = reply.send(Err(anyhow!(message))); } @@ -967,6 +1065,7 @@ fn validate_direct_sweep( config: Option, model: Model, basic_input: BasicInput, + points: u32, ) -> anyhow::Result<()> { let Some(config) = config else { return Ok(()); @@ -982,6 +1081,9 @@ fn validate_direct_sweep( maximum_hz ); } + if config.stop_hz - config.start_hz < points as u64 { + bail!("tinySA sweep span is too narrow for {points} points"); + } Ok(()) } @@ -996,7 +1098,7 @@ fn capabilities(model: Model, basic_input: BasicInput) -> DeviceCapabilities { 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_min_hz: allowed_points()[0] 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, @@ -1108,51 +1210,379 @@ fn trace_frequencies(measured_hz: Vec, target: PowerTraceTarget) -> anyhow: } } -fn startup_settings(model: Model) -> (ScanSettings, Vec<&'static str>) { - let spur = if model.is_ultra() { - SpurMode::Auto +fn validate_settings_shape(settings: &TinySaSettings) -> anyhow::Result<()> { + if !allowed_points().contains(&settings.points) { + bail!("tinySA points must be one of 64, 128, 290, 450, 900, or 1800"); + } + if !(-100..=100).contains(&settings.ext_gain_db) { + bail!("tinySA external gain must be within -100..100 dB"); + } + Ok(()) +} + +fn startup_options( + model: Model, + settings: &TinySaSettings, +) -> anyhow::Result<(Vec, Vec)> { + validate_settings_shape(settings)?; + let mut options = option_definitions(model); + let rbw = if model == Model::Basic && matches!(settings.rbw.as_str(), "0.2" | "1" | "850") { + "auto" } else { - SpurMode::On + &settings.rbw }; - let mut commands = Vec::new(); + let attenuation = if model.is_ultra() && settings.lna { + "0" + } else { + &settings.attenuation + }; + let spur = if model == Model::Basic && settings.spur == "auto" { + "on" + } else { + &settings.spur + }; + + let mut values = vec![ + ("points", settings.points.to_string()), + ("rbw", rbw.to_string()), + ("attenuation", attenuation.to_string()), + ]; if model.is_ultra() { - commands.extend(["ultra on", "ultra auto"]); + values.extend([ + ("lna", if settings.lna { "on" } else { "off" }.to_string()), + ("lna2", settings.lna2.clone()), + ("agc", settings.agc.clone()), + ]); + } + values.extend([ + ("spur", spur.to_string()), + ("ext_gain", settings.ext_gain_db.to_string()), + ]); + + let mut commands = Vec::new(); + for (id, choice) in values { + let option_index = validate_option_choice(&options, id, &choice)?; + if let Some(command) = option_command(model, &options, id, &choice)? { + commands.push(command); + } + options[option_index].selected_choice = choice; } - commands.extend(["rbw auto", "attenuate auto"]); + Ok((options, commands)) +} + +fn baseline_commands(model: Model) -> &'static [&'static str] { if model.is_ultra() { - commands.extend(["lna off", "lna2 auto", "agc auto", "spur auto"]); + &[ + "ultra on", + "ultra auto", + "rbw auto", + "lna off", + "attenuate auto", + "lna2 auto", + "agc auto", + "spur auto", + "ext_gain 0", + ] } else { - commands.push("spur on"); + &["rbw auto", "attenuate auto", "spur on", "ext_gain 0"] + } +} + +fn option_definitions(model: Model) -> Vec { + let mut options = vec![ + option( + "points", + "Points", + allowed_points().map(|value| value.to_string()).to_vec(), + ), + option( + "rbw", + "RBW (kHz)", + if model.is_ultra() { + strings(&[ + "auto", "0.2", "1", "3", "10", "30", "100", "300", "600", "850", + ]) + } else { + strings(&["auto", "3", "10", "30", "100", "300", "600"]) + }, + ), + option( + "attenuation", + "Attenuation (dB)", + std::iter::once("auto".to_string()) + .chain((0..=31).map(|value| value.to_string())) + .collect(), + ), + ]; + if model.is_ultra() { + options.extend([ + option("lna", "LNA", strings(&["off", "on"])), + option( + "lna2", + "LNA2", + std::iter::once("auto".to_string()) + .chain((0..=7).map(|value| value.to_string())) + .collect(), + ), + option( + "agc", + "AGC", + std::iter::once("auto".to_string()) + .chain((0..=7).map(|value| value.to_string())) + .collect(), + ), + ]); } - ( - ScanSettings { - points: DEFAULT_POINTS, - rbw_khz: None, - spur, + options.push(option( + "spur", + "Spur removal", + if model.is_ultra() { + strings(&["off", "on", "auto"]) + } else { + strings(&["off", "on"]) }, + )); + options.push(option( + "ext_gain", + "External gain (dB)", + (-100..=100).map(|value| value.to_string()).collect(), + )); + options +} + +fn option(id: &str, label: &str, choices: Vec) -> DeviceOption { + DeviceOption { + id: id.to_string(), + label: label.to_string(), + selected_choice: choices.first().cloned().unwrap_or_default(), + choices, + } +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn allowed_points() -> [u32; 6] { + [64, 128, 290, 450, 900, 1800] +} + +fn validate_option_choice( + options: &[DeviceOption], + id: &str, + choice: &str, +) -> anyhow::Result { + let option_index = options + .iter() + .position(|option| option.id == id) + .with_context(|| format!("unknown tinySA option {id}"))?; + if !options[option_index] + .choices + .iter() + .any(|candidate| candidate == choice) + { + bail!("invalid choice {choice:?} for tinySA option {id}"); + } + Ok(option_index) +} + +fn selected_option_value<'a>(options: &'a [DeviceOption], id: &str) -> Option<&'a str> { + options + .iter() + .find(|option| option.id == id) + .map(|option| option.selected_choice.as_str()) +} + +fn set_selected_option(options: &mut [DeviceOption], id: &str, choice: &str) -> anyhow::Result<()> { + let option_index = validate_option_choice(options, id, choice)?; + options[option_index].selected_choice = choice.to_string(); + Ok(()) +} + +fn option_command( + model: Model, + options: &[DeviceOption], + id: &str, + choice: &str, +) -> anyhow::Result> { + validate_option_choice(options, id, choice)?; + let command = match id { + "points" => return Ok(None), + "rbw" => format!("rbw {choice}"), + "attenuation" => format!("attenuate {choice}"), + "lna" if model.is_ultra() => format!("lna {choice}"), + "lna2" if model.is_ultra() => format!("lna2 {choice}"), + "agc" if model.is_ultra() => format!("agc {choice}"), + "spur" => format!("spur {choice}"), + "ext_gain" => format!("ext_gain {choice}"), + _ => bail!("unknown tinySA option {id}"), + }; + Ok(Some(command)) +} + +struct PreparedOption { + option_index: usize, + commands: Vec, +} + +fn prepare_option_update( + options: &[DeviceOption], + model: Model, + id: &str, + choice: &str, + span_hz: u64, + direct_sweep: Option, +) -> anyhow::Result { + let option_index = validate_option_choice(options, id, choice)?; + if id == "attenuation" && choice != "0" && selected_option_value(options, "lna") == Some("on") { + bail!("turn the tinySA LNA off before changing attenuation"); + } + if id == "points" { + let points: u32 = choice.parse().context("tinySA point setting is invalid")?; + if span_hz < points as u64 { + bail!("tinySA span is too narrow for {points} points"); + } + if direct_sweep.is_some_and(|config| config.stop_hz - config.start_hz < points as u64) { + bail!("tinySA sweep span is too narrow for {points} points"); + } + } + let mut commands = Vec::new(); + if id == "lna" && choice == "on" { + commands.push("attenuate 0".to_string()); + } + if let Some(command) = option_command(model, options, id, choice)? { + commands.push(command); + } + Ok(PreparedOption { + option_index, commands, - ) + }) +} + +fn commit_option_update( + options: &mut [DeviceOption], + option_index: usize, + id: &str, + choice: &str, +) -> anyhow::Result<()> { + options[option_index].selected_choice = choice.to_string(); + if id == "lna" && choice == "on" { + set_selected_option(options, "attenuation", "0")?; + } + Ok(()) +} + +fn execute_option_update( + options: &mut [DeviceOption], + prepared: PreparedOption, + id: &str, + choice: &str, + mut send: impl FnMut(&str) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + for command in &prepared.commands { + send(command)?; + } + commit_option_update(options, prepared.option_index, id, choice) +} + +fn selected_points(options: &[DeviceOption]) -> anyhow::Result { + selected_option_value(options, "points") + .context("tinySA point setting is missing")? + .parse() + .context("tinySA point setting is invalid") +} + +fn current_rbw_hz(options: &[DeviceOption]) -> Option { + let value = selected_option_value(options, "rbw")?; + if value == "auto" { + return None; + } + value + .parse::() + .ok() + .map(|khz| (khz * 1_000.0).round() as u32) +} + +fn basic_option_commands(options: &[DeviceOption]) -> anyhow::Result> { + let mut commands = Vec::new(); + for id in ["rbw", "attenuation", "spur", "ext_gain"] { + let choice = selected_option_value(options, id) + .with_context(|| format!("tinySA {id} is missing"))?; + if let Some(command) = option_command(Model::Basic, options, id, choice)? { + commands.push(command); + } + } + Ok(commands) +} + +fn restore_option_commands(model: Model, options: &[DeviceOption]) -> anyhow::Result> { + if !model.is_ultra() { + return basic_option_commands(options); + } + + let mut commands = vec!["lna off".to_string()]; + for id in ["rbw", "attenuation"] { + let choice = selected_option_value(options, id) + .with_context(|| format!("tinySA {id} is missing"))?; + if let Some(command) = option_command(model, options, id, choice)? { + commands.push(command); + } + } + if selected_option_value(options, "lna") == Some("on") { + commands.push("lna on".to_string()); + } + for id in ["lna2", "agc", "spur", "ext_gain"] { + let choice = selected_option_value(options, id) + .with_context(|| format!("tinySA {id} is missing"))?; + if let Some(command) = option_command(model, options, id, choice)? { + commands.push(command); + } + } + Ok(commands) +} + +fn recover_option_state( + port: &mut dyn SerialPort, + model: Model, + basic_input: BasicInput, + options: &[DeviceOption], +) -> anyhow::Result<()> { + best_effort_abort(port); + drain_startup(port)?; + send_setter_command(port, input_mode_command(model, basic_input))?; + send_setter_command(port, "abort on")?; + for command in baseline_commands(model) { + send_setter_command(port, command)?; + } + for command in restore_option_commands(model, options)? { + send_setter_command(port, &command)?; + } + Ok(()) } fn scan_inactivity_timeout( segment: Segment, model: Model, - settings: ScanSettings, + options: &[DeviceOption], ) -> anyhow::Result { + let rbw_setting = selected_option_value(options, "rbw").context("tinySA RBW is missing")?; 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 rbw_khz = if rbw_setting == "auto" { + (span_hz * 7e-6).clamp(minimum_rbw, maximum_rbw) + } else { + rbw_setting + .parse::() + .context("tinySA RBW setting is invalid")? + }; 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 - { + let spur = selected_option_value(options, "spur").context("tinySA spur setting is missing")?; + if (spur == "on" && segment.stop_hz > 800_000_000) || spur == "auto" { total_seconds *= 2.0; } let block_seconds = total_seconds * 20.0 / points + 1.0; @@ -1302,26 +1732,295 @@ mod tests { } #[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); + fn option_definitions_match_each_model() { + let basic = option_definitions(Model::Basic); assert_eq!( - commands, + basic + .iter() + .find(|option| option.id == "points") + .unwrap() + .choices, + ["64", "128", "290", "450", "900", "1800"] + ); + assert_eq!( + basic + .iter() + .find(|option| option.id == "rbw") + .unwrap() + .choices, + ["auto", "3", "10", "30", "100", "300", "600"] + ); + assert!(!basic.iter().any(|option| option.id == "lna")); + assert_eq!( + basic + .iter() + .find(|option| option.id == "spur") + .unwrap() + .choices, + ["off", "on"] + ); + + let ultra = option_definitions(Model::Zs407); + assert_eq!( + ultra + .iter() + .find(|option| option.id == "rbw") + .unwrap() + .choices, + ["auto", "0.2", "1", "3", "10", "30", "100", "300", "600", "850"] + ); + for id in ["lna", "lna2", "agc"] { + assert!(ultra.iter().any(|option| option.id == id)); + } + assert_eq!( + ultra + .iter() + .find(|option| option.id == "ext_gain") + .unwrap() + .choices + .len(), + 201 + ); + } + + #[test] + fn startup_sets_a_safe_baseline_before_saved_values() { + assert_eq!( + baseline_commands(Model::Zs407), [ "ultra on", "ultra auto", "rbw auto", - "attenuate auto", "lna off", + "attenuate auto", "lna2 auto", "agc auto", "spur auto", + "ext_gain 0", + ] + ); + assert_eq!( + baseline_commands(Model::Basic), + ["rbw auto", "attenuate auto", "spur on", "ext_gain 0"] + ); + + let settings = TinySaSettings { + points: 900, + rbw: "0.2".into(), + attenuation: "12".into(), + lna: true, + lna2: "3".into(), + agc: "7".into(), + spur: "off".into(), + ext_gain_db: -7, + }; + let (options, commands) = startup_options(Model::Zs407, &settings).unwrap(); + assert_eq!( + commands, + [ + "rbw 0.2", + "attenuate 0", + "lna on", + "lna2 3", + "agc 7", + "spur off", + "ext_gain -7", + ] + ); + assert_eq!(selected_points(&options).unwrap(), 900); + assert_eq!(selected_option_value(&options, "attenuation"), Some("0")); + assert_eq!(selected_option_value(&options, "lna"), Some("on")); + } + + #[test] + fn basic_saved_values_normalize_only_documented_choices() { + for rbw in ["0.2", "1", "850"] { + let settings = TinySaSettings { + rbw: rbw.into(), + spur: "auto".into(), + ..TinySaSettings::default() + }; + let (options, commands) = startup_options(Model::Basic, &settings).unwrap(); + assert_eq!(selected_option_value(&options, "rbw"), Some("auto")); + assert_eq!(selected_option_value(&options, "spur"), Some("on")); + assert!(commands.iter().any(|command| command == "rbw auto")); + assert!(commands.iter().any(|command| command == "spur on")); + } + + assert!(startup_options( + Model::Basic, + &TinySaSettings { + attenuation: "32".into(), + ..TinySaSettings::default() + }, + ) + .is_err()); + assert!(startup_options( + Model::Basic, + &TinySaSettings { + rbw: "2".into(), + ..TinySaSettings::default() + }, + ) + .is_err()); + assert!(startup_options( + Model::Basic, + &TinySaSettings { + spur: "maybe".into(), + ..TinySaSettings::default() + }, + ) + .is_err()); + assert!(validate_settings_shape(&TinySaSettings { + points: 451, + ..TinySaSettings::default() + }) + .is_err()); + assert!(validate_settings_shape(&TinySaSettings { + ext_gain_db: 101, + ..TinySaSettings::default() + }) + .is_err()); + } + + #[test] + fn option_commands_are_whitelisted_after_choice_validation() { + let basic = option_definitions(Model::Basic); + assert_eq!( + option_command(Model::Basic, &basic, "points", "450").unwrap(), + None + ); + assert_eq!( + option_command(Model::Basic, &basic, "rbw", "10").unwrap(), + Some("rbw 10".into()) + ); + assert!(option_command(Model::Basic, &basic, "rbw", "10; reset").is_err()); + assert!(option_command(Model::Basic, &basic, "lna", "on").is_err()); + assert!(option_command(Model::Basic, &basic, "missing", "on").is_err()); + } + + #[test] + fn lna_forces_zero_attenuation_and_blocks_other_values() { + let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + set_selected_option(&mut options, "attenuation", "12").unwrap(); + let prepared = + prepare_option_update(&options, Model::Zs407, "lna", "on", 10_000, None).unwrap(); + let mut commands = Vec::new(); + execute_option_update(&mut options, prepared, "lna", "on", |command| { + commands.push(command.to_string()); + Ok(()) + }) + .unwrap(); + assert_eq!(commands, ["attenuate 0", "lna on"]); + assert_eq!(selected_option_value(&options, "attenuation"), Some("0")); + assert!( + prepare_option_update(&options, Model::Zs407, "attenuation", "auto", 10_000, None,) + .is_err() + ); + assert!( + prepare_option_update(&options, Model::Zs407, "attenuation", "1", 10_000, None,) + .is_err() + ); + assert!( + prepare_option_update(&options, Model::Zs407, "attenuation", "0", 10_000, None,) + .is_ok() + ); + } + + #[test] + fn failed_setter_commands_do_not_change_option_state() { + let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + let before = options.clone(); + let prepared = + prepare_option_update(&options, Model::Zs407, "rbw", "30", 10_000, None).unwrap(); + let result = execute_option_update(&mut options, prepared, "rbw", "30", |_| { + bail!("injected serial failure") + }); + assert!(result.is_err()); + assert_eq!(options, before); + } + + #[test] + fn failed_lna_activation_does_not_publish_dependent_state() { + let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + set_selected_option(&mut options, "attenuation", "12").unwrap(); + let before = options.clone(); + let prepared = + prepare_option_update(&options, Model::Zs407, "lna", "on", 10_000, None).unwrap(); + let mut commands = Vec::new(); + let result = execute_option_update(&mut options, prepared, "lna", "on", |command| { + commands.push(command.to_string()); + if command == "lna on" { + bail!("injected serial failure"); + } + Ok(()) + }); + assert!(result.is_err()); + assert_eq!(commands, ["attenuate 0", "lna on"]); + assert_eq!(options, before); + } + + #[test] + fn restoring_ultra_controls_reestablishes_cached_dependencies() { + let settings = TinySaSettings { + rbw: "30".into(), + attenuation: "12".into(), + lna2: "3".into(), + agc: "7".into(), + spur: "off".into(), + ext_gain_db: -7, + ..TinySaSettings::default() + }; + let (options, _) = startup_options(Model::Zs407, &settings).unwrap(); + assert_eq!( + restore_option_commands(Model::Zs407, &options).unwrap(), + [ + "lna off", + "rbw 30", + "attenuate 12", + "lna2 3", + "agc 7", + "spur off", + "ext_gain -7", + ] + ); + + let lna_settings = TinySaSettings { + lna: true, + ..settings + }; + let (options, _) = startup_options(Model::Zs407, &lna_settings).unwrap(); + assert_eq!( + restore_option_commands(Model::Zs407, &options).unwrap(), + [ + "lna off", + "rbw 30", + "attenuate 0", + "lna on", + "lna2 3", + "agc 7", + "spur off", + "ext_gain -7", ] ); - let (basic, commands) = startup_settings(Model::Basic); - assert_eq!(basic.spur, SpurMode::On); - assert_eq!(commands, ["rbw auto", "attenuate auto", "spur on"]); + } + + #[test] + fn rejected_set_option_commands_receive_one_reply() { + let (reply, replies) = bounded(1); + reject_command( + Command::SetOption { + id: "rbw".into(), + choice: "30".into(), + reply, + }, + anyhow!("injected abort failure"), + ); + assert!(replies.recv().unwrap().is_err()); + assert!(matches!( + replies.try_recv(), + Err(TryRecvError::Disconnected) + )); } #[test] @@ -1341,6 +2040,8 @@ mod tests { normalize_span(1.0, 959_900_000, DEFAULT_POINTS).unwrap(), DEFAULT_POINTS as u64 ); + assert_eq!(normalize_span(899.0, 959_900_000, 900).unwrap(), 900); + assert_eq!(normalize_span(900.0, 959_900_000, 900).unwrap(), 900); 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])); @@ -1438,8 +2139,8 @@ mod tests { stop_hz: 108_000_000, generation: 7, }; - assert!(validate_direct_sweep(Some(valid), Model::Basic, BasicInput::Low).is_ok()); - assert!(validate_direct_sweep(None, Model::Basic, BasicInput::Low).is_ok()); + assert!(validate_direct_sweep(Some(valid), Model::Basic, BasicInput::Low, 450).is_ok()); + assert!(validate_direct_sweep(None, Model::Basic, BasicInput::Low, 450).is_ok()); assert!(validate_direct_sweep( Some(DirectSweepConfig { start_hz: valid.stop_hz, @@ -1447,7 +2148,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::Low + BasicInput::Low, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1456,7 +2158,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::Low + BasicInput::Low, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1466,7 +2169,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::High + BasicInput::High, + 450, ) .is_ok()); assert!(validate_direct_sweep( @@ -1476,7 +2180,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::High + BasicInput::High, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1486,9 +2191,39 @@ mod tests { ..valid }), Model::Basic, - BasicInput::Low + BasicInput::Low, + 450, ) .is_err()); + let exact = DirectSweepConfig { + start_hz: 100_000, + stop_hz: 100_450, + generation: 1, + }; + assert!(validate_direct_sweep(Some(exact), Model::Basic, BasicInput::Low, 450).is_ok()); + assert!(validate_direct_sweep(Some(exact), Model::Basic, BasicInput::Low, 900).is_err()); + } + + #[test] + fn point_updates_fit_both_normal_and_direct_spans() { + let (options, _) = startup_options(Model::Basic, &TinySaSettings::default()).unwrap(); + assert_eq!( + capabilities(Model::Basic, BasicInput::Low).sample_rate_min_hz, + 64.0 + ); + assert!(prepare_option_update(&options, Model::Basic, "points", "900", 900, None).is_ok()); + assert!(prepare_option_update(&options, Model::Basic, "points", "900", 899, None).is_err()); + let direct = Some(DirectSweepConfig { + start_hz: 100_000, + stop_hz: 100_899, + generation: 1, + }); + assert!( + prepare_option_update(&options, Model::Basic, "points", "900", 10_000, direct).is_err() + ); + assert!( + prepare_option_update(&options, Model::Basic, "points", "450", 450, direct).is_ok() + ); } #[test] @@ -1557,18 +2292,39 @@ mod tests { #[test] fn narrow_rbw_expands_the_scan_deadline() { - let settings = ScanSettings { - points: DEFAULT_POINTS, - rbw_khz: Some(0.2), - spur: SpurMode::Auto, + let settings = TinySaSettings { + rbw: "0.2".into(), + ..TinySaSettings::default() }; + let (options, _) = startup_options(Model::Zs405, &settings).unwrap(); 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_eq!(current_rbw_hz(&options), Some(200)); + let timeout = scan_inactivity_timeout(segment, Model::Zs405, &options).unwrap(); assert!(timeout > Duration::from_secs(120), "{timeout:?}"); + let (automatic, _) = startup_options(Model::Zs405, &TinySaSettings::default()).unwrap(); + assert_eq!(current_rbw_hz(&automatic), None); + } + + #[test] + fn explicit_rbw_updates_the_next_trace_metadata_and_timeout() { + let (mut options, _) = startup_options(Model::Zs405, &TinySaSettings::default()).unwrap(); + let segment = Segment { + start_hz: 400_000_000, + stop_hz: 500_000_000, + points: 450, + }; + let automatic_timeout = scan_inactivity_timeout(segment, Model::Zs405, &options).unwrap(); + let prepared = + prepare_option_update(&options, Model::Zs405, "rbw", "0.2", 10_000, None).unwrap(); + execute_option_update(&mut options, prepared, "rbw", "0.2", |_| Ok(())).unwrap(); + assert_eq!(current_rbw_hz(&options), Some(200)); + assert!( + scan_inactivity_timeout(segment, Model::Zs405, &options).unwrap() > automatic_timeout + ); } #[cfg(test)] @@ -1579,7 +2335,12 @@ mod tests { #[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(); + let device = TinySaDevice::open( + Path::new(&path), + BasicInput::Low, + &TinySaSettings::default(), + ) + .unwrap(); assert!(device .info() .board_name @@ -1607,6 +2368,8 @@ mod tests { assert_eq!(spectrum.target, PowerTraceTarget::Spectrum); assert_eq!(spectrum.frequencies_hz.len(), spectrum.levels_dbm.len()); assert!(!spectrum.frequencies_hz.is_empty()); + assert!(device.options().iter().any(|option| option.id == "rbw")); + device.set_option("points", "64").unwrap(); device.stop_rx().unwrap(); assert!(!device.is_streaming()); } diff --git a/src/hardware/tinysa/protocol.rs b/src/hardware/tinysa/protocol.rs index 15928ff1..c8b39d5b 100644 --- a/src/hardware/tinysa/protocol.rs +++ b/src/hardware/tinysa/protocol.rs @@ -69,6 +69,13 @@ pub(super) fn parse_text_frame(frame: &[u8], command: &str) -> anyhow::Result anyhow::Result<()> { + if !body.is_empty() { + bail!("tinySA {command} command returned an unexpected response"); + } + Ok(()) +} + pub(super) fn parse_identity( version: &[u8], info: &[u8], @@ -196,6 +203,19 @@ mod tests { assert!(parse_text_frame(b"frobnicate\r\nfrobnicate?\r\nch> ", "frobnicate").is_err()); } + #[test] + fn setters_require_an_empty_response_body() { + assert!(validate_setter_body(b"", "rbw 10").is_ok()); + for body in [ + b"usage: rbw {auto|3|10}\r\n".as_slice(), + b"error: invalid value\r\n".as_slice(), + b"ok\r\n".as_slice(), + b"\r\n".as_slice(), + ] { + assert!(validate_setter_body(body, "rbw 10").is_err()); + } + } + #[test] fn basic_identity_does_not_need_a_hardware_line() { let identity = parse_identity( diff --git a/user_docs/config.md b/user_docs/config.md index 0fee6cba..49a7203d 100644 --- a/user_docs/config.md +++ b/user_docs/config.md @@ -59,6 +59,16 @@ base = "nord" # see themes.md for the six palettes start_hz = 400000000 # scanner band start stop_hz = 500000000 # scanner band end dwell_ms = 200 # measure time per step (50–2000) + +[tinysa] +points = 450 # 64, 128, 290, 450, 900 or 1800 +rbw = "auto" # resolution bandwidth in kHz +attenuation = "auto" # auto or 0–31 dB +lna = false # Ultra only +lna2 = "auto" # Ultra only: auto or 0–7 +agc = "auto" # Ultra only: auto or 0–7 +spur = "auto" # Basic uses on/off; Ultra also accepts auto +ext_gain_db = 0 # -100–100 dB ``` Each waterfall cell shows two rows of history, so `waterfall_max_rows` is twice @@ -123,6 +133,16 @@ spectrum focus, and both persist once you've picked one. **`[sweep]`** configures the band scanner, described below. +**`[tinysa]`** sets the controls applied after the backend resets the analyzer to +a safe input baseline. The Options pane updates these values at runtime. They +are saved from the analyzer's current state on quit. Other backends preserve the +block unchanged. + +Basic analyzers convert `spur = "auto"` to `"on"`. They also convert Ultra-only +RBW values `0.2`, `1` and `850` to `"auto"`. Ultra-only LNA, LNA2 and AGC values +stay in the file when a Basic analyzer is used. Other invalid values are reported +when a tinySA opens. + --- ## Runtime input: frequency and sample rate diff --git a/user_docs/hardware.md b/user_docs/hardware.md index f80adf5c..4f9d6311 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -44,8 +44,15 @@ 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 offers the -spectrum, waterfall, full band sweep and micro sweep layouts. RBW, attenuation, -gain, AGC and spur handling use safe automatic defaults. +spectrum, waterfall, full band sweep and micro sweep layouts. The Options pane +controls scan points, RBW, attenuation, spur removal and external gain. Ultra +models also expose LNA, LNA2 and AGC. Scan points are a host-side setting. The +other controls use the matching tinySA console commands. + +sdrtop disables output and aborts pending work during startup. It then forces +input mode and applies a safe automatic baseline before restoring `[tinysa]` +settings. Basic input-path changes restore the active RBW, attenuation, spur and +external gain settings after the firmware resets them. ```sh sdrtop --device tinysa From 695c65495a5684560d149d14c4cb7d1d20965ac0 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 15:08:50 +0200 Subject: [PATCH 2/6] fix: keep tinySA controls truthful Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/mod.rs | 142 ++++--------------------------------- src/hardware/tinysa/mod.rs | 141 ++++++++++++++++++++++++++---------- user_docs/config.md | 10 +-- user_docs/hardware.md | 6 +- 4 files changed, 125 insertions(+), 174 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 7c9b2532..548acd3d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -106,7 +106,7 @@ impl App { self.state.lock().unwrap_or_else(|error| error.into_inner()); m.ui.quit_after_device_option = true; } else { - self.finish_session(); + self.finish_session()?; return Ok(()); } } @@ -121,7 +121,7 @@ impl App { AppEvent::DeviceOptionComplete(completion) => { input::complete_device_option(&self.state, completion); if quit_requested && !self.device_option_pending() { - self.finish_session(); + self.finish_session()?; return Ok(()); } true @@ -165,10 +165,10 @@ impl App { ) } - fn finish_session(&self) { + fn finish_session(&self) -> io::Result<()> { self.restore_noise_sweep(); self.restore_sweep_tuning(); - self.save_config(); + self.save_config().map_err(io::Error::other) } fn draw(&mut self, terminal: &mut Terminal) -> io::Result<()> { @@ -370,17 +370,17 @@ impl App { let _ = device.set_frequency(hz); } - fn save_config(&self) { - if self.device.is_none() { - return; - } + fn save_config(&self) -> anyhow::Result<()> { + let Some(device) = self.device.as_ref() else { + return Ok(()); + }; let Some(path) = &self.config_path else { - return; + return Ok(()); }; - let tinysa_options = if self.device_kind == hardware::DeviceKind::TinySa { - self.device.as_ref().map(|device| device.options()) + let tinysa = if self.device_kind == hardware::DeviceKind::TinySa { + crate::hardware::tinysa::persisted_settings(&self.tinysa_config, &device.options())? } else { - None + self.tinysa_config.clone() }; let (freq, rate, gains, amp, wf_rows, wf_palette, spec_style, markers, sweep_cfg, recall) = { let m = self.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -397,11 +397,6 @@ impl App { crate::state::recall_to_hz(&m.ui.recall), ) }; - let tinysa = persisted_tinysa_settings( - self.device_kind, - &self.tinysa_config, - tinysa_options.as_deref(), - ); let cfg = AppConfig { radio: RadioConfig { frequency_hz: freq, @@ -437,127 +432,16 @@ impl App { tinysa, presets: self.user_presets.clone(), }; - let _ = cfg.save(path); + cfg.save(path) } } -fn persisted_tinysa_settings( - device_kind: hardware::DeviceKind, - loaded: &crate::config::TinySaSettings, - options: Option<&[crate::hardware::DeviceOption]>, -) -> crate::config::TinySaSettings { - if device_kind != hardware::DeviceKind::TinySa { - return loaded.clone(); - } - let mut settings = loaded.clone(); - for option in options.unwrap_or_default() { - let value = option.selected_choice.as_str(); - match option.id.as_str() { - "points" => { - if let Ok(points) = value.parse() { - settings.points = points; - } - } - "rbw" => settings.rbw = value.to_string(), - "attenuation" => settings.attenuation = value.to_string(), - "lna" => settings.lna = value == "on", - "lna2" => settings.lna2 = value.to_string(), - "agc" => settings.agc = value.to_string(), - "spur" => settings.spur = value.to_string(), - "ext_gain" => { - if let Ok(db) = value.parse() { - settings.ext_gain_db = db; - } - } - _ => {} - } - } - settings -} - #[cfg(test)] mod tests { use super::*; use crate::state::DeviceOptionUpdate; use std::cell::Cell; use std::sync::{mpsc, Arc, Mutex}; - - fn device_option(id: &str, choice: &str) -> crate::hardware::DeviceOption { - crate::hardware::DeviceOption { - id: id.into(), - label: id.into(), - choices: vec![choice.into()], - selected_choice: choice.into(), - } - } - - #[test] - fn non_tinysa_backends_preserve_loaded_tinysa_settings() { - let loaded = crate::config::TinySaSettings { - points: 1800, - rbw: "custom".into(), - attenuation: "31".into(), - lna: true, - lna2: "7".into(), - agc: "6".into(), - spur: "off".into(), - ext_gain_db: 99, - }; - let options = [device_option("points", "64")]; - assert_eq!( - persisted_tinysa_settings(hardware::DeviceKind::HackRf, &loaded, Some(&options),), - loaded - ); - } - - #[test] - fn tinysa_persistence_uses_authoritative_dependent_settings() { - let loaded = crate::config::TinySaSettings::default(); - let options = [ - device_option("points", "900"), - device_option("rbw", "0.2"), - device_option("attenuation", "0"), - device_option("lna", "on"), - device_option("lna2", "3"), - device_option("agc", "7"), - device_option("spur", "off"), - device_option("ext_gain", "-12"), - ]; - let saved = - persisted_tinysa_settings(hardware::DeviceKind::TinySa, &loaded, Some(&options)); - assert_eq!(saved.points, 900); - assert_eq!(saved.rbw, "0.2"); - assert_eq!(saved.attenuation, "0"); - assert!(saved.lna); - assert_eq!(saved.lna2, "3"); - assert_eq!(saved.agc, "7"); - assert_eq!(saved.spur, "off"); - assert_eq!(saved.ext_gain_db, -12); - } - - #[test] - fn basic_tinysa_persistence_preserves_ultra_fields() { - let loaded = crate::config::TinySaSettings { - lna: true, - lna2: "5".into(), - agc: "4".into(), - ..crate::config::TinySaSettings::default() - }; - let options = [ - device_option("points", "64"), - device_option("rbw", "3"), - device_option("attenuation", "12"), - device_option("spur", "on"), - device_option("ext_gain", "7"), - ]; - let saved = - persisted_tinysa_settings(hardware::DeviceKind::TinySa, &loaded, Some(&options)); - assert_eq!(saved.points, 64); - assert!(saved.lna); - assert_eq!(saved.lna2, "5"); - assert_eq!(saved.agc, "4"); - } - /// Quitting must give the tuner back before the config is written. /// /// `save_config` persists `radio.frequency`, and while a sweep is running diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index cc800f58..063d8e69 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -1220,6 +1220,40 @@ fn validate_settings_shape(settings: &TinySaSettings) -> anyhow::Result<()> { Ok(()) } +pub(crate) fn persisted_settings( + loaded: &TinySaSettings, + options: &[DeviceOption], +) -> anyhow::Result { + let mut settings = loaded.clone(); + for option in options { + let value = option.selected_choice.as_str(); + validate_option_choice(options, &option.id, value)?; + match option.id.as_str() { + "points" => { + settings.points = value.parse().context("tinySA point setting is invalid")?; + } + "rbw" => settings.rbw = value.to_string(), + "attenuation" => settings.attenuation = value.to_string(), + "lna" => { + settings.lna = match value { + "off" => false, + "on" => true, + _ => bail!("tinySA LNA setting is invalid"), + }; + } + "spur" => settings.spur = value.to_string(), + "ext_gain" => { + settings.ext_gain_db = value + .parse() + .context("tinySA external gain setting is invalid")?; + } + id => bail!("unknown tinySA option {id}"), + } + } + validate_settings_shape(&settings)?; + Ok(settings) +} + fn startup_options( model: Model, settings: &TinySaSettings, @@ -1248,11 +1282,7 @@ fn startup_options( ("attenuation", attenuation.to_string()), ]; if model.is_ultra() { - values.extend([ - ("lna", if settings.lna { "on" } else { "off" }.to_string()), - ("lna2", settings.lna2.clone()), - ("agc", settings.agc.clone()), - ]); + values.push(("lna", if settings.lna { "on" } else { "off" }.to_string())); } values.extend([ ("spur", spur.to_string()), @@ -1278,8 +1308,6 @@ fn baseline_commands(model: Model) -> &'static [&'static str] { "rbw auto", "lna off", "attenuate auto", - "lna2 auto", - "agc auto", "spur auto", "ext_gain 0", ] @@ -1315,23 +1343,7 @@ fn option_definitions(model: Model) -> Vec { ), ]; if model.is_ultra() { - options.extend([ - option("lna", "LNA", strings(&["off", "on"])), - option( - "lna2", - "LNA2", - std::iter::once("auto".to_string()) - .chain((0..=7).map(|value| value.to_string())) - .collect(), - ), - option( - "agc", - "AGC", - std::iter::once("auto".to_string()) - .chain((0..=7).map(|value| value.to_string())) - .collect(), - ), - ]); + options.push(option("lna", "LNA", strings(&["off", "on"]))); } options.push(option( "spur", @@ -1411,8 +1423,6 @@ fn option_command( "rbw" => format!("rbw {choice}"), "attenuation" => format!("attenuate {choice}"), "lna" if model.is_ultra() => format!("lna {choice}"), - "lna2" if model.is_ultra() => format!("lna2 {choice}"), - "agc" if model.is_ultra() => format!("agc {choice}"), "spur" => format!("spur {choice}"), "ext_gain" => format!("ext_gain {choice}"), _ => bail!("unknown tinySA option {id}"), @@ -1531,7 +1541,7 @@ fn restore_option_commands(model: Model, options: &[DeviceOption]) -> anyhow::Re if selected_option_value(options, "lna") == Some("on") { commands.push("lna on".to_string()); } - for id in ["lna2", "agc", "spur", "ext_gain"] { + for id in ["spur", "ext_gain"] { let choice = selected_option_value(options, id) .with_context(|| format!("tinySA {id} is missing"))?; if let Some(command) = option_command(model, options, id, choice)? { @@ -1769,9 +1779,9 @@ mod tests { .choices, ["auto", "0.2", "1", "3", "10", "30", "100", "300", "600", "850"] ); - for id in ["lna", "lna2", "agc"] { - assert!(ultra.iter().any(|option| option.id == id)); - } + assert!(ultra.iter().any(|option| option.id == "lna")); + assert!(!ultra.iter().any(|option| option.id == "lna2")); + assert!(!ultra.iter().any(|option| option.id == "agc")); assert_eq!( ultra .iter() @@ -1793,8 +1803,6 @@ mod tests { "rbw auto", "lna off", "attenuate auto", - "lna2 auto", - "agc auto", "spur auto", "ext_gain 0", ] @@ -1821,8 +1829,6 @@ mod tests { "rbw 0.2", "attenuate 0", "lna on", - "lna2 3", - "agc 7", "spur off", "ext_gain -7", ] @@ -1978,8 +1984,6 @@ mod tests { "lna off", "rbw 30", "attenuate 12", - "lna2 3", - "agc 7", "spur off", "ext_gain -7", ] @@ -1997,14 +2001,75 @@ mod tests { "rbw 30", "attenuate 0", "lna on", - "lna2 3", - "agc 7", "spur off", "ext_gain -7", ] ); } + #[test] + fn ultra_persistence_uses_authoritative_dependent_settings() { + let loaded = TinySaSettings { + attenuation: "12".into(), + lna: true, + lna2: "5".into(), + agc: "4".into(), + ..TinySaSettings::default() + }; + let (mut options, _) = startup_options(Model::Zs407, &loaded).unwrap(); + for (id, choice) in [ + ("points", "900"), + ("rbw", "0.2"), + ("attenuation", "0"), + ("lna", "on"), + ("spur", "off"), + ("ext_gain", "-12"), + ] { + set_selected_option(&mut options, id, choice).unwrap(); + } + + let saved = persisted_settings(&loaded, &options).unwrap(); + + assert_eq!(saved.points, 900); + assert_eq!(saved.rbw, "0.2"); + assert_eq!(saved.attenuation, "0"); + assert!(saved.lna); + assert_eq!(saved.lna2, "5"); + assert_eq!(saved.agc, "4"); + assert_eq!(saved.spur, "off"); + assert_eq!(saved.ext_gain_db, -12); + } + + #[test] + fn basic_persistence_preserves_ultra_settings() { + let loaded = TinySaSettings { + lna: true, + lna2: "5".into(), + agc: "4".into(), + ..TinySaSettings::default() + }; + let options = option_definitions(Model::Basic); + let saved = persisted_settings(&loaded, &options).unwrap(); + + assert!(saved.lna); + assert_eq!(saved.lna2, "5"); + assert_eq!(saved.agc, "4"); + } + + #[test] + fn persistence_rejects_malformed_option_state() { + for id in ["points", "ext_gain"] { + let mut options = option_definitions(Model::Zs407); + options + .iter_mut() + .find(|option| option.id == id) + .unwrap() + .selected_choice = "invalid".into(); + + assert!(persisted_settings(&TinySaSettings::default(), &options).is_err()); + } + } + #[test] fn rejected_set_option_commands_receive_one_reply() { let (reply, replies) = bounded(1); diff --git a/user_docs/config.md b/user_docs/config.md index 49a7203d..e2975102 100644 --- a/user_docs/config.md +++ b/user_docs/config.md @@ -65,8 +65,6 @@ points = 450 # 64, 128, 290, 450, 900 or 1800 rbw = "auto" # resolution bandwidth in kHz attenuation = "auto" # auto or 0–31 dB lna = false # Ultra only -lna2 = "auto" # Ultra only: auto or 0–7 -agc = "auto" # Ultra only: auto or 0–7 spur = "auto" # Basic uses on/off; Ultra also accepts auto ext_gain_db = 0 # -100–100 dB ``` @@ -139,9 +137,11 @@ are saved from the analyzer's current state on quit. Other backends preserve the block unchanged. Basic analyzers convert `spur = "auto"` to `"on"`. They also convert Ultra-only -RBW values `0.2`, `1` and `850` to `"auto"`. Ultra-only LNA, LNA2 and AGC values -stay in the file when a Basic analyzer is used. Other invalid values are reported -when a tinySA opens. +RBW values `0.2`, `1` and `850` to `"auto"`. The Ultra-only LNA value stays in +the file when a Basic analyzer is used. Legacy `lna2` and `agc` values are +accepted and preserved without being applied. Verified ZS405 firmware reloads +its internal LNA2 and AGC values before every scan. Other invalid values are +reported when a tinySA opens. --- diff --git a/user_docs/hardware.md b/user_docs/hardware.md index 4f9d6311..b043d6e5 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -46,8 +46,10 @@ running `tinySA4_v1.4-236-ge5aa115`. The device supplies swept power readings without IQ samples. sdrtop offers the spectrum, waterfall, full band sweep and micro sweep layouts. The Options pane controls scan points, RBW, attenuation, spur removal and external gain. Ultra -models also expose LNA, LNA2 and AGC. Scan points are a host-side setting. The -other controls use the matching tinySA console commands. +models also expose the external front-end LNA. Scan points are a host-side +setting. The other controls use the matching tinySA console commands. LNA2 and +AGC are hidden because the verified ZS405 firmware reloads its internal values +before every scan. sdrtop disables output and aborts pending work during startup. It then forces input mode and applies a safe automatic baseline before restoring `[tinysa]` From bb93f863439121050fdf4498cde003332ae6a868 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 16:52:16 +0200 Subject: [PATCH 3/6] fix: persist tinySA input controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/builder/mod.rs | 1 + src/app/mod.rs | 16 +- src/config.rs | 15 ++ src/hardware/discovery.rs | 5 +- src/hardware/tinysa/mod.rs | 334 +++++++++++++++++++++++++++++++------ user_docs/config.md | 29 +++- user_docs/hardware.md | 39 +++-- 7 files changed, 372 insertions(+), 67 deletions(-) diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index 23db7909..d50ce30b 100644 --- a/src/app/builder/mod.rs +++ b/src/app/builder/mod.rs @@ -347,6 +347,7 @@ impl App { focus_keys, theme_config: cfg.theme.clone(), tinysa_config: cfg.tinysa.clone(), + tinysa_basic_input: None, device_kind, user_presets: cfg.presets, }) diff --git a/src/app/mod.rs b/src/app/mod.rs index 548acd3d..8075c92f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -48,6 +48,7 @@ pub struct App { /// to write back. pub(super) theme_config: crate::config::ThemeConfig, pub(super) tinysa_config: crate::config::TinySaSettings, + pub(super) tinysa_basic_input: Option, pub(super) device_kind: hardware::DeviceKind, } @@ -57,7 +58,10 @@ impl App { config_path: Option, listing: &hardware::DeviceListing, ) -> anyhow::Result { - match hardware::open_device(listing, &cfg.tinysa) { + let tinysa_basic_input = (listing.kind == hardware::DeviceKind::TinySa).then(|| { + hardware::tinysa::resolve_basic_input(listing.tiny_sa_input, cfg.tinysa.basic_input) + }); + let mut app = match hardware::open_device(listing, &cfg.tinysa) { Ok(device) => Self::new_normal(cfg, config_path, device, listing.kind), Err(open_err) => { // Device is present but couldn't be opened (e.g. busy) - fall back @@ -72,7 +76,9 @@ impl App { }; Self::new_observer(cfg, config_path, sysinfo, profile, listing.kind) } - } + }?; + app.tinysa_basic_input = tinysa_basic_input; + Ok(app) } pub fn run(&mut self, terminal: &mut Terminal) -> io::Result<()> { @@ -378,7 +384,11 @@ impl App { return Ok(()); }; let tinysa = if self.device_kind == hardware::DeviceKind::TinySa { - crate::hardware::tinysa::persisted_settings(&self.tinysa_config, &device.options())? + crate::hardware::tinysa::persisted_settings( + &self.tinysa_config, + &device.options(), + self.tinysa_basic_input, + )? } else { self.tinysa_config.clone() }; diff --git a/src/config.rs b/src/config.rs index 352b7aef..4265b872 100644 --- a/src/config.rs +++ b/src/config.rs @@ -172,6 +172,8 @@ fn default_auto() -> String { /// Settings applied when a tinySA backend opens. #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] pub struct TinySaSettings { + #[serde(default)] + pub basic_input: crate::hardware::tinysa::BasicInput, #[serde(default = "default_tinysa_points")] pub points: u32, #[serde(default = "default_auto")] @@ -179,6 +181,8 @@ pub struct TinySaSettings { #[serde(default = "default_auto")] pub attenuation: String, #[serde(default)] + pub high_attenuation: bool, + #[serde(default)] pub lna: bool, #[serde(default = "default_auto")] pub lna2: String, @@ -193,9 +197,11 @@ pub struct TinySaSettings { impl Default for TinySaSettings { fn default() -> Self { Self { + basic_input: crate::hardware::tinysa::BasicInput::default(), points: default_tinysa_points(), rbw: default_auto(), attenuation: default_auto(), + high_attenuation: false, lna: false, lna2: default_auto(), agc: default_auto(), @@ -771,9 +777,11 @@ panels = [ let source = r#" [tinysa] + basic_input = "high" points = 900 rbw = "0.2" attenuation = "12" + high_attenuation = true lna = true lna2 = "3" agc = "7" @@ -785,14 +793,21 @@ panels = [ let restored: AppConfig = toml::from_str(&serialized).unwrap(); assert_eq!(restored.tinysa, config.tinysa); assert!(serialized.contains("[tinysa]")); + assert!(serialized.contains("basic_input = \"high\"")); + assert!(restored.tinysa.high_attenuation); } #[test] fn partial_tinysa_settings_fill_field_defaults() { let config: AppConfig = toml::from_str("[tinysa]\npoints = 64\n").unwrap(); assert_eq!(config.tinysa.points, 64); + assert_eq!( + config.tinysa.basic_input, + crate::hardware::tinysa::BasicInput::Low + ); assert_eq!(config.tinysa.rbw, "auto"); assert_eq!(config.tinysa.attenuation, "auto"); + assert!(!config.tinysa.high_attenuation); assert!(!config.tinysa.lna); assert_eq!(config.tinysa.lna2, "auto"); assert_eq!(config.tinysa.agc, "auto"); diff --git a/src/hardware/discovery.rs b/src/hardware/discovery.rs index 7b2ba975..5d5aa0ca 100644 --- a/src/hardware/discovery.rs +++ b/src/hardware/discovery.rs @@ -263,9 +263,12 @@ pub fn open_device( let Some(path) = listing.path.as_deref() else { anyhow::bail!("a tinySA listing with no serial port cannot be opened"); }; + let basic_input = + tinysa::resolve_basic_input(listing.tiny_sa_input, tinysa_settings.basic_input); Ok(Arc::new(tinysa::TinySaDevice::open( path, - listing.tiny_sa_input.unwrap_or_default(), + basic_input, + listing.tiny_sa_input, tinysa_settings, )?)) } diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index 063d8e69..aa4d4c13 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Context}; use crossbeam_channel::{bounded, Receiver, Sender, TryRecvError}; +use serde::{Deserialize, Serialize}; use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; use crate::config::TinySaSettings; @@ -39,7 +40,8 @@ const BASIC_HIGH_MAX_HZ: u64 = 959_000_000; type UnitReply = Sender>; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum BasicInput { #[default] Low, @@ -77,6 +79,10 @@ impl BasicInput { } } +pub fn resolve_basic_input(explicit: Option, configured: BasicInput) -> BasicInput { + explicit.unwrap_or(configured) +} + pub fn list(selector: Option<&str>) -> Vec { let (path, input) = match selector { Some(selector) => match parse_selector(selector) { @@ -138,6 +144,7 @@ impl TinySaDevice { pub fn open( path: &Path, basic_input: BasicInput, + explicit_basic_input: Option, settings: &TinySaSettings, ) -> anyhow::Result { validate_settings_shape(settings)?; @@ -179,12 +186,17 @@ impl TinySaDevice { } }; let caps = capabilities(initialized.identity.model, basic_input); - let notes = initialized + let mut notes = initialized .identity .hardware .iter() .cloned() .collect::>(); + if let Some(note) = + ignored_explicit_basic_input_note(initialized.identity.model, explicit_basic_input) + { + notes.push(note); + } let info = DeviceInfo { board_name: initialized.identity.board.clone(), serial: path.display().to_string(), @@ -698,13 +710,10 @@ fn initialize( 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"); - } - let (options, saved_commands) = startup_options(identity.model, settings)?; + let (options, saved_commands) = startup_options(identity.model, basic_input, settings)?; send_setter_command(port, input_mode_command(identity.model, basic_input))?; send_setter_command(port, "abort on")?; - for command in baseline_commands(identity.model) { + for command in baseline_commands(identity.model, basic_input) { send_setter_command(port, command)?; } for command in saved_commands { @@ -726,6 +735,20 @@ fn input_mode_command(model: Model, basic_input: BasicInput) -> &'static str { } } +fn ignored_explicit_basic_input_note( + model: Model, + explicit_basic_input: Option, +) -> Option { + explicit_basic_input.and_then(|input| { + model.is_ultra().then(|| { + format!( + "tinySA Ultra uses automatic input selection; explicit Basic {} input selection was ignored", + input.label() + ) + }) + }) +} + 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"))?; @@ -1223,6 +1246,7 @@ fn validate_settings_shape(settings: &TinySaSettings) -> anyhow::Result<()> { pub(crate) fn persisted_settings( loaded: &TinySaSettings, options: &[DeviceOption], + resolved_basic_input: Option, ) -> anyhow::Result { let mut settings = loaded.clone(); for option in options { @@ -1234,6 +1258,13 @@ pub(crate) fn persisted_settings( } "rbw" => settings.rbw = value.to_string(), "attenuation" => settings.attenuation = value.to_string(), + "high_attenuation" => { + settings.high_attenuation = match value { + "off" => false, + "on" => true, + _ => bail!("tinySA HIGH attenuation setting is invalid"), + }; + } "lna" => { settings.lna = match value { "off" => false, @@ -1250,26 +1281,27 @@ pub(crate) fn persisted_settings( id => bail!("unknown tinySA option {id}"), } } + if !options.iter().any(|option| option.id == "lna") { + settings.basic_input = + resolved_basic_input.context("resolved tinySA Basic input is missing")?; + } validate_settings_shape(&settings)?; Ok(settings) } fn startup_options( model: Model, + basic_input: BasicInput, settings: &TinySaSettings, ) -> anyhow::Result<(Vec, Vec)> { validate_settings_shape(settings)?; - let mut options = option_definitions(model); + validate_legacy_diagnostics(model, settings)?; + let mut options = option_definitions(model, basic_input); let rbw = if model == Model::Basic && matches!(settings.rbw.as_str(), "0.2" | "1" | "850") { "auto" } else { &settings.rbw }; - let attenuation = if model.is_ultra() && settings.lna { - "0" - } else { - &settings.attenuation - }; let spur = if model == Model::Basic && settings.spur == "auto" { "on" } else { @@ -1279,8 +1311,25 @@ fn startup_options( let mut values = vec![ ("points", settings.points.to_string()), ("rbw", rbw.to_string()), - ("attenuation", attenuation.to_string()), ]; + if model == Model::Basic && basic_input == BasicInput::High { + values.push(( + "high_attenuation", + if settings.high_attenuation { + "on" + } else { + "off" + } + .to_string(), + )); + } else { + let attenuation = if model.is_ultra() && settings.lna { + "0" + } else { + &settings.attenuation + }; + values.push(("attenuation", attenuation.to_string())); + } if model.is_ultra() { values.push(("lna", if settings.lna { "on" } else { "off" }.to_string())); } @@ -1300,7 +1349,19 @@ fn startup_options( Ok((options, commands)) } -fn baseline_commands(model: Model) -> &'static [&'static str] { +fn validate_legacy_diagnostics(model: Model, settings: &TinySaSettings) -> anyhow::Result<()> { + if !model.is_ultra() { + return Ok(()); + } + for (name, value) in [("LNA2", &settings.lna2), ("AGC", &settings.agc)] { + if value != "auto" { + bail!("tinySA Ultra {name} must be 'auto': firmware overwrites it before every scan"); + } + } + Ok(()) +} + +fn baseline_commands(model: Model, basic_input: BasicInput) -> &'static [&'static str] { if model.is_ultra() { &[ "ultra on", @@ -1311,12 +1372,14 @@ fn baseline_commands(model: Model) -> &'static [&'static str] { "spur auto", "ext_gain 0", ] + } else if basic_input == BasicInput::High { + &["rbw auto", "attenuate 0", "spur on", "ext_gain 0"] } else { &["rbw auto", "attenuate auto", "spur on", "ext_gain 0"] } } -fn option_definitions(model: Model) -> Vec { +fn option_definitions(model: Model, basic_input: BasicInput) -> Vec { let mut options = vec![ option( "points", @@ -1334,14 +1397,22 @@ fn option_definitions(model: Model) -> Vec { strings(&["auto", "3", "10", "30", "100", "300", "600"]) }, ), - option( + ]; + if model == Model::Basic && basic_input == BasicInput::High { + options.push(option( + "high_attenuation", + "Coarse attenuation", + strings(&["off", "on"]), + )); + } else { + options.push(option( "attenuation", "Attenuation (dB)", std::iter::once("auto".to_string()) .chain((0..=31).map(|value| value.to_string())) .collect(), - ), - ]; + )); + } if model.is_ultra() { options.push(option("lna", "LNA", strings(&["off", "on"]))); } @@ -1422,6 +1493,14 @@ fn option_command( "points" => return Ok(None), "rbw" => format!("rbw {choice}"), "attenuation" => format!("attenuate {choice}"), + "high_attenuation" => format!( + "attenuate {}", + match choice { + "off" => "0", + "on" => "1", + _ => unreachable!("validated tinySA HIGH attenuation choice"), + } + ), "lna" if model.is_ultra() => format!("lna {choice}"), "spur" => format!("spur {choice}"), "ext_gain" => format!("ext_gain {choice}"), @@ -1515,7 +1594,12 @@ fn current_rbw_hz(options: &[DeviceOption]) -> Option { fn basic_option_commands(options: &[DeviceOption]) -> anyhow::Result> { let mut commands = Vec::new(); - for id in ["rbw", "attenuation", "spur", "ext_gain"] { + let attenuation_id = if options.iter().any(|option| option.id == "high_attenuation") { + "high_attenuation" + } else { + "attenuation" + }; + for id in ["rbw", attenuation_id, "spur", "ext_gain"] { let choice = selected_option_value(options, id) .with_context(|| format!("tinySA {id} is missing"))?; if let Some(command) = option_command(Model::Basic, options, id, choice)? { @@ -1561,7 +1645,7 @@ fn recover_option_state( drain_startup(port)?; send_setter_command(port, input_mode_command(model, basic_input))?; send_setter_command(port, "abort on")?; - for command in baseline_commands(model) { + for command in baseline_commands(model, basic_input) { send_setter_command(port, command)?; } for command in restore_option_commands(model, options)? { @@ -1743,7 +1827,7 @@ mod tests { #[test] fn option_definitions_match_each_model() { - let basic = option_definitions(Model::Basic); + let basic = option_definitions(Model::Basic, BasicInput::Low); assert_eq!( basic .iter() @@ -1760,6 +1844,13 @@ mod tests { .choices, ["auto", "3", "10", "30", "100", "300", "600"] ); + let low_attenuation = basic + .iter() + .find(|option| option.id == "attenuation") + .unwrap(); + assert_eq!(low_attenuation.label, "Attenuation (dB)"); + assert_eq!(low_attenuation.choices.first().unwrap(), "auto"); + assert_eq!(low_attenuation.choices.last().unwrap(), "31"); assert!(!basic.iter().any(|option| option.id == "lna")); assert_eq!( basic @@ -1770,7 +1861,16 @@ mod tests { ["off", "on"] ); - let ultra = option_definitions(Model::Zs407); + let high = option_definitions(Model::Basic, BasicInput::High); + let high_attenuation = high + .iter() + .find(|option| option.id == "high_attenuation") + .unwrap(); + assert_eq!(high_attenuation.label, "Coarse attenuation"); + assert_eq!(high_attenuation.choices, ["off", "on"]); + assert!(!high.iter().any(|option| option.id == "attenuation")); + + let ultra = option_definitions(Model::Zs407, BasicInput::Low); assert_eq!( ultra .iter() @@ -1796,7 +1896,7 @@ mod tests { #[test] fn startup_sets_a_safe_baseline_before_saved_values() { assert_eq!( - baseline_commands(Model::Zs407), + baseline_commands(Model::Zs407, BasicInput::Low), [ "ultra on", "ultra auto", @@ -1808,21 +1908,28 @@ mod tests { ] ); assert_eq!( - baseline_commands(Model::Basic), + baseline_commands(Model::Basic, BasicInput::Low), ["rbw auto", "attenuate auto", "spur on", "ext_gain 0"] ); + assert_eq!( + baseline_commands(Model::Basic, BasicInput::High), + ["rbw auto", "attenuate 0", "spur on", "ext_gain 0"] + ); let settings = TinySaSettings { + basic_input: BasicInput::Low, points: 900, rbw: "0.2".into(), attenuation: "12".into(), + high_attenuation: false, lna: true, - lna2: "3".into(), - agc: "7".into(), + lna2: "auto".into(), + agc: "auto".into(), spur: "off".into(), ext_gain_db: -7, }; - let (options, commands) = startup_options(Model::Zs407, &settings).unwrap(); + let (options, commands) = + startup_options(Model::Zs407, BasicInput::Low, &settings).unwrap(); assert_eq!( commands, [ @@ -1846,7 +1953,8 @@ mod tests { spur: "auto".into(), ..TinySaSettings::default() }; - let (options, commands) = startup_options(Model::Basic, &settings).unwrap(); + let (options, commands) = + startup_options(Model::Basic, BasicInput::Low, &settings).unwrap(); assert_eq!(selected_option_value(&options, "rbw"), Some("auto")); assert_eq!(selected_option_value(&options, "spur"), Some("on")); assert!(commands.iter().any(|command| command == "rbw auto")); @@ -1855,6 +1963,7 @@ mod tests { assert!(startup_options( Model::Basic, + BasicInput::Low, &TinySaSettings { attenuation: "32".into(), ..TinySaSettings::default() @@ -1863,6 +1972,7 @@ mod tests { .is_err()); assert!(startup_options( Model::Basic, + BasicInput::Low, &TinySaSettings { rbw: "2".into(), ..TinySaSettings::default() @@ -1871,6 +1981,7 @@ mod tests { .is_err()); assert!(startup_options( Model::Basic, + BasicInput::Low, &TinySaSettings { spur: "maybe".into(), ..TinySaSettings::default() @@ -1891,7 +2002,7 @@ mod tests { #[test] fn option_commands_are_whitelisted_after_choice_validation() { - let basic = option_definitions(Model::Basic); + let basic = option_definitions(Model::Basic, BasicInput::Low); assert_eq!( option_command(Model::Basic, &basic, "points", "450").unwrap(), None @@ -1903,11 +2014,23 @@ mod tests { assert!(option_command(Model::Basic, &basic, "rbw", "10; reset").is_err()); assert!(option_command(Model::Basic, &basic, "lna", "on").is_err()); assert!(option_command(Model::Basic, &basic, "missing", "on").is_err()); + + let high = option_definitions(Model::Basic, BasicInput::High); + assert_eq!( + option_command(Model::Basic, &high, "high_attenuation", "off").unwrap(), + Some("attenuate 0".into()) + ); + assert_eq!( + option_command(Model::Basic, &high, "high_attenuation", "on").unwrap(), + Some("attenuate 1".into()) + ); + assert!(option_command(Model::Basic, &high, "high_attenuation", "auto").is_err()); } #[test] fn lna_forces_zero_attenuation_and_blocks_other_values() { - let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + let (mut options, _) = + startup_options(Model::Zs407, BasicInput::Low, &TinySaSettings::default()).unwrap(); set_selected_option(&mut options, "attenuation", "12").unwrap(); let prepared = prepare_option_update(&options, Model::Zs407, "lna", "on", 10_000, None).unwrap(); @@ -1935,7 +2058,8 @@ mod tests { #[test] fn failed_setter_commands_do_not_change_option_state() { - let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + let (mut options, _) = + startup_options(Model::Zs407, BasicInput::Low, &TinySaSettings::default()).unwrap(); let before = options.clone(); let prepared = prepare_option_update(&options, Model::Zs407, "rbw", "30", 10_000, None).unwrap(); @@ -1948,7 +2072,8 @@ mod tests { #[test] fn failed_lna_activation_does_not_publish_dependent_state() { - let (mut options, _) = startup_options(Model::Zs407, &TinySaSettings::default()).unwrap(); + let (mut options, _) = + startup_options(Model::Zs407, BasicInput::Low, &TinySaSettings::default()).unwrap(); set_selected_option(&mut options, "attenuation", "12").unwrap(); let before = options.clone(); let prepared = @@ -1971,13 +2096,11 @@ mod tests { let settings = TinySaSettings { rbw: "30".into(), attenuation: "12".into(), - lna2: "3".into(), - agc: "7".into(), spur: "off".into(), ext_gain_db: -7, ..TinySaSettings::default() }; - let (options, _) = startup_options(Model::Zs407, &settings).unwrap(); + let (options, _) = startup_options(Model::Zs407, BasicInput::Low, &settings).unwrap(); assert_eq!( restore_option_commands(Model::Zs407, &options).unwrap(), [ @@ -1993,7 +2116,7 @@ mod tests { lna: true, ..settings }; - let (options, _) = startup_options(Model::Zs407, &lna_settings).unwrap(); + let (options, _) = startup_options(Model::Zs407, BasicInput::Low, &lna_settings).unwrap(); assert_eq!( restore_option_commands(Model::Zs407, &options).unwrap(), [ @@ -2016,7 +2139,8 @@ mod tests { agc: "4".into(), ..TinySaSettings::default() }; - let (mut options, _) = startup_options(Model::Zs407, &loaded).unwrap(); + let (mut options, _) = + startup_options(Model::Zs407, BasicInput::Low, &TinySaSettings::default()).unwrap(); for (id, choice) in [ ("points", "900"), ("rbw", "0.2"), @@ -2028,7 +2152,7 @@ mod tests { set_selected_option(&mut options, id, choice).unwrap(); } - let saved = persisted_settings(&loaded, &options).unwrap(); + let saved = persisted_settings(&loaded, &options, Some(BasicInput::High)).unwrap(); assert_eq!(saved.points, 900); assert_eq!(saved.rbw, "0.2"); @@ -2040,6 +2164,73 @@ mod tests { assert_eq!(saved.ext_gain_db, -12); } + #[test] + fn low_and_high_attenuation_persist_independently() { + let loaded = TinySaSettings { + basic_input: BasicInput::Low, + attenuation: "12".into(), + high_attenuation: true, + ..TinySaSettings::default() + }; + + let (mut low_options, _) = startup_options(Model::Basic, BasicInput::Low, &loaded).unwrap(); + set_selected_option(&mut low_options, "attenuation", "7").unwrap(); + let low_saved = persisted_settings(&loaded, &low_options, Some(BasicInput::Low)).unwrap(); + assert_eq!(low_saved.basic_input, BasicInput::Low); + assert_eq!(low_saved.attenuation, "7"); + assert!(low_saved.high_attenuation); + + let (mut high_options, _) = + startup_options(Model::Basic, BasicInput::High, &loaded).unwrap(); + set_selected_option(&mut high_options, "high_attenuation", "off").unwrap(); + let high_saved = + persisted_settings(&loaded, &high_options, Some(BasicInput::High)).unwrap(); + assert_eq!(high_saved.basic_input, BasicInput::High); + assert_eq!(high_saved.attenuation, "12"); + assert!(!high_saved.high_attenuation); + } + + #[test] + fn high_recovery_restores_the_coarse_attenuation_choice() { + let settings = TinySaSettings { + high_attenuation: true, + ..TinySaSettings::default() + }; + let (options, commands) = + startup_options(Model::Basic, BasicInput::High, &settings).unwrap(); + assert!(commands.iter().any(|command| command == "attenuate 1")); + assert_eq!( + restore_option_commands(Model::Basic, &options).unwrap(), + ["rbw auto", "attenuate 1", "spur on", "ext_gain 0"] + ); + } + + #[test] + fn legacy_diagnostics_are_ignored_on_basic_and_rejected_on_ultra() { + let manual = TinySaSettings { + lna2: "3".into(), + agc: "7".into(), + ..TinySaSettings::default() + }; + assert!(startup_options(Model::Basic, BasicInput::Low, &manual).is_ok()); + + for settings in [ + TinySaSettings { + lna2: "3".into(), + ..TinySaSettings::default() + }, + TinySaSettings { + agc: "7".into(), + ..TinySaSettings::default() + }, + ] { + let error = startup_options(Model::Zs407, BasicInput::Low, &settings) + .unwrap_err() + .to_string(); + assert!(error.contains("firmware overwrites it before every scan")); + } + } + #[test] fn basic_persistence_preserves_ultra_settings() { let loaded = TinySaSettings { @@ -2048,8 +2239,8 @@ mod tests { agc: "4".into(), ..TinySaSettings::default() }; - let options = option_definitions(Model::Basic); - let saved = persisted_settings(&loaded, &options).unwrap(); + let options = option_definitions(Model::Basic, BasicInput::Low); + let saved = persisted_settings(&loaded, &options, Some(BasicInput::Low)).unwrap(); assert!(saved.lna); assert_eq!(saved.lna2, "5"); @@ -2059,14 +2250,19 @@ mod tests { #[test] fn persistence_rejects_malformed_option_state() { for id in ["points", "ext_gain"] { - let mut options = option_definitions(Model::Zs407); + let mut options = option_definitions(Model::Zs407, BasicInput::Low); options .iter_mut() .find(|option| option.id == id) .unwrap() .selected_choice = "invalid".into(); - assert!(persisted_settings(&TinySaSettings::default(), &options).is_err()); + assert!(persisted_settings( + &TinySaSettings::default(), + &options, + Some(BasicInput::Low) + ) + .is_err()); } } @@ -2271,7 +2467,8 @@ mod tests { #[test] fn point_updates_fit_both_normal_and_direct_spans() { - let (options, _) = startup_options(Model::Basic, &TinySaSettings::default()).unwrap(); + let (options, _) = + startup_options(Model::Basic, BasicInput::Low, &TinySaSettings::default()).unwrap(); assert_eq!( capabilities(Model::Basic, BasicInput::Low).sample_rate_min_hz, 64.0 @@ -2333,6 +2530,46 @@ mod tests { assert!(parse_selector("/dev/ttyACM2?input=other").is_err()); } + #[test] + fn basic_input_resolution_uses_config_until_the_cli_overrides_it() { + assert_eq!( + resolve_basic_input(None, BasicInput::High), + BasicInput::High + ); + assert_eq!( + resolve_basic_input(Some(BasicInput::Low), BasicInput::High), + BasicInput::Low + ); + assert_eq!( + resolve_basic_input(Some(BasicInput::High), BasicInput::Low), + BasicInput::High + ); + } + + #[test] + fn ultra_ignores_basic_input_and_warns_only_for_an_explicit_selector() { + let settings = TinySaSettings { + basic_input: BasicInput::High, + ..TinySaSettings::default() + }; + let (options, _) = startup_options(Model::Zs407, settings.basic_input, &settings).unwrap(); + let saved = persisted_settings(&settings, &options, Some(settings.basic_input)).unwrap(); + assert_eq!(saved.basic_input, BasicInput::High); + assert_eq!( + input_mode_command(Model::Zs407, BasicInput::High), + "mode input" + ); + assert!(ignored_explicit_basic_input_note(Model::Zs407, None).is_none()); + for input in [BasicInput::Low, BasicInput::High] { + let note = ignored_explicit_basic_input_note(Model::Zs407, Some(input)).unwrap(); + assert!(note.contains(&format!( + "explicit Basic {} input selection was ignored", + input.label() + ))); + } + assert!(ignored_explicit_basic_input_note(Model::Basic, Some(BasicInput::High)).is_none()); + } + #[test] fn only_an_explicit_selector_overrides_the_basic_input() { let bare = list(Some("/dev/ttyACM2")); @@ -2361,7 +2598,7 @@ mod tests { rbw: "0.2".into(), ..TinySaSettings::default() }; - let (options, _) = startup_options(Model::Zs405, &settings).unwrap(); + let (options, _) = startup_options(Model::Zs405, BasicInput::Low, &settings).unwrap(); let segment = Segment { start_hz: 400_000_000, stop_hz: 500_000_000, @@ -2370,13 +2607,15 @@ mod tests { assert_eq!(current_rbw_hz(&options), Some(200)); let timeout = scan_inactivity_timeout(segment, Model::Zs405, &options).unwrap(); assert!(timeout > Duration::from_secs(120), "{timeout:?}"); - let (automatic, _) = startup_options(Model::Zs405, &TinySaSettings::default()).unwrap(); + let (automatic, _) = + startup_options(Model::Zs405, BasicInput::Low, &TinySaSettings::default()).unwrap(); assert_eq!(current_rbw_hz(&automatic), None); } #[test] fn explicit_rbw_updates_the_next_trace_metadata_and_timeout() { - let (mut options, _) = startup_options(Model::Zs405, &TinySaSettings::default()).unwrap(); + let (mut options, _) = + startup_options(Model::Zs405, BasicInput::Low, &TinySaSettings::default()).unwrap(); let segment = Segment { start_hz: 400_000_000, stop_hz: 500_000_000, @@ -2403,6 +2642,7 @@ mod tests { let device = TinySaDevice::open( Path::new(&path), BasicInput::Low, + None, &TinySaSettings::default(), ) .unwrap(); diff --git a/user_docs/config.md b/user_docs/config.md index e2975102..89ac52bb 100644 --- a/user_docs/config.md +++ b/user_docs/config.md @@ -61,9 +61,11 @@ stop_hz = 500000000 # scanner band end dwell_ms = 200 # measure time per step (50–2000) [tinysa] +basic_input = "low" # Basic only: low or high points = 450 # 64, 128, 290, 450, 900 or 1800 rbw = "auto" # resolution bandwidth in kHz -attenuation = "auto" # auto or 0–31 dB +attenuation = "auto" # Basic LOW and Ultra: auto or 0–31 dB +high_attenuation = false # Basic HIGH coarse attenuation lna = false # Ultra only spur = "auto" # Basic uses on/off; Ultra also accepts auto ext_gain_db = 0 # -100–100 dB @@ -136,12 +138,29 @@ a safe input baseline. The Options pane updates these values at runtime. They are saved from the analyzer's current state on quit. Other backends preserve the block unchanged. +`basic_input` selects the Basic model's physical connector for the full session. +It accepts `low` from 100 kHz to 350 MHz or `high` from 240 MHz to 959 MHz. +This is a startup-only setting. Restart sdrtop after editing it. An explicit +`?input=low` or `?input=high` in `--device` takes priority for that session. A +bare `--device tinysa` or `--device tinysa=PATH` uses `basic_input`. + +Basic LOW and Ultra use `attenuation`. Its choices are `auto` or 0–31 dB. Basic +HIGH uses `high_attenuation`. `false` sends `attenuate 0`. `true` sends +`attenuate 1`, which enables the firmware's frequency-dependent coarse +attenuation of roughly 25–40 dB. Each connector's setting is saved separately. + +Ultra firmware always selects its input automatically. `basic_input` remains +unchanged after an Ultra session. An explicit Basic input in `--device` is +ignored on Ultra and produces a note in the startup log. + Basic analyzers convert `spur = "auto"` to `"on"`. They also convert Ultra-only RBW values `0.2`, `1` and `850` to `"auto"`. The Ultra-only LNA value stays in -the file when a Basic analyzer is used. Legacy `lna2` and `agc` values are -accepted and preserved without being applied. Verified ZS405 firmware reloads -its internal LNA2 and AGC values before every scan. Other invalid values are -reported when a tinySA opens. +the file when a Basic analyzer is used. + +Legacy `lna2` and `agc` fields remain readable and survive config saves. Basic +sessions ignore them. Ultra sessions accept only `"auto"`. A manual value stops +the open with an error because Ultra firmware overwrites LNA2 and AGC before +every scan. Other invalid values are reported when a tinySA opens. --- diff --git a/user_docs/hardware.md b/user_docs/hardware.md index b043d6e5..edf42fb5 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -45,16 +45,18 @@ running `tinySA4_v1.4-236-ge5aa115`. The device supplies swept power readings without IQ samples. sdrtop offers the spectrum, waterfall, full band sweep and micro sweep layouts. The Options pane -controls scan points, RBW, attenuation, spur removal and external gain. Ultra -models also expose the external front-end LNA. Scan points are a host-side -setting. The other controls use the matching tinySA console commands. LNA2 and -AGC are hidden because the verified ZS405 firmware reloads its internal values -before every scan. +controls scan points, RBW, attenuation, spur removal and external gain. Basic +LOW and Ultra offer `Attenuation (dB)` with `auto` and 0–31 dB choices. Basic +HIGH offers `Coarse attenuation` with `off` and `on` choices. `on` asks the +firmware for frequency-dependent attenuation of roughly 25–40 dB. Ultra models +also expose the external front-end LNA. Scan points are a host-side setting. +The other controls use the matching tinySA console commands. sdrtop disables output and aborts pending work during startup. It then forces input mode and applies a safe automatic baseline before restoring `[tinysa]` -settings. Basic input-path changes restore the active RBW, attenuation, spur and -external gain settings after the firmware resets them. +settings. Basic HIGH uses a truthful coarse attenuation baseline of `off`. +Recovery restores the active connector's attenuation setting. LOW numeric +attenuation and HIGH coarse attenuation are saved independently. ```sh sdrtop --device tinysa @@ -64,10 +66,25 @@ 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. +present or discovery cannot inspect sysfs. A Basic tinySA uses the persisted +`[tinysa].basic_input` value. The default is LOW from 100 kHz to 350 MHz. HIGH +covers 240 MHz to 959 MHz. The supported firmware defines 959 MHz as the HIGH +limit. `?input=low` or `?input=high` overrides the config for one session. A bare +`--device tinysa` or `tinysa=PATH` uses the config value. + +Connector selection happens only during startup. Restart sdrtop to use an edited +config value. One Basic session stays on one connector. Quitting after a Basic +session saves the connector that session used. This includes a CLI override. + +Ultra firmware always uses automatic input selection. The Basic connector value +does not affect an Ultra open and remains saved for the next Basic session. An +explicit `?input=low` or `?input=high` is ignored on Ultra. The startup log says +that it was ignored. + +Legacy `lna2` and `agc` config fields remain readable and survive saves. They are +not shown or applied. Basic sessions ignore manual values. Ultra requires +`"auto"` because its firmware overwrites both controls before every scan. A +manual Ultra value produces an error during open. > **RTL clones vary.** Different tuners, different gain tables, different quirks, > and no single person owns them all. If yours behaves oddly, please From 169f8def719fa8fd12c69c26a6ff651cbc13d5a3 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 19:41:58 +0200 Subject: [PATCH 4/6] docs: clarify tinySA input persistence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- user_docs/config.md | 2 +- user_docs/hardware.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/user_docs/config.md b/user_docs/config.md index 89ac52bb..fc3408fc 100644 --- a/user_docs/config.md +++ b/user_docs/config.md @@ -141,7 +141,7 @@ block unchanged. `basic_input` selects the Basic model's physical connector for the full session. It accepts `low` from 100 kHz to 350 MHz or `high` from 240 MHz to 959 MHz. This is a startup-only setting. Restart sdrtop after editing it. An explicit -`?input=low` or `?input=high` in `--device` takes priority for that session. A +`?input=low` or `?input=high` in `--device` takes priority at startup. A bare `--device tinysa` or `--device tinysa=PATH` uses `basic_input`. Basic LOW and Ultra use `attenuation`. Its choices are `auto` or 0–31 dB. Basic diff --git a/user_docs/hardware.md b/user_docs/hardware.md index edf42fb5..44a13198 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -69,7 +69,7 @@ Automatic discovery recognizes the official USB CDC identity on present or discovery cannot inspect sysfs. A Basic tinySA uses the persisted `[tinysa].basic_input` value. The default is LOW from 100 kHz to 350 MHz. HIGH covers 240 MHz to 959 MHz. The supported firmware defines 959 MHz as the HIGH -limit. `?input=low` or `?input=high` overrides the config for one session. A bare +limit. `?input=low` or `?input=high` overrides the config at startup. A bare `--device tinysa` or `tinysa=PATH` uses the config value. Connector selection happens only during startup. Restart sdrtop to use an edited From 2f93c3a08d0eaf81e5bb143a6efc700cf0fb2240 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 19:51:14 +0200 Subject: [PATCH 5/6] fix: force manual tinySA attenuation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/hardware/tinysa/mod.rs | 220 +++++++++++++++++++++++++++---------- 1 file changed, 164 insertions(+), 56 deletions(-) diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index aa4d4c13..61042a31 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -714,7 +714,7 @@ fn initialize( send_setter_command(port, input_mode_command(identity.model, basic_input))?; send_setter_command(port, "abort on")?; for command in baseline_commands(identity.model, basic_input) { - send_setter_command(port, command)?; + send_setter_command(port, &command)?; } for command in saved_commands { send_setter_command(port, &command)?; @@ -1341,9 +1341,7 @@ fn startup_options( let mut commands = Vec::new(); for (id, choice) in values { let option_index = validate_option_choice(&options, id, &choice)?; - if let Some(command) = option_command(model, &options, id, &choice)? { - commands.push(command); - } + commands.extend(option_commands(model, &options, id, &choice)?); options[option_index].selected_choice = choice; } Ok((options, commands)) @@ -1361,9 +1359,9 @@ fn validate_legacy_diagnostics(model: Model, settings: &TinySaSettings) -> anyho Ok(()) } -fn baseline_commands(model: Model, basic_input: BasicInput) -> &'static [&'static str] { +fn baseline_commands(model: Model, basic_input: BasicInput) -> Vec { if model.is_ultra() { - &[ + strings(&[ "ultra on", "ultra auto", "rbw auto", @@ -1371,11 +1369,14 @@ fn baseline_commands(model: Model, basic_input: BasicInput) -> &'static [&'stati "attenuate auto", "spur auto", "ext_gain 0", - ] + ]) } else if basic_input == BasicInput::High { - &["rbw auto", "attenuate 0", "spur on", "ext_gain 0"] + let mut commands = vec!["rbw auto".to_string()]; + commands.extend(high_attenuation_commands("off")); + commands.extend(strings(&["spur on", "ext_gain 0"])); + commands } else { - &["rbw auto", "attenuate auto", "spur on", "ext_gain 0"] + strings(&["rbw auto", "attenuate auto", "spur on", "ext_gain 0"]) } } @@ -1482,31 +1483,46 @@ fn set_selected_option(options: &mut [DeviceOption], id: &str, choice: &str) -> Ok(()) } -fn option_command( +fn option_commands( model: Model, options: &[DeviceOption], id: &str, choice: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { validate_option_choice(options, id, choice)?; - let command = match id { - "points" => return Ok(None), - "rbw" => format!("rbw {choice}"), - "attenuation" => format!("attenuate {choice}"), - "high_attenuation" => format!( - "attenuate {}", - match choice { - "off" => "0", - "on" => "1", - _ => unreachable!("validated tinySA HIGH attenuation choice"), - } - ), - "lna" if model.is_ultra() => format!("lna {choice}"), - "spur" => format!("spur {choice}"), - "ext_gain" => format!("ext_gain {choice}"), + let commands = match id { + "points" => Vec::new(), + "rbw" => vec![format!("rbw {choice}")], + "attenuation" => manual_attenuation_commands(choice), + "high_attenuation" => high_attenuation_commands(choice), + "lna" if model.is_ultra() => vec![format!("lna {choice}")], + "spur" => vec![format!("spur {choice}")], + "ext_gain" => vec![format!("ext_gain {choice}")], _ => bail!("unknown tinySA option {id}"), }; - Ok(Some(command)) + Ok(commands) +} + +fn manual_attenuation_commands(choice: &str) -> Vec { + if choice == "auto" { + return vec!["attenuate auto".to_string()]; + } + let target: u8 = choice + .parse() + .expect("validated tinySA attenuation choice must be numeric"); + let transition = if target == 0 { 1 } else { 0 }; + vec![ + format!("attenuate {transition}"), + format!("attenuate {target}"), + ] +} + +fn high_attenuation_commands(choice: &str) -> Vec { + let mut commands = vec!["attenuate 1".to_string(), "attenuate 0".to_string()]; + if choice == "on" { + commands.push("attenuate 1".to_string()); + } + commands } struct PreparedOption { @@ -1535,12 +1551,17 @@ fn prepare_option_update( bail!("tinySA sweep span is too narrow for {points} points"); } } + let lna_is_on = selected_option_value(options, "lna") == Some("on"); let mut commands = Vec::new(); if id == "lna" && choice == "on" { - commands.push("attenuate 0".to_string()); + commands.extend(manual_attenuation_commands("0")); } - if let Some(command) = option_command(model, options, id, choice)? { - commands.push(command); + if id == "attenuation" && lna_is_on { + commands.push("lna off".to_string()); + commands.extend(option_commands(model, options, id, choice)?); + commands.push("lna on".to_string()); + } else { + commands.extend(option_commands(model, options, id, choice)?); } Ok(PreparedOption { option_index, @@ -1602,9 +1623,7 @@ fn basic_option_commands(options: &[DeviceOption]) -> anyhow::Result for id in ["rbw", attenuation_id, "spur", "ext_gain"] { let choice = selected_option_value(options, id) .with_context(|| format!("tinySA {id} is missing"))?; - if let Some(command) = option_command(Model::Basic, options, id, choice)? { - commands.push(command); - } + commands.extend(option_commands(Model::Basic, options, id, choice)?); } Ok(commands) } @@ -1618,9 +1637,7 @@ fn restore_option_commands(model: Model, options: &[DeviceOption]) -> anyhow::Re for id in ["rbw", "attenuation"] { let choice = selected_option_value(options, id) .with_context(|| format!("tinySA {id} is missing"))?; - if let Some(command) = option_command(model, options, id, choice)? { - commands.push(command); - } + commands.extend(option_commands(model, options, id, choice)?); } if selected_option_value(options, "lna") == Some("on") { commands.push("lna on".to_string()); @@ -1628,9 +1645,7 @@ fn restore_option_commands(model: Model, options: &[DeviceOption]) -> anyhow::Re for id in ["spur", "ext_gain"] { let choice = selected_option_value(options, id) .with_context(|| format!("tinySA {id} is missing"))?; - if let Some(command) = option_command(model, options, id, choice)? { - commands.push(command); - } + commands.extend(option_commands(model, options, id, choice)?); } Ok(commands) } @@ -1646,7 +1661,7 @@ fn recover_option_state( send_setter_command(port, input_mode_command(model, basic_input))?; send_setter_command(port, "abort on")?; for command in baseline_commands(model, basic_input) { - send_setter_command(port, command)?; + send_setter_command(port, &command)?; } for command in restore_option_commands(model, options)? { send_setter_command(port, &command)?; @@ -1913,7 +1928,13 @@ mod tests { ); assert_eq!( baseline_commands(Model::Basic, BasicInput::High), - ["rbw auto", "attenuate 0", "spur on", "ext_gain 0"] + [ + "rbw auto", + "attenuate 1", + "attenuate 0", + "spur on", + "ext_gain 0", + ] ); let settings = TinySaSettings { @@ -1934,6 +1955,7 @@ mod tests { commands, [ "rbw 0.2", + "attenuate 1", "attenuate 0", "lna on", "spur off", @@ -2004,27 +2026,96 @@ mod tests { fn option_commands_are_whitelisted_after_choice_validation() { let basic = option_definitions(Model::Basic, BasicInput::Low); assert_eq!( - option_command(Model::Basic, &basic, "points", "450").unwrap(), - None + option_commands(Model::Basic, &basic, "points", "450").unwrap(), + Vec::::new() ); assert_eq!( - option_command(Model::Basic, &basic, "rbw", "10").unwrap(), - Some("rbw 10".into()) + option_commands(Model::Basic, &basic, "rbw", "10").unwrap(), + ["rbw 10"] ); - assert!(option_command(Model::Basic, &basic, "rbw", "10; reset").is_err()); - assert!(option_command(Model::Basic, &basic, "lna", "on").is_err()); - assert!(option_command(Model::Basic, &basic, "missing", "on").is_err()); + assert!(option_commands(Model::Basic, &basic, "rbw", "10; reset").is_err()); + assert!(option_commands(Model::Basic, &basic, "lna", "on").is_err()); + assert!(option_commands(Model::Basic, &basic, "missing", "on").is_err()); let high = option_definitions(Model::Basic, BasicInput::High); assert_eq!( - option_command(Model::Basic, &high, "high_attenuation", "off").unwrap(), - Some("attenuate 0".into()) + option_commands(Model::Basic, &high, "high_attenuation", "off").unwrap(), + ["attenuate 1", "attenuate 0"] ); assert_eq!( - option_command(Model::Basic, &high, "high_attenuation", "on").unwrap(), - Some("attenuate 1".into()) + option_commands(Model::Basic, &high, "high_attenuation", "on").unwrap(), + ["attenuate 1", "attenuate 0", "attenuate 1"] + ); + assert!(option_commands(Model::Basic, &high, "high_attenuation", "auto").is_err()); + } + + #[test] + fn manual_attenuation_plans_clear_firmware_auto_mode() { + struct FirmwareAttenuation { + value: u8, + automatic: bool, + high_input: bool, + } + + impl FirmwareAttenuation { + fn apply(&mut self, command: &str) { + let choice = command.strip_prefix("attenuate ").unwrap(); + if choice == "auto" { + self.value = if self.high_input { 0 } else { 30 }; + self.automatic = true; + return; + } + let value = choice.parse().unwrap(); + if self.value == value { + return; + } + self.value = value; + if !self.high_input || value == 0 { + self.automatic = false; + } + } + } + + for (high_input, target, commands) in [ + (false, 30, manual_attenuation_commands("30")), + (true, 0, high_attenuation_commands("off")), + (true, 1, high_attenuation_commands("on")), + ] { + let mut firmware = FirmwareAttenuation { + value: if high_input { 0 } else { 30 }, + automatic: true, + high_input, + }; + for command in commands { + firmware.apply(&command); + } + assert_eq!(firmware.value, target); + assert!(!firmware.automatic); + } + } + + #[test] + fn basic_low_startup_and_recovery_force_manual_attenuation() { + let settings = TinySaSettings { + attenuation: "30".into(), + ..TinySaSettings::default() + }; + let (options, startup) = startup_options(Model::Basic, BasicInput::Low, &settings).unwrap(); + + assert_eq!( + startup, + [ + "rbw auto", + "attenuate 0", + "attenuate 30", + "spur on", + "ext_gain 0", + ] + ); + assert_eq!( + restore_option_commands(Model::Basic, &options).unwrap(), + startup ); - assert!(option_command(Model::Basic, &high, "high_attenuation", "auto").is_err()); } #[test] @@ -2040,7 +2131,7 @@ mod tests { Ok(()) }) .unwrap(); - assert_eq!(commands, ["attenuate 0", "lna on"]); + assert_eq!(commands, ["attenuate 1", "attenuate 0", "lna on"]); assert_eq!(selected_option_value(&options, "attenuation"), Some("0")); assert!( prepare_option_update(&options, Model::Zs407, "attenuation", "auto", 10_000, None,) @@ -2054,6 +2145,14 @@ mod tests { prepare_option_update(&options, Model::Zs407, "attenuation", "0", 10_000, None,) .is_ok() ); + + let prepared = + prepare_option_update(&options, Model::Zs407, "attenuation", "0", 10_000, None) + .unwrap(); + assert_eq!( + prepared.commands, + ["lna off", "attenuate 1", "attenuate 0", "lna on",] + ); } #[test] @@ -2087,7 +2186,7 @@ mod tests { Ok(()) }); assert!(result.is_err()); - assert_eq!(commands, ["attenuate 0", "lna on"]); + assert_eq!(commands, ["attenuate 1", "attenuate 0", "lna on"]); assert_eq!(options, before); } @@ -2106,6 +2205,7 @@ mod tests { [ "lna off", "rbw 30", + "attenuate 0", "attenuate 12", "spur off", "ext_gain -7", @@ -2122,6 +2222,7 @@ mod tests { [ "lna off", "rbw 30", + "attenuate 1", "attenuate 0", "lna on", "spur off", @@ -2201,7 +2302,14 @@ mod tests { assert!(commands.iter().any(|command| command == "attenuate 1")); assert_eq!( restore_option_commands(Model::Basic, &options).unwrap(), - ["rbw auto", "attenuate 1", "spur on", "ext_gain 0"] + [ + "rbw auto", + "attenuate 1", + "attenuate 0", + "attenuate 1", + "spur on", + "ext_gain 0", + ] ); } From a25f9eb970fb54ec88f6c69df18c357052194766 Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 7 Sep 2026 19:53:09 +0200 Subject: [PATCH 6/6] fix: keep attenuation transitions safe Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/hardware/tinysa/mod.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/hardware/tinysa/mod.rs b/src/hardware/tinysa/mod.rs index 61042a31..5b14e184 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -1510,7 +1510,8 @@ fn manual_attenuation_commands(choice: &str) -> Vec { let target: u8 = choice .parse() .expect("validated tinySA attenuation choice must be numeric"); - let transition = if target == 0 { 1 } else { 0 }; + // Use a high attenuation transition because firmware ignores an equal numeric value + let transition = if target == 31 { 30 } else { 31 }; vec![ format!("attenuate {transition}"), format!("attenuate {target}"), @@ -1955,7 +1956,7 @@ mod tests { commands, [ "rbw 0.2", - "attenuate 1", + "attenuate 31", "attenuate 0", "lna on", "spur off", @@ -2078,6 +2079,7 @@ mod tests { for (high_input, target, commands) in [ (false, 30, manual_attenuation_commands("30")), + (false, 31, manual_attenuation_commands("31")), (true, 0, high_attenuation_commands("off")), (true, 1, high_attenuation_commands("on")), ] { @@ -2106,7 +2108,7 @@ mod tests { startup, [ "rbw auto", - "attenuate 0", + "attenuate 31", "attenuate 30", "spur on", "ext_gain 0", @@ -2131,7 +2133,7 @@ mod tests { Ok(()) }) .unwrap(); - assert_eq!(commands, ["attenuate 1", "attenuate 0", "lna on"]); + assert_eq!(commands, ["attenuate 31", "attenuate 0", "lna on"]); assert_eq!(selected_option_value(&options, "attenuation"), Some("0")); assert!( prepare_option_update(&options, Model::Zs407, "attenuation", "auto", 10_000, None,) @@ -2151,7 +2153,7 @@ mod tests { .unwrap(); assert_eq!( prepared.commands, - ["lna off", "attenuate 1", "attenuate 0", "lna on",] + ["lna off", "attenuate 31", "attenuate 0", "lna on",] ); } @@ -2186,7 +2188,7 @@ mod tests { Ok(()) }); assert!(result.is_err()); - assert_eq!(commands, ["attenuate 1", "attenuate 0", "lna on"]); + assert_eq!(commands, ["attenuate 31", "attenuate 0", "lna on"]); assert_eq!(options, before); } @@ -2205,7 +2207,7 @@ mod tests { [ "lna off", "rbw 30", - "attenuate 0", + "attenuate 31", "attenuate 12", "spur off", "ext_gain -7", @@ -2222,7 +2224,7 @@ mod tests { [ "lna off", "rbw 30", - "attenuate 1", + "attenuate 31", "attenuate 0", "lna on", "spur off",