Added RSSI Indicator
This commit is contained in:
+21
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user