diff --git a/src/app.rs b/src/app.rs index 64dae7e..68a2229 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,9 @@ //! Application state management. use anyhow::Result; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; use crate::capture::{ButtonCommand, Capture}; use crate::protocols::ProtocolRegistry; @@ -85,6 +87,14 @@ impl RadioDevice { RadioDevice::RtlSdr(_) => "RTL-SDR (RX only)", } } + + /// Shared atomic for RSSI (UI reads so RX never blocks on channel). None if no radio. + pub fn rssi_source(&self) -> Option> { + match self { + RadioDevice::HackRf(h) => Some(h.rssi_source()), + RadioDevice::RtlSdr(r) => Some(r.rssi_source()), + } + } } /// Input mode for the application @@ -279,6 +289,8 @@ pub struct App { pub last_error: Option, /// Last status message pub status_message: Option, + /// Latest RSSI (average magnitude, 0..~1) from receiver + pub rssi: f32, // -- Signal action menu state -- /// Currently selected signal menu item index @@ -307,6 +319,8 @@ pub struct App { /// Sender for radio events (cloned to radio thread) #[allow(dead_code)] radio_event_tx: Sender, + /// RSSI read from radio atomic (no channel traffic, avoids RX blocking) + pub rssi_source: Option>, /// Set by :q / :quit so the main loop can exit cleanly (terminal cleanup) pub quit_requested: bool, @@ -400,6 +414,8 @@ impl App { InputMode::Normal }; + let rssi_source = radio.as_ref().and_then(|r| r.rssi_source()); + Ok(Self { input_mode: initial_mode, command_input: String::new(), @@ -413,6 +429,7 @@ impl App { radio_state, last_error: None, status_message: None, + rssi: 0.0, signal_menu_index: 0, overlay_scroll: 0, settings_field_index: 0, @@ -423,6 +440,7 @@ impl App { radio, radio_event_rx, radio_event_tx, + rssi_source, quit_requested: false, pending_fob_files, export_capture_id: None, @@ -899,6 +917,9 @@ impl App { /// Process pending radio events pub fn process_radio_events(&mut self) -> Result<()> { + if let Some(ref rssi_arc) = self.rssi_source { + self.rssi = f32::from_bits(rssi_arc.load(Ordering::Relaxed)); + } while let Ok(event) = self.radio_event_rx.try_recv() { match event { RadioEvent::SignalCaptured(mut capture) => { diff --git a/src/radio/hackrf.rs b/src/radio/hackrf.rs index daa4cc6..71cbf98 100644 --- a/src/radio/hackrf.rs +++ b/src/radio/hackrf.rs @@ -7,7 +7,7 @@ use anyhow::Result; use std::sync::mpsc::Sender; use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU32, Ordering}, Arc, Mutex, }; use std::thread::{self, JoinHandle}; @@ -58,6 +58,8 @@ pub struct HackRfController { hackrf_available: bool, /// Shared gain settings (read by receiver thread) gain_settings: Arc>, + /// RSSI (f32 bits) written by RX callback, read by UI - never blocks + rssi_value: Arc, } impl HackRfController { @@ -84,9 +86,15 @@ impl HackRfController { demodulator_fm: Arc::new(Mutex::new(demodulator_fm)), hackrf_available, gain_settings: Arc::new(Mutex::new(GainSettings::default())), + rssi_value: Arc::new(AtomicU32::new(0)), }) } + /// Shared atomic for RSSI (f32::to_bits); UI reads so callback never blocks on channel. + pub fn rssi_source(&self) -> Arc { + self.rssi_value.clone() + } + /// Check if HackRF is available #[allow(dead_code)] pub fn is_available(&self) -> bool { @@ -114,6 +122,7 @@ impl HackRfController { let demodulator_fm = self.demodulator_fm.clone(); let hackrf_available = self.hackrf_available; let gain_settings = self.gain_settings.clone(); + let rssi_value = self.rssi_value.clone(); self.rx_thread = Some(thread::spawn(move || { if hackrf_available { @@ -124,6 +133,7 @@ impl HackRfController { demodulator_am, demodulator_fm, gain_settings, + rssi_value, ) { let _ = event_tx.send(RadioEvent::Error(format!("Receiver error: {}", e))); @@ -269,6 +279,8 @@ struct RxState { demodulator_am: Arc>, demodulator_fm: Arc>, capture_id: std::sync::atomic::AtomicU32, + /// RSSI (f32 bits) written here so callback never blocks on channel + rssi_value: Arc, } fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec { @@ -281,6 +293,22 @@ fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec { .collect() } +/// Compute average magnitude of IQ buffer (0..~1 for i8) +fn compute_rssi(buffer: &[num_complex::Complex]) -> f32 { + if buffer.is_empty() { + return 0.0; + } + let sum_mag: f32 = buffer + .iter() + .map(|c| { + let i = c.re as f32 / 128.0; + let q = c.im as f32 / 128.0; + (i * i + q * q).sqrt() + }) + .sum(); + sum_mag / buffer.len() as f32 +} + /// RX callback: feed same IQ to AM and FM demodulators; emit a capture per path when signal complete. fn rx_callback( _hackrf: &libhackrf::HackRf, @@ -297,6 +325,8 @@ fn rx_callback( let current_freq = *state.frequency.lock().unwrap(); let samples: Vec = buffer.iter().flat_map(|c| [c.re, c.im]).collect(); + state.rssi_value.store(compute_rssi(buffer).to_bits(), Ordering::Relaxed); + if let Ok(mut demod) = state.demodulator_am.lock() { if let Some(pairs) = demod.process_samples(&samples) { let id = state.capture_id.fetch_add(1, Ordering::SeqCst); @@ -331,6 +361,7 @@ fn run_receiver_hackrf( demodulator_am: Arc>, demodulator_fm: Arc>, gain_settings: Arc>, + rssi_value: Arc, ) -> Result<()> { use anyhow::Context; @@ -366,6 +397,7 @@ fn run_receiver_hackrf( demodulator_am, demodulator_fm, capture_id: std::sync::atomic::AtomicU32::new(0), + rssi_value, }; diff --git a/src/radio/rtlsdr.rs b/src/radio/rtlsdr.rs index e2497be..a81af3b 100644 --- a/src/radio/rtlsdr.rs +++ b/src/radio/rtlsdr.rs @@ -6,7 +6,7 @@ use anyhow::Result; use std::sync::mpsc::Sender; use std::sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU32, Ordering}, Arc, Mutex, }; use std::thread::{self, JoinHandle}; @@ -48,6 +48,8 @@ pub struct RtlSdrController { demodulator_fm: Arc>, rtlsdr_available: bool, gain_settings: Arc>, + /// RSSI (f32 bits) written by RX thread, read by UI - never blocks + rssi_value: Arc, } impl RtlSdrController { @@ -73,9 +75,15 @@ impl RtlSdrController { demodulator_fm: Arc::new(Mutex::new(demodulator_fm)), rtlsdr_available, gain_settings: Arc::new(Mutex::new(TunerGainSetting::default())), + rssi_value: Arc::new(AtomicU32::new(0)), }) } + /// Shared atomic for RSSI (f32::to_bits); UI reads so RX thread never blocks on channel. + pub fn rssi_source(&self) -> Arc { + self.rssi_value.clone() + } + /// Returns true if an RTL-SDR device was found. pub fn is_available(&self) -> bool { self.rtlsdr_available @@ -102,6 +110,7 @@ impl RtlSdrController { let demodulator_fm = self.demodulator_fm.clone(); let rtlsdr_available = self.rtlsdr_available; let gain_settings = self.gain_settings.clone(); + let rssi_value = self.rssi_value.clone(); self.rx_thread = Some(thread::spawn(move || { if rtlsdr_available { @@ -112,6 +121,7 @@ impl RtlSdrController { demodulator_am, demodulator_fm, gain_settings, + rssi_value, ) { let _ = event_tx.send(RadioEvent::Error(format!("RTL-SDR receiver error: {}", e))); } @@ -235,6 +245,29 @@ fn u8_iq_to_i8(buf: &[u8]) -> Vec { .collect() } +/// Average magnitude of interleaved I/Q i8 samples (0..~1). +fn compute_rssi_i8(samples: &[i8]) -> f32 { + if samples.len() < 2 { + return 0.0; + } + let mut sum = 0.0f32; + let mut count = 0usize; + for chunk in samples.chunks(2) { + if chunk.len() < 2 { + break; + } + let i = chunk[0] as f32 / 128.0; + let q = chunk[1] as f32 / 128.0; + sum += (i * i + q * q).sqrt(); + count += 1; + } + if count == 0 { + 0.0 + } else { + sum / count as f32 + } +} + /// Run the receiver loop with an RTL-SDR device. fn run_receiver_rtlsdr( receiving: Arc, @@ -243,6 +276,7 @@ fn run_receiver_rtlsdr( demodulator_am: Arc>, demodulator_fm: Arc>, gain_settings: Arc>, + rssi_value: Arc, ) -> Result<()> { use anyhow::Context; @@ -278,6 +312,8 @@ fn run_receiver_rtlsdr( let current_freq = *frequency.lock().unwrap(); let samples = u8_iq_to_i8(&buf[..n]); + rssi_value.store(compute_rssi_i8(&samples).to_bits(), Ordering::Relaxed); + if let Ok(mut demod) = demodulator_am.lock() { if let Some(pairs) = demod.process_samples(&samples) { let id = capture_id.fetch_add(1, Ordering::SeqCst); diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 9e925dd..a0c9cd9 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; @@ -21,52 +21,65 @@ use crate::app::InputMode as IM; const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// RSSI bar width (right side) +const RSSI_BAR_WIDTH: u16 = 5; + /// Draw the entire UI pub fn draw_ui(frame: &mut Frame, app: &App) { let show_settings = matches!(app.input_mode, IM::SettingsSelect | IM::SettingsEdit); let show_command = app.input_mode == IM::Command; - let mut constraints = vec![Constraint::Length(3)]; // Header - + // Full-width rows: header, settings (optional), then middle row split into [captures | RX bar], status, command (optional), help + let main_area = frame.area(); + let mut v_constraints = vec![ + Constraint::Length(3), // Header (full width) + Constraint::Min(8), // Middle: captures + RX bar + Constraint::Length(3), // Status bar (full width) + Constraint::Length(1), // Help bar (full width) + ]; if show_settings { - constraints.push(Constraint::Length(3)); // Settings tabs + v_constraints.insert(1, Constraint::Length(3)); // Settings tabs (full width) } - - constraints.push(Constraint::Min(8)); // Captures list - constraints.push(Constraint::Length(3)); // Status bar - if show_command { - constraints.push(Constraint::Length(3)); // Command input + v_constraints.insert(v_constraints.len() - 1, Constraint::Length(3)); // Command (full width) } - constraints.push(Constraint::Length(1)); // Help bar - - let chunks = Layout::default() + let rows = Layout::default() .direction(Direction::Vertical) - .constraints(constraints) - .split(frame.area()); + .constraints(v_constraints) + .split(main_area); let mut idx = 0; - render_header(frame, chunks[idx], app); + render_header(frame, rows[idx], app); idx += 1; if show_settings { - render_settings_tabs(frame, chunks[idx], app); + render_settings_tabs(frame, rows[idx], app); idx += 1; } - render_captures_list(frame, chunks[idx], app); + // Only the middle row is split: captures (left) | RX bar (right) + let middle_row = rows[idx]; + let h_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(RSSI_BAR_WIDTH)]) + .split(middle_row); + let captures_area = h_chunks[0]; + let rssi_bar_rect = h_chunks[1]; idx += 1; - render_status_bar(frame, chunks[idx], app); + render_captures_list(frame, captures_area, app); + render_rssi_bar(frame, rssi_bar_rect, app); + + render_status_bar(frame, rows[idx], app); idx += 1; if show_command { - render_command_line(frame, chunks[idx], app); + render_command_line(frame, rows[idx], app); idx += 1; } - render_help_bar(frame, chunks[idx], app); + render_help_bar(frame, rows[idx], app); // Overlay widgets (rendered on top of everything else) if app.input_mode == InputMode::SignalMenu { @@ -111,6 +124,40 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { } } +/// Render the RSSI bar on the right (vertical bar, bottom = strong). +fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) { + let block = Block::default() + .borders(Borders::ALL) + .title(" RX "); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width == 0 || inner.height == 0 { + return; + } + + // Normalize RSSI (0..~1) to fill ratio; scale so ~0.5 magnitude ≈ full bar + let fill_ratio = (app.rssi / 0.6).min(1.0); + let filled_rows = (inner.height as f32 * fill_ratio).round() as u16; + + let filled_style = Style::default().fg(Color::Green); + let empty_style = Style::default().fg(Color::DarkGray); + + let mut lines = Vec::with_capacity(inner.height as usize); + for r in 0..inner.height { + let fill = r >= inner.height.saturating_sub(filled_rows); + let (style, ch) = if fill { + (filled_style, "█") + } else { + (empty_style, " ") + }; + let s = ch.repeat(inner.width as usize); + lines.push(Line::from(Span::styled(s, style))); + } + let paragraph = Paragraph::new(Text::from(lines)); + frame.render_widget(paragraph, inner); +} + /// Render the header with title and radio status fn render_header(frame: &mut Frame, area: Rect, app: &App) { let (status_symbol, status_style) = match app.radio_state {