Added RSSI Indicator

This commit is contained in:
KaraZajac
2026-02-17 20:12:32 -05:00
parent 0cffd71a87
commit 76451bb458
4 changed files with 158 additions and 22 deletions
+21
View File
@@ -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<Arc<AtomicU32>> {
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<String>,
/// Last status message
pub status_message: Option<String>,
/// 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<RadioEvent>,
/// RSSI read from radio atomic (no channel traffic, avoids RX blocking)
pub rssi_source: Option<Arc<AtomicU32>>,
/// 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) => {
+33 -1
View File
@@ -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<Mutex<GainSettings>>,
/// RSSI (f32 bits) written by RX callback, read by UI - never blocks
rssi_value: Arc<AtomicU32>,
}
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<AtomicU32> {
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<Mutex<Demodulator>>,
demodulator_fm: Arc<Mutex<FmDemodulator>>,
capture_id: std::sync::atomic::AtomicU32,
/// RSSI (f32 bits) written here so callback never blocks on channel
rssi_value: Arc<AtomicU32>,
}
fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec<StoredLevelDuration> {
@@ -281,6 +293,22 @@ fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec<StoredLevelDuration> {
.collect()
}
/// Compute average magnitude of IQ buffer (0..~1 for i8)
fn compute_rssi(buffer: &[num_complex::Complex<i8>]) -> 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<i8> = 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<Mutex<Demodulator>>,
demodulator_fm: Arc<Mutex<FmDemodulator>>,
gain_settings: Arc<Mutex<GainSettings>>,
rssi_value: Arc<AtomicU32>,
) -> 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,
};
+37 -1
View File
@@ -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<Mutex<FmDemodulator>>,
rtlsdr_available: bool,
gain_settings: Arc<Mutex<TunerGainSetting>>,
/// RSSI (f32 bits) written by RX thread, read by UI - never blocks
rssi_value: Arc<AtomicU32>,
}
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<AtomicU32> {
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<i8> {
.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<AtomicBool>,
@@ -243,6 +276,7 @@ fn run_receiver_rtlsdr(
demodulator_am: Arc<Mutex<Demodulator>>,
demodulator_fm: Arc<Mutex<FmDemodulator>>,
gain_settings: Arc<Mutex<TunerGainSetting>>,
rssi_value: Arc<AtomicU32>,
) -> 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);
+67 -20
View File
@@ -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 {