diff --git a/src/app/builder/mod.rs b/src/app/builder/mod.rs index be5a0820..d50ce30b 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,9 @@ impl App { theme, 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 e4b9ebce..8075c92f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -47,6 +47,9 @@ 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) tinysa_basic_input: Option, + pub(super) device_kind: hardware::DeviceKind, } impl App { @@ -55,8 +58,11 @@ impl App { config_path: Option, listing: &hardware::DeviceListing, ) -> anyhow::Result { - match hardware::open_device(listing) { - Ok(device) => Self::new_normal(cfg, config_path, device), + 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 // to read-only observer mode via the matching backend's sysfs @@ -68,9 +74,11 @@ 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) } - } + }?; + app.tinysa_basic_input = tinysa_basic_input; + Ok(app) } pub fn run(&mut self, terminal: &mut Terminal) -> io::Result<()> { @@ -104,7 +112,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(()); } } @@ -119,7 +127,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 @@ -163,10 +171,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<()> { @@ -368,12 +376,21 @@ 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 = if self.device_kind == hardware::DeviceKind::TinySa { + crate::hardware::tinysa::persisted_settings( + &self.tinysa_config, + &device.options(), + self.tinysa_basic_input, + )? + } else { + 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()); @@ -422,9 +439,10 @@ impl App { stop_hz: sweep_cfg.stop_hz, dwell_ms: sweep_cfg.dwell_ms, }, + tinysa, presets: self.user_presets.clone(), }; - let _ = cfg.save(path); + cfg.save(path) } } @@ -434,7 +452,6 @@ mod tests { use crate::state::DeviceOptionUpdate; use std::cell::Cell; use std::sync::{mpsc, Arc, Mutex}; - /// 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..4265b872 100644 --- a/src/config.rs +++ b/src/config.rs @@ -161,6 +161,56 @@ 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)] + pub basic_input: crate::hardware::tinysa::BasicInput, + #[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 high_attenuation: bool, + #[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 { + 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(), + spur: default_auto(), + ext_gain_db: 0, + } + } +} + #[derive(Deserialize, Serialize, Clone, Debug, Default)] pub struct AppConfig { #[serde(default)] @@ -171,6 +221,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 +770,51 @@ 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] + basic_input = "high" + points = 900 + rbw = "0.2" + attenuation = "12" + high_attenuation = true + 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]")); + 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"); + 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..5d5aa0ca 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)?)), @@ -260,9 +263,13 @@ pub fn open_device(listing: &DeviceListing) -> anyhow::Result 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 ecb0c840..5b14e184 100644 --- a/src/hardware/tinysa/mod.rs +++ b/src/hardware/tinysa/mod.rs @@ -12,10 +12,12 @@ 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; 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 +28,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); @@ -37,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, @@ -75,17 +79,8 @@ 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 resolve_basic_input(explicit: Option, configured: BasicInput) -> BasicInput { + explicit.unwrap_or(configured) } pub fn list(selector: Option<&str>) -> Vec { @@ -140,12 +135,19 @@ 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, + explicit_basic_input: Option, + 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 +158,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, @@ -172,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(), @@ -191,6 +210,7 @@ impl TinySaDevice { caps, info, notes, + options, command_tx, worker: Mutex::new(Some(worker)), }) @@ -247,6 +267,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 +318,11 @@ enum Command { SetSpan(f64, Sender>), NoOp(UnitReply), SetDirectSweep(Option, UnitReply), + SetOption { + id: String, + choice: String, + reply: UnitReply, + }, Shutdown(UnitReply), } @@ -287,12 +334,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 +377,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 +403,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 +460,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 +479,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 +490,7 @@ impl Worker { } Err(error) => { best_effort_abort(&mut *self.port); + self.prompt_ready = false; self.stop_acquisition(&error.to_string()); return; } @@ -455,7 +510,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 +537,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 +553,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 +585,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 +632,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 +645,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 +682,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,26 +705,21 @@ 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")?; 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, 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, basic_input) { + send_setter_command(port, &command)?; } - 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)?; + for command in saved_commands { + send_setter_command(port, &command)?; } - Ok(()) + Ok((identity, options)) } fn best_effort_abort(port: &mut dyn SerialPort) { @@ -634,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"))?; @@ -645,6 +760,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 +1071,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 +1088,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 +1104,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 +1121,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 +1233,466 @@ 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(()) +} + +pub(crate) fn persisted_settings( + loaded: &TinySaSettings, + options: &[DeviceOption], + resolved_basic_input: Option, +) -> 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(), + "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, + "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}"), + } + } + 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)?; + 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 { - SpurMode::On + &settings.rbw }; - let mut commands = Vec::new(); + 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()), + ]; + 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() { - commands.extend(["ultra on", "ultra auto"]); + values.push(("lna", if settings.lna { "on" } else { "off" }.to_string())); + } + 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)?; + commands.extend(option_commands(model, &options, id, &choice)?); + options[option_index].selected_choice = choice; } - commands.extend(["rbw auto", "attenuate auto"]); + Ok((options, commands)) +} + +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) -> Vec { if model.is_ultra() { - commands.extend(["lna off", "lna2 auto", "agc auto", "spur auto"]); + strings(&[ + "ultra on", + "ultra auto", + "rbw auto", + "lna off", + "attenuate auto", + "spur auto", + "ext_gain 0", + ]) + } else if basic_input == BasicInput::High { + let mut commands = vec!["rbw auto".to_string()]; + commands.extend(high_attenuation_commands("off")); + commands.extend(strings(&["spur on", "ext_gain 0"])); + commands + } else { + strings(&["rbw auto", "attenuate auto", "spur on", "ext_gain 0"]) + } +} + +fn option_definitions(model: Model, basic_input: BasicInput) -> 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"]) + }, + ), + ]; + if model == Model::Basic && basic_input == BasicInput::High { + options.push(option( + "high_attenuation", + "Coarse attenuation", + strings(&["off", "on"]), + )); } else { - commands.push("spur on"); + 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"]))); } - ( - 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_commands( + model: Model, + options: &[DeviceOption], + id: &str, + choice: &str, +) -> anyhow::Result> { + validate_option_choice(options, id, 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(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"); + // 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}"), + ] +} + +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 { + 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 lna_is_on = selected_option_value(options, "lna") == Some("on"); + let mut commands = Vec::new(); + if id == "lna" && choice == "on" { + commands.extend(manual_attenuation_commands("0")); + } + 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, 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(); + 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"))?; + commands.extend(option_commands(Model::Basic, options, id, choice)?); + } + 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"))?; + commands.extend(option_commands(model, options, id, choice)?); + } + if selected_option_value(options, "lna") == Some("on") { + commands.push("lna on".to_string()); + } + for id in ["spur", "ext_gain"] { + let choice = selected_option_value(options, id) + .with_context(|| format!("tinySA {id} is missing"))?; + commands.extend(option_commands(model, options, id, choice)?); + } + 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, basic_input) { + 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 +1842,556 @@ 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, BasicInput::Low); 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"] + ); + 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 + .iter() + .find(|option| option.id == "spur") + .unwrap() + .choices, + ["off", "on"] + ); + + 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() + .find(|option| option.id == "rbw") + .unwrap() + .choices, + ["auto", "0.2", "1", "3", "10", "30", "100", "300", "600", "850"] + ); + 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() + .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, BasicInput::Low), [ "ultra on", "ultra auto", "rbw auto", - "attenuate auto", "lna off", - "lna2 auto", - "agc auto", + "attenuate auto", "spur auto", + "ext_gain 0", ] ); - let (basic, commands) = startup_settings(Model::Basic); - assert_eq!(basic.spur, SpurMode::On); - assert_eq!(commands, ["rbw auto", "attenuate auto", "spur on"]); + assert_eq!( + 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 1", + "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: "auto".into(), + agc: "auto".into(), + spur: "off".into(), + ext_gain_db: -7, + }; + let (options, commands) = + startup_options(Model::Zs407, BasicInput::Low, &settings).unwrap(); + assert_eq!( + commands, + [ + "rbw 0.2", + "attenuate 31", + "attenuate 0", + "lna on", + "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, 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")); + assert!(commands.iter().any(|command| command == "spur on")); + } + + assert!(startup_options( + Model::Basic, + BasicInput::Low, + &TinySaSettings { + attenuation: "32".into(), + ..TinySaSettings::default() + }, + ) + .is_err()); + assert!(startup_options( + Model::Basic, + BasicInput::Low, + &TinySaSettings { + rbw: "2".into(), + ..TinySaSettings::default() + }, + ) + .is_err()); + assert!(startup_options( + Model::Basic, + BasicInput::Low, + &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, BasicInput::Low); + assert_eq!( + option_commands(Model::Basic, &basic, "points", "450").unwrap(), + Vec::::new() + ); + assert_eq!( + option_commands(Model::Basic, &basic, "rbw", "10").unwrap(), + ["rbw 10"] + ); + 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_commands(Model::Basic, &high, "high_attenuation", "off").unwrap(), + ["attenuate 1", "attenuate 0"] + ); + assert_eq!( + 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")), + (false, 31, manual_attenuation_commands("31")), + (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 31", + "attenuate 30", + "spur on", + "ext_gain 0", + ] + ); + assert_eq!( + restore_option_commands(Model::Basic, &options).unwrap(), + startup + ); + } + + #[test] + fn lna_forces_zero_attenuation_and_blocks_other_values() { + 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(); + 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 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,) + .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() + ); + + let prepared = + prepare_option_update(&options, Model::Zs407, "attenuation", "0", 10_000, None) + .unwrap(); + assert_eq!( + prepared.commands, + ["lna off", "attenuate 31", "attenuate 0", "lna on",] + ); + } + + #[test] + fn failed_setter_commands_do_not_change_option_state() { + 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(); + 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, BasicInput::Low, &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 31", "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(), + spur: "off".into(), + ext_gain_db: -7, + ..TinySaSettings::default() + }; + let (options, _) = startup_options(Model::Zs407, BasicInput::Low, &settings).unwrap(); + assert_eq!( + restore_option_commands(Model::Zs407, &options).unwrap(), + [ + "lna off", + "rbw 30", + "attenuate 31", + "attenuate 12", + "spur off", + "ext_gain -7", + ] + ); + + let lna_settings = TinySaSettings { + lna: true, + ..settings + }; + let (options, _) = startup_options(Model::Zs407, BasicInput::Low, &lna_settings).unwrap(); + assert_eq!( + restore_option_commands(Model::Zs407, &options).unwrap(), + [ + "lna off", + "rbw 30", + "attenuate 31", + "attenuate 0", + "lna on", + "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, BasicInput::Low, &TinySaSettings::default()).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, Some(BasicInput::High)).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 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", + "attenuate 0", + "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 { + lna: true, + lna2: "5".into(), + agc: "4".into(), + ..TinySaSettings::default() + }; + 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"); + 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, BasicInput::Low); + options + .iter_mut() + .find(|option| option.id == id) + .unwrap() + .selected_choice = "invalid".into(); + + assert!(persisted_settings( + &TinySaSettings::default(), + &options, + Some(BasicInput::Low) + ) + .is_err()); + } + } + + #[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 +2411,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 +2510,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 +2519,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::Low + BasicInput::Low, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1456,7 +2529,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::Low + BasicInput::Low, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1466,7 +2540,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::High + BasicInput::High, + 450, ) .is_ok()); assert!(validate_direct_sweep( @@ -1476,7 +2551,8 @@ mod tests { ..valid }), Model::Basic, - BasicInput::High + BasicInput::High, + 450, ) .is_err()); assert!(validate_direct_sweep( @@ -1486,9 +2562,40 @@ 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, BasicInput::Low, &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] @@ -1533,6 +2640,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")); @@ -1557,18 +2704,41 @@ 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, BasicInput::Low, &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, 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, BasicInput::Low, &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 +2749,13 @@ 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, + None, + &TinySaSettings::default(), + ) + .unwrap(); assert!(device .info() .board_name @@ -1607,6 +2783,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..fc3408fc 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] +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" # 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 ``` Each waterfall cell shows two rows of history, so `waterfall_max_rows` is twice @@ -123,6 +133,35 @@ 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_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 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 +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` 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. + --- ## Runtime input: frequency and sample rate diff --git a/user_docs/hardware.md b/user_docs/hardware.md index f80adf5c..44a13198 100644 --- a/user_docs/hardware.md +++ b/user_docs/hardware.md @@ -44,8 +44,19 @@ 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. 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 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 @@ -55,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 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 +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