Working changes

This commit is contained in:
leviathan
2026-02-13 18:10:29 -05:00
parent 9e2565c1fc
commit 04b1d91d78
15 changed files with 587 additions and 162 deletions
+171 -13
View File
@@ -1,19 +1,14 @@
//! AM/OOK demodulator for extracting level+duration pairs from raw IQ samples.
//! AM/OOK and FM/2FSK demodulators for extracting level+duration pairs from raw IQ samples.
//!
//! KAT uses **AM (envelope) detection only**; FM/2FSK is not demodulated. Protocols
//! are tagged with AM/FM/Both (from ProtoPirate) for display and export. FM protocols
//! may still decode when the received signal is strong enough to produce a usable
//! envelope; proper FM support would require a separate demodulator path.
//! Two demodulators run in parallel on the same IQ stream:
//! - **Demodulator** (AM): envelope detection → level/duration pairs for OOK/AM protocols.
//! - **FmDemodulator** (FM): phase discriminator → instantaneous frequency → level/duration for 2FSK.
//!
//! This demodulator converts raw IQ samples into a stream of (level, duration_us) pairs
//! that can be processed by protocol decoders, similar to how the Flipper Zero SubGHz
//! system works.
//! Protocols are tagged AM/FM/Both (ProtoPirate). Captures record which path produced them
//! (`received_rf`). Decoders and encoders are agnostic; TX remains OOK/AM.
//!
//! Key design decisions for HackRF (vs Flipper's CC1101 hardware slicer):
//! - Adaptive threshold with hysteresis to prevent chattering at decision boundary
//! - Debounce mechanism to reject noise spikes shorter than min_duration_us
//! - Magnitude smoothing (EMA) to reduce per-sample noise
//! - Fast initial threshold adaptation, slower during steady-state reception
//! AM key design: adaptive threshold, hysteresis, debounce, transition-based threshold updates.
//! FM key design: phase diff → freq (Hz), EMA smoothing, zero-centered threshold with hysteresis.
/// A single level+duration pair representing one segment of the signal
#[derive(Debug, Clone, Copy)]
@@ -359,6 +354,169 @@ impl Demodulator {
}
}
// ─── FM / 2FSK demodulator (phase discriminator) ─────────────────────────────
/// FM/2FSK demodulator: instantaneous frequency from phase difference → level/duration pairs.
/// Uses same debounce and gap-detection logic as AM so protocol decoders see a consistent stream.
pub struct FmDemodulator {
sample_rate: u32,
samples_per_us: f64,
/// Previous I,Q (normalized) for phase-diff
prev_i: f32,
prev_q: f32,
/// Have we seen at least one previous sample?
have_prev: bool,
/// EMA of instantaneous frequency (Hz)
freq_smooth: f32,
/// Threshold (Hz): above = high, below = low. Zero for symmetric 2FSK.
threshold: f32,
hysteresis: f32,
current_level: bool,
level_sample_count: u64,
in_transition: bool,
pending_level: bool,
pending_count: u64,
pairs: Vec<LevelDuration>,
min_duration_us: u32,
max_gap_us: u32,
samples_since_edge: u64,
}
impl FmDemodulator {
/// Create a new FM demodulator. Hysteresis in Hz (e.g. 3001000 for keyfob 2FSK).
pub fn new(sample_rate: u32) -> Self {
Self {
sample_rate,
samples_per_us: sample_rate as f64 / 1_000_000.0,
prev_i: 0.0,
prev_q: 0.0,
have_prev: false,
freq_smooth: 0.0,
threshold: 0.0,
hysteresis: 500.0, // Hz; keyfob 2FSK deviation typically a few kHz
current_level: false,
level_sample_count: 0,
in_transition: false,
pending_level: false,
pending_count: 0,
pairs: Vec::with_capacity(2048),
min_duration_us: 40,
max_gap_us: 20_000,
samples_since_edge: 0,
}
}
/// Process raw IQ samples; returns `Some(pairs)` when a complete signal is detected (gap).
pub fn process_samples(&mut self, samples: &[i8]) -> Option<Vec<LevelDuration>> {
let two_pi = std::f32::consts::TAU;
let rad_to_hz = self.sample_rate as f32 / two_pi;
for chunk in samples.chunks(2) {
if chunk.len() < 2 {
continue;
}
let i = chunk[0] as f32 / 128.0;
let q = chunk[1] as f32 / 128.0;
if !self.have_prev {
self.prev_i = i;
self.prev_q = q;
self.have_prev = true;
continue;
}
// Phase difference: atan2(Im(c*conj(c_prev)), Re(c*conj(c_prev)))
let re = i * self.prev_i + q * self.prev_q;
let im = q * self.prev_i - i * self.prev_q;
let phase_diff = im.atan2(re);
self.prev_i = i;
self.prev_q = q;
// Instantaneous frequency (Hz)
let freq_hz = phase_diff * rad_to_hz;
// EMA smoothing (alpha ≈ 0.1)
self.freq_smooth = self.freq_smooth * 0.9 + freq_hz * 0.1;
let is_high = if self.current_level {
self.freq_smooth > (self.threshold - self.hysteresis)
} else {
self.freq_smooth > (self.threshold + self.hysteresis)
};
if self.in_transition {
if is_high == self.pending_level {
self.pending_count += 1;
let pending_us = (self.pending_count as f64 / self.samples_per_us) as u32;
if pending_us >= self.min_duration_us {
let duration_us =
(self.level_sample_count as f64 / self.samples_per_us) as u32;
if duration_us >= self.min_duration_us {
self.pairs.push(LevelDuration::new(self.current_level, duration_us));
}
self.samples_since_edge = 0;
self.current_level = self.pending_level;
self.level_sample_count = self.pending_count;
self.in_transition = false;
}
} else {
self.level_sample_count += self.pending_count + 1;
self.in_transition = false;
}
} else if is_high != self.current_level && self.level_sample_count > 0 {
self.in_transition = true;
self.pending_level = is_high;
self.pending_count = 1;
} else {
self.level_sample_count += 1;
self.samples_since_edge += 1;
}
}
let gap_samples = (self.max_gap_us as f64 * self.samples_per_us) as u64;
if !self.pairs.is_empty() && self.samples_since_edge > gap_samples {
if self.in_transition {
let duration_us =
(self.level_sample_count as f64 / self.samples_per_us) as u32;
if duration_us >= self.min_duration_us {
self.pairs.push(LevelDuration::new(self.current_level, duration_us));
}
self.level_sample_count = self.pending_count;
self.current_level = self.pending_level;
self.in_transition = false;
}
let duration_us =
(self.level_sample_count as f64 / self.samples_per_us) as u32;
if duration_us >= self.min_duration_us {
self.pairs.push(LevelDuration::new(self.current_level, duration_us));
}
let result = std::mem::take(&mut self.pairs);
self.fm_reset_state();
if result.len() >= 10 {
return Some(result);
}
}
if self.pairs.len() > 4096 {
self.fm_reset_state();
}
None
}
fn fm_reset_state(&mut self) {
self.pairs.clear();
self.level_sample_count = 0;
self.samples_since_edge = 0;
self.current_level = false;
self.in_transition = false;
self.pending_level = false;
self.pending_count = 0;
}
}
// Note: duration_diff macro is defined in protocols/mod.rs
#[cfg(test)]
+59 -39
View File
@@ -13,9 +13,10 @@ use std::sync::{
use std::thread::{self, JoinHandle};
use crate::app::RadioEvent;
use crate::capture::Capture;
use crate::capture::{Capture, RfModulation, StoredLevelDuration};
use super::demodulator::Demodulator;
use super::demodulator::FmDemodulator;
use super::demodulator::LevelDuration;
/// Sample rate for HackRF (2 MHz is good for keyfob signals)
@@ -49,8 +50,10 @@ pub struct HackRfController {
rx_thread: Option<JoinHandle<()>>,
/// Current frequency
frequency: Arc<Mutex<u32>>,
/// Demodulator for processing samples
demodulator: Arc<Mutex<Demodulator>>,
/// AM/OOK demodulator
demodulator_am: Arc<Mutex<Demodulator>>,
/// FM/2FSK demodulator
demodulator_fm: Arc<Mutex<FmDemodulator>>,
/// Whether HackRF is available
hackrf_available: bool,
/// Shared gain settings (read by receiver thread)
@@ -60,7 +63,8 @@ pub struct HackRfController {
impl HackRfController {
/// Create a new HackRF controller
pub fn new(event_tx: Sender<RadioEvent>) -> Result<Self> {
let demodulator = Demodulator::new(SAMPLE_RATE);
let demodulator_am = Demodulator::new(SAMPLE_RATE);
let demodulator_fm = FmDemodulator::new(SAMPLE_RATE);
// Check if HackRF is available
let hackrf_available = check_hackrf_available();
@@ -76,7 +80,8 @@ impl HackRfController {
receiving: Arc::new(AtomicBool::new(false)),
rx_thread: None,
frequency: Arc::new(Mutex::new(433_920_000)),
demodulator: Arc::new(Mutex::new(demodulator)),
demodulator_am: Arc::new(Mutex::new(demodulator_am)),
demodulator_fm: Arc::new(Mutex::new(demodulator_fm)),
hackrf_available,
gain_settings: Arc::new(Mutex::new(GainSettings::default())),
})
@@ -100,14 +105,21 @@ impl HackRfController {
let receiving = self.receiving.clone();
let event_tx = self.event_tx.clone();
let freq = self.frequency.clone();
let demodulator = self.demodulator.clone();
let demodulator_am = self.demodulator_am.clone();
let demodulator_fm = self.demodulator_fm.clone();
let hackrf_available = self.hackrf_available;
let gain_settings = self.gain_settings.clone();
self.rx_thread = Some(thread::spawn(move || {
if hackrf_available {
if let Err(e) =
run_receiver_hackrf(receiving.clone(), event_tx.clone(), freq, demodulator, gain_settings)
if let Err(e) = run_receiver_hackrf(
receiving.clone(),
event_tx.clone(),
freq,
demodulator_am,
demodulator_fm,
gain_settings,
)
{
let _ = event_tx.send(RadioEvent::Error(format!("Receiver error: {}", e)));
}
@@ -249,46 +261,58 @@ struct RxState {
receiving: Arc<AtomicBool>,
event_tx: Sender<RadioEvent>,
frequency: Arc<Mutex<u32>>,
demodulator: Arc<Mutex<Demodulator>>,
demodulator_am: Arc<Mutex<Demodulator>>,
demodulator_fm: Arc<Mutex<FmDemodulator>>,
capture_id: std::sync::atomic::AtomicU32,
}
/// RX callback function for libhackrf
fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec<StoredLevelDuration> {
pairs
.iter()
.map(|p| StoredLevelDuration {
level: p.level,
duration_us: p.duration_us,
})
.collect()
}
/// RX callback: feed same IQ to AM and FM demodulators; emit a capture per path when signal complete.
fn rx_callback(
_hackrf: &libhackrf::HackRf,
buffer: &[num_complex::Complex<i8>],
user_data: &dyn std::any::Any,
) {
use crate::capture::StoredLevelDuration;
// Downcast user_data to our state
let state = match user_data.downcast_ref::<RxState>() {
Some(s) => s,
None => return,
};
if !state.receiving.load(Ordering::SeqCst) {
return;
}
let current_freq = *state.frequency.lock().unwrap();
let samples: Vec<i8> = buffer.iter().flat_map(|c| [c.re, c.im]).collect();
// Convert Complex<i8> samples to i8 pairs for demodulator
let samples: Vec<i8> = buffer.iter()
.flat_map(|c| [c.re, c.im])
.collect();
// Process through demodulator
if let Ok(mut demod) = state.demodulator.lock() {
if let Ok(mut demod) = state.demodulator_am.lock() {
if let Some(pairs) = demod.process_samples(&samples) {
// Convert to storable format
let stored_pairs: Vec<StoredLevelDuration> = pairs
.iter()
.map(|p| StoredLevelDuration { level: p.level, duration_us: p.duration_us })
.collect();
let id = state.capture_id.fetch_add(1, Ordering::SeqCst);
let capture = Capture::from_pairs(id, current_freq, stored_pairs);
let capture = Capture::from_pairs_with_rf(
id,
current_freq,
pairs_to_stored(&pairs),
Some(RfModulation::AM),
);
let _ = state.event_tx.send(RadioEvent::SignalCaptured(capture));
}
}
if let Ok(mut demod) = state.demodulator_fm.lock() {
if let Some(pairs) = demod.process_samples(&samples) {
let id = state.capture_id.fetch_add(1, Ordering::SeqCst);
let capture = Capture::from_pairs_with_rf(
id,
current_freq,
pairs_to_stored(&pairs),
Some(RfModulation::FM),
);
let _ = state.event_tx.send(RadioEvent::SignalCaptured(capture));
}
}
@@ -299,14 +323,14 @@ fn run_receiver_hackrf(
receiving: Arc<AtomicBool>,
event_tx: Sender<RadioEvent>,
frequency: Arc<Mutex<u32>>,
demodulator: Arc<Mutex<Demodulator>>,
demodulator_am: Arc<Mutex<Demodulator>>,
demodulator_fm: Arc<Mutex<FmDemodulator>>,
gain_settings: Arc<Mutex<GainSettings>>,
) -> Result<()> {
use anyhow::Context;
tracing::info!("HackRF receiver thread starting...");
// Open HackRF device
let hackrf = libhackrf::HackRf::open()
.context("Failed to open HackRF device")?;
@@ -317,33 +341,29 @@ fn run_receiver_hackrf(
freq, SAMPLE_RATE, initial_gains.lna_gain, initial_gains.vga_gain, initial_gains.amp_enabled
);
// Configure HackRF
hackrf.set_sample_rate(SAMPLE_RATE)
.context("Failed to set sample rate")?;
hackrf.set_freq(freq as u64)
.context("Failed to set frequency")?;
hackrf.set_lna_gain(initial_gains.lna_gain)
.context("Failed to set LNA gain")?;
hackrf.set_rxvga_gain(initial_gains.vga_gain)
.context("Failed to set RXVGA gain")?;
hackrf.set_amp_enable(initial_gains.amp_enabled)
.context("Failed to enable amp")?;
tracing::info!("HackRF configured, starting RX...");
tracing::info!("HackRF configured, starting RX (AM + FM demodulators)...");
// Create state for callback
let state = RxState {
receiving: receiving.clone(),
event_tx: event_tx.clone(),
frequency: frequency.clone(),
demodulator,
demodulator_am,
demodulator_fm,
capture_id: std::sync::atomic::AtomicU32::new(0),
};
// Start receiving
hackrf.start_rx(rx_callback, state)
.context("Failed to start RX")?;
+2
View File
@@ -10,4 +10,6 @@ pub use hackrf::HackRfController;
#[allow(unused_imports)]
pub use demodulator::Demodulator;
#[allow(unused_imports)]
pub use demodulator::FmDemodulator;
#[allow(unused_imports)]
pub use modulator::Modulator;