diff --git a/.gitignore b/.gitignore index 19d9603..a906fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /REFERENCES +/EXPORTS diff --git a/src/app.rs b/src/app.rs index b8e5d55..5c11868 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, - // -- .fob export metadata state -- - /// Capture ID being exported (set before entering FobMeta modes) + // -- Export state -- + /// Capture ID being exported pub export_capture_id: Option, + /// Export filename input buffer (without extension) + pub export_filename: String, + /// Which export format is in progress + pub export_format: Option, + + // -- .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(()) } diff --git a/src/main.rs b/src/main.rs index b5685e1..4fcfd05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(terminal: &mut Terminal, 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 => { diff --git a/src/protocols/ford_v0.rs b/src/protocols/ford_v0.rs index be428a5..314ae73 100644 --- a/src/protocols/ford_v0.rs +++ b/src/protocols/ford_v0.rs @@ -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 300–450µ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; diff --git a/src/protocols/subaru.rs b/src/protocols/subaru.rs index d8fb957..cc173a3 100644 --- a/src/protocols/subaru.rs +++ b/src/protocols/subaru.rs @@ -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, 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 { 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) diff --git a/src/protocols/vag.rs b/src/protocols/vag.rs index 45546fa..9a4c09b 100644 --- a/src/protocols/vag.rs +++ b/src/protocols/vag.rs @@ -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 { - self.te_last - TE_SHORT - } else { - TE_SHORT - self.te_last - }; - if prev_diff < TE_DELTA { + // 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_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,44 +1005,44 @@ 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 { + if diff <= TE_DELTA { + self.te_last = duration; + self.step = DecoderStep::Sync2A; 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 { + // 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; } - self.te_last = duration; - self.step = DecoderStep::Sync2A; + // 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 { - self.te_last = duration; - self.step = DecoderStep::Sync2B; - return None; - } + if diff < TE_DELTA || duration < TE_SHORT { + self.te_last = duration; + self.step = DecoderStep::Sync2B; + return None; } } self.step = DecoderStep::Reset; @@ -1045,6 +1050,7 @@ impl ProtocolDecoder for VagDecoder { 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,26 +1063,26 @@ 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 { - self.mid_count += 1; - self.step = DecoderStep::Sync2B; + let prev_diff = if self.te_last > 750 { + self.te_last - 750 + } else { + 750 - self.te_last + }; + if (diff <= TE_DELTA || duration < 750) && prev_diff <= TE_DELTA { + self.mid_count += 1; + self.step = DecoderStep::Sync2B; - if self.mid_count == 3 { - self.data_low = 1; - self.data_high = 0; - self.bit_count = 1; - self.manchester_advance(ManchesterEvent::Reset); - self.step = DecoderStep::Data2; - } - return None; + if self.mid_count == 3 { + self.data_low = 1; + self.data_high = 0; + self.bit_count = 1; + self.manchester_advance(ManchesterEvent::Reset); + self.step = DecoderStep::Data2; } + return None; } } self.step = DecoderStep::Reset; @@ -1084,9 +1090,13 @@ impl ProtocolDecoder for VagDecoder { 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 diff --git a/src/radio/demodulator.rs b/src/radio/demodulator.rs index aad18e8..9eaecc7 100644 --- a/src/radio/demodulator.rs +++ b/src/radio/demodulator.rs @@ -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 + /// 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, - /// 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,21 +88,38 @@ 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, } } /// Process raw IQ samples and return level+duration pairs if signal complete - /// + /// /// Returns None if still accumulating, Some(pairs) when a complete signal is detected pub fn process_samples(&mut self, samples: &[i8]) -> Option> { // Process each IQ sample pair @@ -82,53 +133,136 @@ 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; - } - - self.current_level = is_high; - self.level_sample_count = 1; - } else { - self.level_sample_count += 1; - self.samples_since_edge += 1; + // 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); } + // 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 { + // 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 { + // 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.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; + 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)); } // Return the pairs and reset let result = std::mem::take(&mut self.pairs); self.reset_state(); - + if result.len() >= 10 { return Some(result); } } - // 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.threshold = (self.low_level + self.high_level) / 2.0; - - // Ensure reasonable bounds - self.threshold = self.threshold.max(0.05).min(0.5); + self.recalc_threshold(); } - /// Reset the demodulator state + /// 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 — 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 (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 = 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] + ); + } + } + } + } } diff --git a/src/radio/hackrf.rs b/src/radio/hackrf.rs index 0546302..50bb51b 100644 --- a/src/radio/hackrf.rs +++ b/src/radio/hackrf.rs @@ -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>, /// Whether HackRF is available hackrf_available: bool, + /// Shared gain settings (read by receiver thread) + gain_settings: Arc>, } 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, frequency: Arc>, demodulator: Arc>, + gain_settings: Arc>, ) -> 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 diff --git a/src/ui/command.rs b/src/ui/command.rs index 4a3d995..82904b9 100644 --- a/src/ui/command.rs +++ b/src/ui/command.rs @@ -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 diff --git a/src/ui/layout.rs b/src/ui/layout.rs index b3c5c9b..fc1a29b 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -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 = [ - InputMode::FobMetaYear, - InputMode::FobMetaMake, - InputMode::FobMetaModel, - InputMode::FobMetaRegion, - InputMode::FobMetaNotes, - ]; + // Build the ordered list of field modes for this export type + // Filename is always first; .fob adds metadata fields after + let field_modes: Vec = 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,39 +314,53 @@ 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 = vec![ FormField { - label: " Year: ", - value: &app.fob_meta_year, - placeholder: "(e.g. 2024)", + label: " File: ", + value: &filename_display, + placeholder: "(enter filename)", idx: 0, }, - FormField { - label: " Make: ", - value: &app.fob_meta_make, - placeholder: "(auto-detected from protocol)", - idx: 1, - }, - FormField { - label: " Model: ", - value: &app.fob_meta_model, - placeholder: "(e.g. Sportage, F-150)", - idx: 2, - }, - FormField { - label: " Region: ", - value: &app.fob_meta_region, - placeholder: "(e.g. NA, EU, APAC, MEA)", - idx: 3, - }, - FormField { - label: " Notes: ", - value: &app.fob_meta_notes, - placeholder: "(optional — color, trim, VIN, etc.)", - idx: 4, - }, ]; + // .fob metadata fields + if is_fob { + fields.extend([ + FormField { + label: " Year: ", + value: &app.fob_meta_year, + placeholder: "(e.g. 2024)", + idx: 1, + }, + FormField { + label: " Make: ", + value: &app.fob_meta_make, + placeholder: "(auto-detected from protocol)", + idx: 2, + }, + FormField { + label: " Model: ", + value: &app.fob_meta_model, + placeholder: "(e.g. Sportage, F-150)", + idx: 3, + }, + FormField { + label: " Region: ", + value: &app.fob_meta_region, + placeholder: "(e.g. NA, EU, APAC, MEA)", + idx: 4, + }, + FormField { + label: " Notes: ", + value: &app.fob_meta_notes, + placeholder: "(optional — color, trim, VIN, etc.)", + idx: 5, + }, + ]); + } + for field in &fields { let label_s = style_for(field.idx); let display_val = if field.value.is_empty() { @@ -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));