better working signals

This commit is contained in:
leviathan
2026-02-08 01:26:21 -05:00
parent 284bf45198
commit 4029177fd6
10 changed files with 671 additions and 204 deletions
+1
View File
@@ -1,2 +1,3 @@
/target
/REFERENCES
/EXPORTS
+74 -16
View File
@@ -23,6 +23,8 @@ pub enum InputMode {
SettingsEdit,
/// Startup prompt: found .fob files, import? (y/n)
StartupImport,
/// Export: editing filename (before format-specific steps)
ExportFilename,
/// Fob export metadata: editing year field
FobMetaYear,
/// Fob export metadata: editing make field
@@ -35,6 +37,13 @@ pub enum InputMode {
FobMetaNotes,
}
/// Export format being used
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Fob,
Flipper,
}
/// Items available in the signal action menu
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignalAction {
@@ -208,9 +217,15 @@ pub struct App {
/// .fob files found on startup in export_dir
pub pending_fob_files: Vec<std::path::PathBuf>,
// -- .fob export metadata state --
/// Capture ID being exported (set before entering FobMeta modes)
// -- Export state --
/// Capture ID being exported
pub export_capture_id: Option<u32>,
/// Export filename input buffer (without extension)
pub export_filename: String,
/// Which export format is in progress
pub export_format: Option<ExportFormat>,
// -- .fob export metadata state --
/// Year input buffer
pub fob_meta_year: String,
/// Make input buffer
@@ -238,8 +253,12 @@ impl App {
// Try to initialize HackRF
let hackrf = match HackRfController::new(radio_event_tx.clone()) {
Ok(h) => {
Ok(mut h) => {
tracing::info!("HackRF initialized successfully");
// Push config defaults to the controller so they're used on first start_receiving
let _ = h.set_lna_gain(storage.config.default_lna_gain);
let _ = h.set_vga_gain(storage.config.default_vga_gain);
let _ = h.set_amp_enable(storage.config.default_amp);
Some(h)
}
Err(e) => {
@@ -298,6 +317,8 @@ impl App {
radio_event_tx,
pending_fob_files,
export_capture_id: None,
export_filename: String::new(),
export_format: None,
fob_meta_year: String::new(),
fob_meta_make: String::new(),
fob_meta_model: String::new(),
@@ -776,25 +797,41 @@ impl App {
Ok(())
}
/// Start .fob export by entering metadata input mode
/// Generate a default export filename (without extension) for a capture
fn default_export_filename(capture: &Capture) -> String {
format!(
"{}_{}",
capture.protocol_name().replace(' ', "_").to_lowercase(),
capture.serial_hex()
)
}
/// Start .fob export by entering filename input mode
pub fn export_fob(&mut self, id: u32) -> Result<()> {
if !self.captures.iter().any(|c| c.id == id) {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
// Pre-fill filename from protocol + serial
let default_name = self.captures.iter().find(|c| c.id == id)
.map(|c| Self::default_export_filename(c))
.unwrap_or_else(|| format!("capture_{}", id));
// Pre-fill make from protocol
let make = self.captures.iter().find(|c| c.id == id).map(|c| {
Self::get_make_for_protocol(c.protocol_name()).to_string()
}).unwrap_or_default();
self.export_capture_id = Some(id);
self.export_filename = default_name;
self.export_format = Some(ExportFormat::Fob);
self.fob_meta_year = String::new();
self.fob_meta_make = make;
self.fob_meta_model = String::new();
self.fob_meta_region = String::new();
self.fob_meta_notes = String::new();
self.input_mode = InputMode::FobMetaYear;
self.input_mode = InputMode::ExportFilename;
Ok(())
}
@@ -829,11 +866,7 @@ impl App {
notes: self.fob_meta_notes.clone(),
};
let filename = format!(
"{}_{}.fob",
capture.protocol_name().replace(' ', "_").to_lowercase(),
capture.serial_hex()
);
let filename = format!("{}.fob", self.export_filename);
let path = export_dir.join(&filename);
crate::export::fob::export_fob(
@@ -844,6 +877,7 @@ impl App {
)?;
self.export_capture_id = None;
self.export_format = None;
self.status_message = Some(format!("Exported to {}", filename));
Ok(())
}
@@ -880,8 +914,34 @@ impl App {
self.status_message = Some("Starting with no imported signals".to_string());
}
/// Export capture as Flipper .sub file
/// Start .sub (Flipper) export by entering filename input mode
pub fn export_flipper(&mut self, id: u32) -> Result<()> {
if !self.captures.iter().any(|c| c.id == id) {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
let default_name = self.captures.iter().find(|c| c.id == id)
.map(|c| Self::default_export_filename(c))
.unwrap_or_else(|| format!("capture_{}", id));
self.export_capture_id = Some(id);
self.export_filename = default_name;
self.export_format = Some(ExportFormat::Flipper);
self.input_mode = InputMode::ExportFilename;
Ok(())
}
/// Complete Flipper .sub export (called after filename is confirmed)
pub fn complete_flipper_export(&mut self) -> Result<()> {
let id = match self.export_capture_id {
Some(id) => id,
None => {
self.last_error = Some("No capture selected for export".to_string());
return Ok(());
}
};
let capture = match self.captures.iter().find(|c| c.id == id) {
Some(c) => c.clone(),
None => {
@@ -895,14 +955,12 @@ impl App {
std::fs::create_dir_all(&export_dir)?;
}
let filename = format!(
"{}_{}.sub",
capture.protocol_name().replace(' ', "_").to_lowercase(),
capture.serial_hex()
);
let filename = format!("{}.sub", self.export_filename);
let path = export_dir.join(&filename);
crate::export::flipper::export_flipper_sub(&capture, &path)?;
self.export_capture_id = None;
self.export_format = None;
self.status_message = Some(format!("Exported to {}", filename));
Ok(())
}
+38 -1
View File
@@ -22,7 +22,7 @@ use std::io::{self, Write};
use std::panic;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use app::{App, InputMode, SignalAction, SettingsField};
use app::{App, InputMode, SignalAction, SettingsField, ExportFormat};
use ui::draw_ui;
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -259,6 +259,43 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
_ => {}
},
// Export: filename input
InputMode::ExportFilename => match key.code {
KeyCode::Enter => {
if app.export_filename.is_empty() {
app.last_error = Some("Filename cannot be empty".to_string());
} else {
match app.export_format {
Some(ExportFormat::Fob) => {
app.input_mode = InputMode::FobMetaYear;
}
Some(ExportFormat::Flipper) => {
app.complete_flipper_export()?;
app.input_mode = InputMode::Normal;
}
None => {
app.input_mode = InputMode::Normal;
}
}
}
}
KeyCode::Char(c) => {
// Allow filesystem-safe characters
if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
app.export_filename.push(c);
}
}
KeyCode::Backspace => {
app.export_filename.pop();
}
KeyCode::Esc => {
app.export_capture_id = None;
app.export_format = None;
app.input_mode = InputMode::Normal;
}
_ => {}
},
// .fob export metadata: Year
InputMode::FobMetaYear => match key.code {
KeyCode::Enter => {
+24 -11
View File
@@ -15,11 +15,12 @@ use crate::duration_diff;
const TE_SHORT: u32 = 250;
const TE_LONG: u32 = 500;
const TE_DELTA: u32 = 100;
const TE_DELTA: u32 = 200; // Wider tolerance for HackRF software demodulation (was 100)
const MIN_COUNT_BIT: usize = 64;
const TOTAL_BURSTS: u8 = 6;
const PREAMBLE_PAIRS: usize = 4;
const GAP_US: u32 = 3500;
const GAP_TOLERANCE: u32 = 1500; // Wide gap tolerance for software demodulator
// CRC matrix for Ford V0 — GF(2) matrix multiplication
// Copied directly from protopirate's ford_v0.c
@@ -552,13 +553,16 @@ impl ProtocolDecoder for FordV0Decoder {
// ─── Step 3: PreambleCheck — count preamble pairs or transition to gap ───
DecoderStep::PreambleCheck => {
if level {
if duration_diff!(duration, TE_LONG) < TE_DELTA {
// Long HIGH: another preamble pair
let short_diff = duration_diff!(duration, TE_SHORT);
let long_diff = duration_diff!(duration, TE_LONG);
if long_diff < TE_DELTA && long_diff <= short_diff {
// Long HIGH (closer to TE_LONG): another preamble pair
self.header_count += 1;
self.te_last = duration;
self.step = DecoderStep::Preamble;
} else if duration_diff!(duration, TE_SHORT) < TE_DELTA {
// Short HIGH: end of preamble, transition to gap
} else if short_diff < TE_DELTA {
// Short HIGH (closer to TE_SHORT): end of preamble, transition to gap
self.step = DecoderStep::Gap;
} else {
self.step = DecoderStep::Reset;
@@ -568,24 +572,33 @@ impl ProtocolDecoder for FordV0Decoder {
// ─── Step 4: Gap — wait for ~3500µs LOW gap ───
DecoderStep::Gap => {
if !level && duration_diff!(duration, GAP_US) < 250 {
if !level && duration_diff!(duration, GAP_US) < GAP_TOLERANCE {
// Gap detected, start data collection
// First bit is implicitly 1 (matches protopirate)
self.decode_data = 1;
self.bit_count = 1;
self.step = DecoderStep::Data;
} else if !level && duration > GAP_US + 250 {
} else if !level && duration > GAP_US + GAP_TOLERANCE {
self.step = DecoderStep::Reset;
}
}
// ─── Step 5: Data — Manchester decode 80 bits ───
DecoderStep::Data => {
// Map level+duration to Manchester event
// Flipper convention: level=true (HIGH) → Low event, level=false (LOW) → High event
let event = if duration_diff!(duration, TE_SHORT) < TE_DELTA {
// Map level+duration to Manchester event using NEAREST-MATCH.
// With TE_DELTA=200, SHORT(250) and LONG(500) ranges overlap at 300450µs.
// First-match would always pick SHORT for overlapping durations, causing
// bit errors and CRC failure. Nearest-match picks the closer timing.
//
// Tie-break favors LONG (strict < for short_diff) because asymmetric
// demodulation compresses LOWs towards the midpoint (375µs) — these are
// actually LONG pulses that got shortened by threshold bias.
let short_diff = duration_diff!(duration, TE_SHORT);
let long_diff = duration_diff!(duration, TE_LONG);
let event = if short_diff < TE_DELTA && short_diff < long_diff {
if level { 0 } else { 1 } // ShortLow / ShortHigh
} else if duration_diff!(duration, TE_LONG) < TE_DELTA {
} else if long_diff < TE_DELTA {
if level { 2 } else { 3 } // LongLow / LongHigh
} else {
self.step = DecoderStep::Reset;
+40 -17
View File
@@ -15,7 +15,7 @@ use crate::duration_diff;
const TE_SHORT: u32 = 800;
const TE_LONG: u32 = 1600;
const TE_DELTA: u32 = 200;
const TE_DELTA: u32 = 300;
#[allow(dead_code)]
const MIN_COUNT_BIT: usize = 64;
@@ -115,6 +115,19 @@ impl SubaruDecoder {
((hi as u16) << 8) | (lo as u16)
}
/// Add a level+duration to the signal, merging with the previous entry
/// if it has the same level. This prevents consecutive same-level pulses
/// which would silently merge during HackRF transmission.
fn add_level(signal: &mut Vec<LevelDuration>, level: bool, duration: u32) {
if let Some(last) = signal.last_mut() {
if last.level == level {
*last = LevelDuration::new(level, last.duration_us + duration);
return;
}
}
signal.push(LevelDuration::new(level, duration));
}
/// Process the decoded data
fn process_data(&self) -> Option<DecodedSignal> {
if self.bit_count < 64 {
@@ -160,7 +173,7 @@ impl ProtocolDecoder for SubaruDecoder {
}
fn supported_frequencies(&self) -> &[u32] {
&[433_920_000]
&[433_920_000, 315_000_000] // 433.92 MHz (EU/AU) and 315 MHz (US/JP)
}
fn reset(&mut self) {
@@ -284,38 +297,48 @@ impl ProtocolDecoder for SubaruDecoder {
let key = decoded.data;
let mut signal = Vec::with_capacity(512);
// Generate 3 bursts
// Generate 3 bursts.
//
// IMPORTANT: Uses add_level() to merge adjacent same-level pulses.
// The HackRF transmitter generates IQ samples sequentially from the
// LevelDuration list, so consecutive same-level pairs silently merge
// and corrupt timing. For example, the last preamble LOW (1600µs) +
// gap LOW (2800µs) would become a single 4400µs LOW without merging.
for burst in 0..3 {
if burst > 0 {
signal.push(LevelDuration::new(false, 25000));
// Inter-burst silence
Self::add_level(&mut signal, false, 25000);
}
// Preamble: 80 long HIGH/LOW pairs
for _ in 0..80 {
signal.push(LevelDuration::new(true, TE_LONG));
signal.push(LevelDuration::new(false, TE_LONG));
// Preamble: 79 full pairs + 80th HIGH only.
// The gap LOW replaces the 80th preamble LOW.
for i in 0..80 {
Self::add_level(&mut signal, true, TE_LONG);
if i < 79 {
Self::add_level(&mut signal, false, TE_LONG);
}
}
// Gap
signal.push(LevelDuration::new(false, GAP_US));
// Gap (replaces the 80th preamble LOW)
Self::add_level(&mut signal, false, GAP_US);
// Sync
signal.push(LevelDuration::new(true, SYNC_US));
signal.push(LevelDuration::new(false, TE_LONG));
Self::add_level(&mut signal, true, SYNC_US);
Self::add_level(&mut signal, false, TE_LONG);
// Data: 64 bits (MSB first)
// Short HIGH = 1, Long HIGH = 0
for bit in (0..64).rev() {
if (key >> bit) & 1 == 1 {
signal.push(LevelDuration::new(true, TE_SHORT));
Self::add_level(&mut signal, true, TE_SHORT);
} else {
signal.push(LevelDuration::new(true, TE_LONG));
Self::add_level(&mut signal, true, TE_LONG);
}
signal.push(LevelDuration::new(false, TE_SHORT));
Self::add_level(&mut signal, false, TE_SHORT);
}
// End marker
signal.push(LevelDuration::new(false, TE_LONG * 2));
// End-of-burst gap (extends the last data LOW)
Self::add_level(&mut signal, false, TE_LONG * 2);
}
Some(signal)
+45 -35
View File
@@ -21,14 +21,14 @@ use crate::radio::demodulator::LevelDuration;
// Type 3/4 timing (used as default for ProtocolTiming)
const TE_SHORT: u32 = 500;
const TE_LONG: u32 = 1000;
const TE_DELTA: u32 = 80;
const TE_DELTA: u32 = 150; // Wider tolerance for HackRF software demodulation (was 80)
#[allow(dead_code)]
const MIN_COUNT_BIT: usize = 80;
// Type 1/2 timing
const TE_SHORT_12: u32 = 300;
const TE_LONG_12: u32 = 600;
const TE_DELTA_12: u32 = 79;
const TE_DELTA_12: u32 = 120; // Wider tolerance for HackRF (was 79)
// TEA constants
const TEA_DELTA: u32 = 0x9E3779B9;
@@ -982,15 +982,20 @@ impl ProtocolDecoder for VagDecoder {
DecoderStep::Preamble2 => {
if !level {
// Low pulse - check if matches 500µs
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
if diff < TE_DELTA {
let prev_diff = if self.te_last > TE_SHORT {
// Low pulse during preamble. With HackRF software demodulation,
// the LOW pulses can be severely shortened by threshold asymmetry.
// Instead of requiring LOWs to be ~500µs, accept any LOW that's
// short enough to be part of a preamble pair. We rely primarily
// on the HIGH pulse timing for validation.
let prev_high_diff = if self.te_last > TE_SHORT {
self.te_last - TE_SHORT
} else {
TE_SHORT - self.te_last
};
if prev_diff < TE_DELTA {
if prev_high_diff < TE_DELTA {
// Previous HIGH was valid. Accept LOWs up to 2*TE_SHORT
// (covers both symmetric and asymmetric demodulation).
if duration < TE_SHORT * 2 {
self.te_last = duration;
self.header_count += 1;
return None;
@@ -1000,51 +1005,52 @@ impl ProtocolDecoder for VagDecoder {
return None;
}
// High pulse after sufficient preamble
// High pulse — check for preamble continuation or sync transition
if self.header_count < 41 {
// Not enough preamble yet — keep counting if this HIGH is ~500µs
let hi_diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
if hi_diff < TE_DELTA {
self.te_last = duration;
}
return None;
}
// Check for 1000µs sync HIGH
// Sufficient preamble — check for 1000µs sync HIGH
let diff = if duration > TE_LONG { duration - TE_LONG } else { TE_LONG - duration };
if diff > TE_DELTA_12 {
return None;
}
let prev_diff = if self.te_last > TE_SHORT {
self.te_last - TE_SHORT
} else {
TE_SHORT - self.te_last
};
if prev_diff > TE_DELTA_12 {
return None;
}
if diff <= TE_DELTA {
self.te_last = duration;
self.step = DecoderStep::Sync2A;
return None;
}
// Not sync — might be another preamble HIGH
let hi_diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
if hi_diff < TE_DELTA {
self.te_last = duration;
return None;
}
// Neither preamble nor sync — reset
self.step = DecoderStep::Reset;
}
DecoderStep::Sync2A => {
if !level {
// Sync LOW after 1000µs HIGH. Accept LOWs within TE_DELTA of
// TE_SHORT, or any short LOW from asymmetric demodulation.
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
if diff < TE_DELTA {
let prev_diff = if self.te_last > TE_LONG {
self.te_last - TE_LONG
} else {
TE_LONG - self.te_last
};
if prev_diff < TE_DELTA {
if diff < TE_DELTA || duration < TE_SHORT {
self.te_last = duration;
self.step = DecoderStep::Sync2B;
return None;
}
}
}
self.step = DecoderStep::Reset;
}
DecoderStep::Sync2B => {
if level {
// Expect ~750µs HIGH sync pulse
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
if diff < TE_DELTA {
self.te_last = duration;
@@ -1057,14 +1063,15 @@ impl ProtocolDecoder for VagDecoder {
DecoderStep::Sync2C => {
if !level {
// Expect ~750µs LOW sync pulse, but accept shorter from
// asymmetric demodulation (as low as ~200µs)
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
if diff <= TE_DELTA_12 {
let prev_diff = if self.te_last > 750 {
self.te_last - 750
} else {
750 - self.te_last
};
if prev_diff <= TE_DELTA_12 {
if (diff <= TE_DELTA || duration < 750) && prev_diff <= TE_DELTA {
self.mid_count += 1;
self.step = DecoderStep::Sync2B;
@@ -1078,15 +1085,18 @@ impl ProtocolDecoder for VagDecoder {
return None;
}
}
}
self.step = DecoderStep::Reset;
}
DecoderStep::Data2 => {
// Determine Manchester event for Type 3/4 (500/1000µs)
let event = if duration >= 380 && duration <= 620 {
// Use nearest-match with wider tolerance for HackRF demodulation.
let short_diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
let long_diff = if duration > TE_LONG { duration - TE_LONG } else { TE_LONG - duration };
let event = if short_diff <= TE_DELTA && short_diff <= long_diff {
Some(if level { ManchesterEvent::ShortLow } else { ManchesterEvent::ShortHigh })
} else if duration >= 880 && duration <= 1120 {
} else if long_diff <= TE_DELTA {
Some(if level { ManchesterEvent::LongLow } else { ManchesterEvent::LongHigh })
} else {
None
+266 -47
View File
@@ -3,6 +3,12 @@
//! 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.
//!
//! 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
/// A single level+duration pair representing one segment of the signal
#[derive(Debug, Clone, Copy)]
@@ -26,25 +32,53 @@ pub struct Demodulator {
sample_rate: u32,
/// Samples per microsecond
samples_per_us: f64,
// ── Adaptive threshold with hysteresis ──
/// Current threshold for high/low detection
threshold: f32,
/// Adaptive threshold - high level estimate
high_level: f32,
/// Adaptive threshold - low level estimate
low_level: f32,
/// Current signal state (high or low)
/// Hysteresis (half-width of dead zone around threshold)
hysteresis: f32,
// ── Magnitude smoothing ──
/// Smoothed magnitude (exponential moving average)
mag_smooth: f32,
// ── Current confirmed level state ──
/// Current confirmed signal level (high or low)
current_level: bool,
/// Sample count at current level
/// Sample count at current confirmed level
level_sample_count: u64,
// ── Level magnitude tracking (for transition-based threshold updates) ──
/// Sum of smoothed magnitudes during the current level period
level_mag_sum: f64,
/// Count of samples during the current level period (for averaging)
level_mag_count: u64,
// ── Debounce / pending transition ──
/// Whether we're in a pending transition (unconfirmed level change)
in_transition: bool,
/// The level we're tentatively transitioning to
pending_level: bool,
/// Sample count accumulated at the pending level
pending_count: u64,
/// Sum of smoothed magnitudes during the pending transition
pending_mag_sum: f64,
// ── Output and limits ──
/// Accumulated level+duration pairs
pairs: Vec<LevelDuration>,
/// Total samples processed
/// Total samples processed (for adaptive threshold speed)
total_samples: u64,
/// Minimum duration to consider valid (in µs)
/// Minimum duration to consider valid (in µs) — also debounce threshold
min_duration_us: u32,
/// Maximum gap before considering signal complete (in µs)
max_gap_us: u32,
/// Samples since last edge
/// Samples since last confirmed edge (for gap detection)
samples_since_edge: u64,
}
@@ -54,15 +88,32 @@ impl Demodulator {
Self {
sample_rate,
samples_per_us: sample_rate as f64 / 1_000_000.0,
threshold: 0.15,
high_level: 0.3,
low_level: 0.05,
// Start with lower initial threshold — the HackRF's signal levels
// vary widely depending on gain and distance; starting low ensures
// we don't miss weak signals during initial adaptation.
threshold: 0.08,
high_level: 0.15,
low_level: 0.02,
hysteresis: 0.02,
mag_smooth: 0.0,
current_level: false,
level_sample_count: 0,
level_mag_sum: 0.0,
level_mag_count: 0,
in_transition: false,
pending_level: false,
pending_count: 0,
pending_mag_sum: 0.0,
pairs: Vec::with_capacity(2048),
total_samples: 0,
min_duration_us: 50, // Minimum 50µs pulse
max_gap_us: 10_000, // 10ms gap = end of signal
min_duration_us: 40, // 40µs debounce (was 50 — slightly more permissive)
max_gap_us: 20_000, // 20ms gap = end of signal (was 10ms — wider to avoid splitting signals with internal gaps)
samples_since_edge: 0,
}
}
@@ -82,41 +133,124 @@ impl Demodulator {
let q = chunk[1] as f32 / 128.0;
let magnitude = (i * i + q * q).sqrt();
// Update adaptive threshold
self.update_threshold(magnitude);
// Smooth the magnitude with EMA to reduce per-sample noise.
// Alpha=0.1 gives a time constant of ~10 samples (5µs at 2MHz),
// which smooths noise without distorting pulse edges.
self.mag_smooth = self.mag_smooth * 0.9 + magnitude * 0.1;
// Detect level
let is_high = magnitude > self.threshold;
// Check for level change
if is_high != self.current_level && self.level_sample_count > 0 {
// Calculate duration of the previous level
let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32;
// Only record if above minimum duration (noise filtering)
if duration_us >= self.min_duration_us {
self.pairs.push(LevelDuration::new(self.current_level, duration_us));
self.samples_since_edge = 0;
// During initial calibration, use fast per-sample threshold updates.
// After calibration, threshold is updated at transitions (see below)
// to avoid the duty-cycle bias that causes pulse asymmetry.
if self.total_samples < 10_000 {
self.update_threshold_fast(self.mag_smooth);
}
self.current_level = is_high;
self.level_sample_count = 1;
// Determine level using hysteresis (Schmitt trigger behavior):
// LOW → HIGH requires magnitude > threshold + hysteresis
// HIGH → LOW requires magnitude < threshold - hysteresis
let is_high = if self.current_level {
// Currently HIGH: stay HIGH unless magnitude drops well below threshold
self.mag_smooth > (self.threshold - self.hysteresis)
} else {
self.level_sample_count += 1;
self.samples_since_edge += 1;
}
// Currently LOW: go HIGH only if magnitude rises well above threshold
self.mag_smooth > (self.threshold + self.hysteresis)
};
self.total_samples += 1;
// Track magnitude for the current level period (used for
// transition-based threshold updates after initial calibration)
let mag_f64 = self.mag_smooth as f64;
// ── Debounce state machine ──
// When we see a level change, we don't immediately commit to it.
// Instead, we enter a "pending transition" state and wait for the
// new level to persist for at least min_duration_us. If it flips
// back sooner, we treat it as noise and absorb it.
if self.in_transition {
if is_high == self.pending_level {
// Still at the new (pending) level — accumulate
self.pending_count += 1;
self.pending_mag_sum += mag_f64;
let pending_us =
(self.pending_count as f64 / self.samples_per_us) as u32;
if pending_us >= self.min_duration_us {
// Transition confirmed! Update threshold from the
// COMPLETED level's average magnitude. This ensures
// equal contribution from HIGH and LOW periods
// regardless of their duration (no duty-cycle bias).
if self.total_samples >= 10_000 && self.level_mag_count > 0 {
let avg_mag = (self.level_mag_sum / self.level_mag_count as f64) as f32;
self.update_threshold_at_transition(avg_mag, self.current_level);
}
// Record the previous level's duration.
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;
// Transfer pending magnitude tracking to current level
self.level_mag_sum = self.pending_mag_sum;
self.level_mag_count = self.pending_count;
self.in_transition = false;
}
} else {
// Flipped back before confirmation — this was noise.
// Absorb the pending samples back into the current level.
self.level_sample_count += self.pending_count + 1;
self.level_mag_sum += self.pending_mag_sum + mag_f64;
self.level_mag_count += self.pending_count + 1;
self.in_transition = false;
}
} else if is_high != self.current_level && self.level_sample_count > 0 {
// Potential new transition — start pending
self.in_transition = true;
self.pending_level = is_high;
self.pending_count = 1;
self.pending_mag_sum = mag_f64;
} else {
// Same level as before, just accumulate
self.level_sample_count += 1;
self.level_mag_sum += mag_f64;
self.level_mag_count += 1;
self.samples_since_edge += 1;
}
}
// Check if we have a complete signal (long gap detected)
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 {
// Add the final level duration
let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32;
// Flush any pending transition
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.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;
}
// Add the final level duration
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));
}
// Return the pairs and reset
@@ -128,7 +262,7 @@ impl Demodulator {
}
}
// Limit buffer size
// Limit buffer size to prevent unbounded growth
if self.pairs.len() > 4096 {
self.reset_state();
}
@@ -136,40 +270,87 @@ impl Demodulator {
None
}
/// Update adaptive threshold based on signal levels
fn update_threshold(&mut self, magnitude: f32) {
const ALPHA: f32 = 0.001; // Slow adaptation
/// Fast per-sample threshold update — used only during initial calibration
/// (first ~5ms / 10K samples). Updates high/low estimates every sample for
/// quick convergence to the signal's dynamic range.
fn update_threshold_fast(&mut self, magnitude: f32) {
let alpha: f32 = 0.01;
if magnitude > self.threshold {
// Update high level estimate
self.high_level = self.high_level * (1.0 - ALPHA) + magnitude * ALPHA;
self.high_level = self.high_level * (1.0 - alpha) + magnitude * alpha;
} else {
// Update low level estimate
self.low_level = self.low_level * (1.0 - ALPHA) + magnitude * ALPHA;
self.low_level = self.low_level * (1.0 - alpha) + magnitude * alpha;
}
// Threshold is midpoint between low and high
self.recalc_threshold();
}
/// Transition-based threshold update — used after initial calibration.
///
/// Called once per confirmed level transition with the AVERAGE magnitude
/// of the completed level period. This eliminates the duty-cycle bias
/// that per-sample updates cause: a 500µs HIGH and a 100µs LOW now
/// contribute equally to their respective level estimates, producing
/// symmetric pulse widths in the demodulated output.
///
/// Alpha=0.3 provides fast convergence (~5 pulses / 10 transitions to
/// reach 97% of the correct threshold). This is critical because after
/// a long silence, high_level starts at a stale initial guess and must
/// converge before the data section begins. With alpha=0.05 it took ~50
/// transitions (entire preamble + data), causing massive pulse asymmetry.
fn update_threshold_at_transition(&mut self, avg_magnitude: f32, was_high: bool) {
let alpha: f32 = 0.3; // Fast convergence — one update per transition
if was_high {
self.high_level = self.high_level * (1.0 - alpha) + avg_magnitude * alpha;
} else {
self.low_level = self.low_level * (1.0 - alpha) + avg_magnitude * alpha;
}
self.recalc_threshold();
}
/// Recalculate threshold and hysteresis from current high/low estimates.
fn recalc_threshold(&mut self) {
// Threshold is midpoint between low and high estimates
self.threshold = (self.low_level + self.high_level) / 2.0;
// Ensure reasonable bounds
self.threshold = self.threshold.max(0.05).min(0.5);
// Ensure reasonable bounds — very low threshold for weak signals,
// but not so low that ADC noise alone triggers it
self.threshold = self.threshold.max(0.02).min(0.5);
// Dynamic hysteresis: 10% of the estimated signal-noise gap, clamped to [0.01, 0.08].
// This prevents chattering near the threshold while allowing clean transitions
// for both strong and weak signals.
self.hysteresis = ((self.high_level - self.low_level) * 0.10)
.max(0.01)
.min(0.08);
}
/// Reset the demodulator state
/// Reset the demodulator state (keeps threshold adaptation)
fn reset_state(&mut self) {
self.pairs.clear();
self.level_sample_count = 0;
self.level_mag_sum = 0.0;
self.level_mag_count = 0;
self.samples_since_edge = 0;
self.current_level = false;
self.in_transition = false;
self.pending_level = false;
self.pending_count = 0;
self.pending_mag_sum = 0.0;
}
/// Reset completely (including threshold adaptation)
#[allow(dead_code)]
pub fn reset(&mut self) {
self.reset_state();
self.threshold = 0.15;
self.high_level = 0.3;
self.low_level = 0.05;
self.threshold = 0.08;
self.high_level = 0.15;
self.low_level = 0.02;
self.hysteresis = 0.02;
self.mag_smooth = 0.0;
self.total_samples = 0;
}
}
@@ -191,4 +372,42 @@ mod tests {
assert!(ld.level);
assert_eq!(ld.duration_us, 500);
}
#[test]
fn test_no_consecutive_same_levels() {
// Simulate a signal with a noise spike in the middle of a LOW period.
// The debounce should absorb the spike and never produce consecutive same-level pairs.
let mut demod = Demodulator::new(2_000_000);
// At 2MHz, 1 sample = 0.5µs.
// Create a buffer: 200µs LOW, 20µs HIGH spike (noise), 200µs LOW, 200µs HIGH, 200µs LOW
// 200µs = 400 samples, 20µs = 40 samples
let mut buf = Vec::new();
// LOW: magnitude ≈ 0.01
for _ in 0..400 { buf.push(1i8); buf.push(0i8); }
// Brief HIGH spike: magnitude ≈ 0.9
for _ in 0..40 { buf.push(115i8); buf.push(0i8); }
// LOW again
for _ in 0..400 { buf.push(1i8); buf.push(0i8); }
// Real HIGH
for _ in 0..400 { buf.push(115i8); buf.push(0i8); }
// LOW
for _ in 0..400 { buf.push(1i8); buf.push(0i8); }
// Process (won't return signal yet since no long gap)
let _ = demod.process_samples(&buf);
// Add a long gap to flush
let gap_buf: Vec<i8> = vec![1, 0].repeat(50_000); // 25ms LOW
if let Some(pairs) = demod.process_samples(&gap_buf) {
// Verify no consecutive same-level pairs
for window in pairs.windows(2) {
if window[0].level == window[1].level {
panic!(
"Found consecutive same-level pairs: {:?} and {:?}",
window[0], window[1]
);
}
}
}
}
}
+73 -10
View File
@@ -21,6 +21,24 @@ use super::demodulator::LevelDuration;
/// Sample rate for HackRF (2 MHz is good for keyfob signals)
const SAMPLE_RATE: u32 = 2_000_000;
/// Shared gain/amp settings that can be updated while receiving
#[derive(Debug, Clone, Copy, PartialEq)]
struct GainSettings {
lna_gain: u32,
vga_gain: u32,
amp_enabled: bool,
}
impl Default for GainSettings {
fn default() -> Self {
Self {
lna_gain: 24,
vga_gain: 20,
amp_enabled: false,
}
}
}
/// HackRF controller for receiving and transmitting signals
pub struct HackRfController {
/// Event sender for notifying the app
@@ -35,6 +53,8 @@ pub struct HackRfController {
demodulator: Arc<Mutex<Demodulator>>,
/// Whether HackRF is available
hackrf_available: bool,
/// Shared gain settings (read by receiver thread)
gain_settings: Arc<Mutex<GainSettings>>,
}
impl HackRfController {
@@ -58,6 +78,7 @@ impl HackRfController {
frequency: Arc::new(Mutex::new(433_920_000)),
demodulator: Arc::new(Mutex::new(demodulator)),
hackrf_available,
gain_settings: Arc::new(Mutex::new(GainSettings::default())),
})
}
@@ -81,11 +102,12 @@ impl HackRfController {
let freq = self.frequency.clone();
let demodulator = self.demodulator.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)
run_receiver_hackrf(receiving.clone(), event_tx.clone(), freq, demodulator, gain_settings)
{
let _ = event_tx.send(RadioEvent::Error(format!("Receiver error: {}", e)));
}
@@ -150,22 +172,27 @@ impl HackRfController {
/// Set LNA gain (0-40 dB, 8 dB steps)
pub fn set_lna_gain(&mut self, gain: u32) -> Result<()> {
tracing::info!("Set LNA gain to {} dB", gain);
// Note: gain changes take effect on next start_receiving
// For now, just log - actual application happens in run_receiver_hackrf
if let Ok(mut settings) = self.gain_settings.lock() {
settings.lna_gain = gain;
}
Ok(())
}
/// Set VGA gain (0-62 dB, 2 dB steps)
pub fn set_vga_gain(&mut self, gain: u32) -> Result<()> {
tracing::info!("Set VGA gain to {} dB", gain);
// Note: gain changes take effect on next start_receiving
if let Ok(mut settings) = self.gain_settings.lock() {
settings.vga_gain = gain;
}
Ok(())
}
/// Enable/disable the RF amplifier
pub fn set_amp_enable(&mut self, enabled: bool) -> Result<()> {
tracing::info!("Set amp enable to {}", enabled);
// Note: amp changes take effect on next start_receiving
if let Ok(mut settings) = self.gain_settings.lock() {
settings.amp_enabled = enabled;
}
Ok(())
}
}
@@ -273,6 +300,7 @@ fn run_receiver_hackrf(
event_tx: Sender<RadioEvent>,
frequency: Arc<Mutex<u32>>,
demodulator: Arc<Mutex<Demodulator>>,
gain_settings: Arc<Mutex<GainSettings>>,
) -> Result<()> {
use anyhow::Context;
@@ -283,7 +311,11 @@ fn run_receiver_hackrf(
.context("Failed to open HackRF device")?;
let freq = *frequency.lock().unwrap();
tracing::info!("Configuring HackRF: freq={} Hz, sample_rate={} Hz", freq, SAMPLE_RATE);
let initial_gains = *gain_settings.lock().unwrap();
tracing::info!(
"Configuring HackRF: freq={} Hz, sample_rate={} Hz, LNA={} dB, VGA={} dB, AMP={}",
freq, SAMPLE_RATE, initial_gains.lna_gain, initial_gains.vga_gain, initial_gains.amp_enabled
);
// Configure HackRF
hackrf.set_sample_rate(SAMPLE_RATE)
@@ -292,13 +324,13 @@ fn run_receiver_hackrf(
hackrf.set_freq(freq as u64)
.context("Failed to set frequency")?;
hackrf.set_lna_gain(32)
hackrf.set_lna_gain(initial_gains.lna_gain)
.context("Failed to set LNA gain")?;
hackrf.set_rxvga_gain(20)
hackrf.set_rxvga_gain(initial_gains.vga_gain)
.context("Failed to set RXVGA gain")?;
hackrf.set_amp_enable(true)
hackrf.set_amp_enable(initial_gains.amp_enabled)
.context("Failed to enable amp")?;
tracing::info!("HackRF configured, starting RX...");
@@ -316,9 +348,40 @@ fn run_receiver_hackrf(
hackrf.start_rx(rx_callback, state)
.context("Failed to start RX")?;
// Wait until receiving is stopped
// Track applied settings so we can detect changes
let mut applied = initial_gains;
// Wait until receiving is stopped, applying gain changes live
while receiving.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(100));
// Check for gain/amp setting changes and apply them live
if let Ok(current) = gain_settings.lock() {
if current.lna_gain != applied.lna_gain {
if let Err(e) = hackrf.set_lna_gain(current.lna_gain) {
tracing::warn!("Failed to set LNA gain to {}: {:?}", current.lna_gain, e);
} else {
tracing::info!("Applied LNA gain: {} dB", current.lna_gain);
applied.lna_gain = current.lna_gain;
}
}
if current.vga_gain != applied.vga_gain {
if let Err(e) = hackrf.set_rxvga_gain(current.vga_gain) {
tracing::warn!("Failed to set VGA gain to {}: {:?}", current.vga_gain, e);
} else {
tracing::info!("Applied VGA gain: {} dB", current.vga_gain);
applied.vga_gain = current.vga_gain;
}
}
if current.amp_enabled != applied.amp_enabled {
if let Err(e) = hackrf.set_amp_enable(current.amp_enabled) {
tracing::warn!("Failed to set amp to {}: {:?}", current.amp_enabled, e);
} else {
tracing::info!("Applied amp: {}", if current.amp_enabled { "ON" } else { "OFF" });
applied.amp_enabled = current.amp_enabled;
}
}
}
}
// Stop receiving
+2 -1
View File
@@ -43,7 +43,8 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
"IMPORT",
Style::default().fg(Color::Yellow),
),
InputMode::FobMetaYear
InputMode::ExportFilename
| InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
+58 -16
View File
@@ -82,13 +82,14 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
if matches!(
app.input_mode,
InputMode::FobMetaYear
InputMode::ExportFilename
| InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaNotes
) {
render_fob_metadata_form(frame, app);
render_export_form(frame, app);
}
}
@@ -146,6 +147,13 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
InputMode::SettingsSelect => "Left/Right: Select | Tab: Cycle | Enter: Edit | Esc: Back",
InputMode::SettingsEdit => "Up/Down: Change Value | Enter: Apply | Esc: Cancel",
InputMode::StartupImport => "y: Import | n: Skip",
InputMode::ExportFilename => {
match app.export_format {
Some(crate::app::ExportFormat::Fob) => "Enter: Next Field | Esc: Cancel Export",
Some(crate::app::ExportFormat::Flipper) => "Enter: Save & Export | Esc: Cancel Export",
None => "Enter: Confirm | Esc: Cancel",
}
}
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
@@ -204,10 +212,17 @@ fn render_startup_import_prompt(frame: &mut Frame, app: &App) {
frame.render_widget(paragraph, popup);
}
/// Render the .fob export metadata form overlay with signal summary
fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
/// Render the export form overlay (filename + optional .fob metadata)
fn render_export_form(frame: &mut Frame, app: &App) {
use crate::app::ExportFormat;
let is_fob = app.export_format == Some(ExportFormat::Fob);
let ext = if is_fob { ".fob" } else { ".sub" };
let area = frame.area();
let popup = centered_rect(62, 19, area);
// Taller popup for .fob (filename + 5 metadata fields), shorter for .sub (filename only)
let popup_height = if is_fob { 21 } else { 11 };
let popup = centered_rect(62, popup_height, area);
frame.render_widget(Clear, popup);
@@ -226,14 +241,21 @@ fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
.add_modifier(Modifier::RAPID_BLINK),
);
// Determine which field is active
let field_modes = [
// Build the ordered list of field modes for this export type
// Filename is always first; .fob adds metadata fields after
let field_modes: Vec<InputMode> = if is_fob {
vec![
InputMode::ExportFilename,
InputMode::FobMetaYear,
InputMode::FobMetaMake,
InputMode::FobMetaModel,
InputMode::FobMetaRegion,
InputMode::FobMetaNotes,
];
]
} else {
vec![InputMode::ExportFilename]
};
let current_idx = field_modes
.iter()
.position(|m| *m == app.input_mode)
@@ -292,38 +314,52 @@ fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
idx: usize,
}
let fields = [
// Filename field (always present)
let filename_display = format!("{}{}", app.export_filename, ext);
let mut fields: Vec<FormField> = vec![
FormField {
label: " File: ",
value: &filename_display,
placeholder: "(enter filename)",
idx: 0,
},
];
// .fob metadata fields
if is_fob {
fields.extend([
FormField {
label: " Year: ",
value: &app.fob_meta_year,
placeholder: "(e.g. 2024)",
idx: 0,
idx: 1,
},
FormField {
label: " Make: ",
value: &app.fob_meta_make,
placeholder: "(auto-detected from protocol)",
idx: 1,
idx: 2,
},
FormField {
label: " Model: ",
value: &app.fob_meta_model,
placeholder: "(e.g. Sportage, F-150)",
idx: 2,
idx: 3,
},
FormField {
label: " Region: ",
value: &app.fob_meta_region,
placeholder: "(e.g. NA, EU, APAC, MEA)",
idx: 3,
idx: 4,
},
FormField {
label: " Notes: ",
value: &app.fob_meta_notes,
placeholder: "(optional — color, trim, VIN, etc.)",
idx: 4,
idx: 5,
},
];
]);
}
for field in &fields {
let label_s = style_for(field.idx);
@@ -377,8 +413,14 @@ fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
Span::styled(hint, dim_style),
]));
let title = if is_fob {
" Export .fob — Filename & Vehicle Details "
} else {
" Export .sub (Flipper Zero) "
};
let block = Block::default()
.title(" Export .fob — Vehicle Details ")
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));