From 04b1d91d78b0bb44fbfc4717adf4a94598a23d39 Mon Sep 17 00:00:00 2001 From: leviathan Date: Fri, 13 Feb 2026 18:10:29 -0500 Subject: [PATCH] Working changes --- src/app.rs | 110 ++++++++++++++++++++--- src/capture.rs | 17 +++- src/export/flipper.rs | 110 ++++++++++++++++++++++- src/export/fob.rs | 2 + src/main.rs | 3 + src/protocols/fiat_v0.rs | 17 +++- src/protocols/ford_v0.rs | 7 +- src/protocols/mod.rs | 5 ++ src/protocols/subaru.rs | 6 +- src/protocols/vag.rs | 171 ++++++++++++++++++++---------------- src/radio/demodulator.rs | 184 ++++++++++++++++++++++++++++++++++++--- src/radio/hackrf.rs | 98 ++++++++++++--------- src/radio/mod.rs | 2 + src/ui/captures_list.rs | 11 ++- src/ui/layout.rs | 6 +- 15 files changed, 587 insertions(+), 162 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8d47bfb..ecf085b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -282,10 +282,13 @@ impl App { let vga_gain = storage.config.default_vga_gain; let amp_enabled = storage.config.default_amp; - // Scan for .fob files in the export directory - let pending_fob_files = crate::export::fob::scan_fob_files(&storage.config.export_directory); + // Scan for .fob and .sub files in the export directory + let mut pending_fob_files = crate::export::fob::scan_fob_files(&storage.config.export_directory); + let sub_files = crate::export::flipper::scan_sub_files(&storage.config.export_directory); + pending_fob_files.extend(sub_files); + pending_fob_files.sort(); let initial_mode = if !pending_fob_files.is_empty() { - tracing::info!("Found {} .fob files in export dir", pending_fob_files.len()); + tracing::info!("Found {} file(s) in export dir", pending_fob_files.len()); InputMode::StartupImport } else { InputMode::Normal @@ -501,8 +504,12 @@ impl App { self.frequency = hz; + // If receiving, restart receiver so the new frequency takes effect (HackRF thread reads freq only at start) if let Some(ref mut hackrf) = self.hackrf { if self.radio_state == RadioState::Receiving { + hackrf.stop_receiving()?; + hackrf.start_receiving(hz)?; + } else { hackrf.set_frequency(hz)?; } } @@ -647,7 +654,16 @@ impl App { Ok(()) } - /// Delete a capture (in-memory only — captures are not persisted) + /// Delete the currently selected capture (if any). No-op if none selected or list empty. + pub fn delete_selected_capture(&mut self) -> Result<()> { + let id = match self.selected_capture { + Some(idx) if idx < self.captures.len() => self.captures[idx].id, + _ => return Ok(()), + }; + self.delete_capture(&id.to_string()) + } + + /// Delete a capture by ID (in-memory only — captures are not persisted) fn delete_capture(&mut self, id_str: &str) -> Result<()> { let id: u32 = match id_str.parse() { Ok(i) => i, @@ -883,27 +899,94 @@ impl App { Ok(()) } - /// Import pending .fob files into captures list + /// Import pending .fob and .sub files into captures list. + /// .sub files are decoded with registered protocols after load (no metadata in file). pub fn import_fob_files(&mut self) -> Result<()> { let files = std::mem::take(&mut self.pending_fob_files); let mut imported = 0; for path in &files { - match crate::export::fob::import_fob(path, self.next_capture_id) { - Ok(capture) => { - self.next_capture_id += 1; - self.captures.push(capture); - imported += 1; + let is_sub = path.extension().map_or(false, |e| e == "sub"); + + if is_sub { + match crate::export::flipper::import_sub(path, self.next_capture_id) { + Ok(captures) => { + for mut capture in captures { + capture.id = self.next_capture_id; + self.next_capture_id += 1; + // .sub has no protocol metadata; run decoder to identify signal + if capture.status == crate::capture::CaptureStatus::Unknown + && !capture.raw_pairs.is_empty() + { + let pairs: Vec = capture + .raw_pairs + .iter() + .map(|p| crate::radio::LevelDuration::new(p.level, p.duration_us)) + .collect(); + if let Some((protocol_name, decoded)) = + self.protocols.process_signal(&pairs, capture.frequency) + { + capture.protocol = Some(protocol_name); + capture.serial = decoded.serial; + capture.button = decoded.button; + capture.counter = decoded.counter; + capture.crc_valid = decoded.crc_valid; + capture.data = decoded.data; + capture.data_count_bit = decoded.data_count_bit; + capture.status = if decoded.encoder_capable { + crate::capture::CaptureStatus::EncoderCapable + } else { + crate::capture::CaptureStatus::Decoded + }; + } + } + self.captures.push(capture); + imported += 1; + } + } + Err(e) => tracing::warn!("Failed to import {:?}: {}", path, e), } - Err(e) => { - tracing::warn!("Failed to import {:?}: {}", path, e); + } else { + match crate::export::fob::import_fob(path, self.next_capture_id) { + Ok(mut capture) => { + self.next_capture_id += 1; + // Re-run decoder when Unknown and raw_pairs present (same as .sub) + if capture.status == crate::capture::CaptureStatus::Unknown + && !capture.raw_pairs.is_empty() + { + let pairs: Vec = capture + .raw_pairs + .iter() + .map(|p| crate::radio::LevelDuration::new(p.level, p.duration_us)) + .collect(); + if let Some((protocol_name, decoded)) = + self.protocols.process_signal(&pairs, capture.frequency) + { + capture.protocol = Some(protocol_name); + capture.serial = decoded.serial; + capture.button = decoded.button; + capture.counter = decoded.counter; + capture.crc_valid = decoded.crc_valid; + capture.data = decoded.data; + capture.data_count_bit = decoded.data_count_bit; + capture.status = if decoded.encoder_capable { + crate::capture::CaptureStatus::EncoderCapable + } else { + crate::capture::CaptureStatus::Decoded + }; + } + } + self.captures.push(capture); + imported += 1; + } + Err(e) => tracing::warn!("Failed to import {:?}: {}", path, e), } } } if imported > 0 { self.selected_capture = Some(0); - self.status_message = Some(format!("Imported {} .fob file(s)", imported)); + self.status_message = Some(format!("Imported {} file(s)", imported)); } Ok(()) @@ -1057,6 +1140,7 @@ impl App { data_count_bit: 64, raw_pairs: vec![], status: crate::capture::CaptureStatus::EncoderCapable, + received_rf: None, }; self.next_capture_id += 1; self.captures.push(capture); diff --git a/src/capture.rs b/src/capture.rs index 5f53250..bec76e4 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -58,6 +58,9 @@ pub struct Capture { pub raw_pairs: Vec, /// Current status pub status: CaptureStatus, + /// Which demodulator path produced this capture (AM or FM). None if unknown/imported. + #[serde(default)] + pub received_rf: Option, } /// Modulation type used by protocol (encoding: PWM vs Manchester) @@ -104,8 +107,19 @@ impl std::fmt::Display for RfModulation { } impl Capture { - /// Create a new capture from level+duration pairs + /// Create a new capture from level+duration pairs (received_rf = None). + #[allow(dead_code)] // public API for tests / code that doesn't have receive path pub fn from_pairs(id: u32, frequency: u32, pairs: Vec) -> Self { + Self::from_pairs_with_rf(id, frequency, pairs, None) + } + + /// Create a new capture from level+duration pairs and optional receive path (AM/FM). + pub fn from_pairs_with_rf( + id: u32, + frequency: u32, + pairs: Vec, + received_rf: Option, + ) -> Self { Self { id, timestamp: Utc::now(), @@ -119,6 +133,7 @@ impl Capture { data_count_bit: 0, raw_pairs: pairs, status: CaptureStatus::Unknown, + received_rf, } } diff --git a/src/export/flipper.rs b/src/export/flipper.rs index bc01ca3..1e47ea2 100644 --- a/src/export/flipper.rs +++ b/src/export/flipper.rs @@ -1,12 +1,12 @@ //! Flipper Zero .sub export format. //! -//! Outputs files in the Flipper SubGhz RAW format with alternating +//! Read/write files in the Flipper SubGhz RAW format with alternating //! positive (high) and negative (low) durations in microseconds. -use anyhow::Result; +use anyhow::{Context, Result}; use std::path::Path; -use crate::capture::Capture; +use crate::capture::{Capture, CaptureStatus, StoredLevelDuration}; /// Export a capture to Flipper Zero .sub RAW format pub fn export_flipper_sub(capture: &Capture, path: &Path) -> Result<()> { @@ -47,3 +47,107 @@ pub fn export_flipper_sub(capture: &Capture, path: &Path) -> Result<()> { tracing::info!("Exported Flipper .sub to {:?}", path); Ok(()) } + +/// Scan a directory for Flipper .sub files (same format we export). +pub fn scan_sub_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for e in entries.flatten() { + let p = e.path(); + if p.extension().map_or(false, |e| e == "sub") { + out.push(p); + } + } + out.sort(); + out +} + +/// Gap duration (µs) used to split a .sub stream into separate transmissions. +/// Keyfobs typically use 10–25 ms between button pushes; in-frame gaps are < 1 ms. +pub const SUB_INTER_BURST_GAP_US: u32 = 10_000; + +/// Split raw level/duration pairs into segments at long gaps (e.g. between keyfob button pushes). +/// Any pulse (HIGH or LOW) with duration >= `gap_threshold_us` starts a new segment; the long pulse is not included in any segment. +pub fn split_raw_pairs_by_gap( + pairs: &[StoredLevelDuration], + gap_threshold_us: u32, +) -> Vec> { + let mut segments = Vec::new(); + let mut current = Vec::new(); + + for p in pairs { + if p.duration_us >= gap_threshold_us { + if !current.is_empty() { + segments.push(std::mem::take(&mut current)); + } + } else { + current.push(*p); + } + } + if !current.is_empty() { + segments.push(current); + } + segments +} + +/// Parse a Flipper SubGhz RAW .sub file and return one Capture per transmission. +/// The stream is split at long gaps (see `split_raw_pairs_by_gap`) so multiple button pushes become separate captures. +/// Positive values = HIGH, negative = LOW; duration in microseconds. +pub fn import_sub(path: &Path, next_id: u32) -> Result> { + let s = std::fs::read_to_string(path) + .with_context(|| format!("Read .sub file: {:?}", path))?; + + let mut frequency_hz: Option = None; + let mut raw_data = Vec::new(); + + for line in s.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("Frequency:") { + let n: u32 = rest.trim().parse().context("Parse Frequency in .sub")?; + frequency_hz = Some(n); + continue; + } + if let Some(rest) = line.strip_prefix("RAW_Data:") { + for word in rest.split_whitespace() { + let value: i64 = word.parse().with_context(|| format!("Parse RAW_Data value: {:?}", word))?; + raw_data.push(value); + } + } + } + + let frequency = frequency_hz.unwrap_or(433_920_000); + + let raw_pairs: Vec = raw_data + .into_iter() + .map(|v| { + let duration_us = v.unsigned_abs() as u32; + let level = v >= 0; + StoredLevelDuration { level, duration_us } + }) + .collect(); + + if raw_pairs.is_empty() { + anyhow::bail!("No RAW_Data in .sub file"); + } + + let segments = split_raw_pairs_by_gap(&raw_pairs, SUB_INTER_BURST_GAP_US); + + let captures: Vec = segments + .into_iter() + .enumerate() + .map(|(i, pairs)| { + let cap = Capture::from_pairs_with_rf(next_id + i as u32, frequency, pairs, None); + Capture { + status: CaptureStatus::Unknown, + ..cap + } + }) + .collect(); + + Ok(captures) +} diff --git a/src/export/fob.rs b/src/export/fob.rs index 4547f4f..c28c55f 100644 --- a/src/export/fob.rs +++ b/src/export/fob.rs @@ -300,6 +300,7 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result { data_count_bit: sig.data_bits, raw_pairs, status, + received_rf: None, }) } @@ -364,6 +365,7 @@ fn import_fob_v1(fob: &FobFileV1, next_id: u32) -> Result { data_count_bit: cap.data_bits, raw_pairs, status, + received_rf: None, }) } diff --git a/src/main.rs b/src/main.rs index 11afdc7..6dddbdb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,6 +138,9 @@ fn run_app(terminal: &mut Terminal, app: &mut A KeyCode::Char('r') => { app.toggle_receiving()?; } + KeyCode::Char('d') => { + let _ = app.delete_selected_capture(); + } KeyCode::Enter => { // Open signal action menu if a capture is selected if app.selected_capture.is_some() && !app.captures.is_empty() { diff --git a/src/protocols/fiat_v0.rs b/src/protocols/fiat_v0.rs index bc041a0..7b72a7d 100644 --- a/src/protocols/fiat_v0.rs +++ b/src/protocols/fiat_v0.rs @@ -22,6 +22,12 @@ const GAP_US: u32 = 800; const TOTAL_BURSTS: u8 = 3; const INTER_BURST_GAP: u32 = 25000; +// After long silence, first HIGH can be merged/wrong (e.g. 658µs); accept 100-700µs to enter Preamble. +const RESET_HIGH_MIN_US: u32 = 100; +const RESET_HIGH_MAX_US: u32 = 700; +// Preamble short LOW: ref 200±100; allow 80-320µs so truncated first LOW (e.g. 99µs) still counts. +const PREAMBLE_SHORT_DELTA: u32 = 120; + /// Manchester state machine states (matches Flipper's manchester_decoder.h, same as Ford V0) #[derive(Debug, Clone, Copy, PartialEq)] enum ManchesterState { @@ -154,7 +160,7 @@ impl ProtocolDecoder for FiatV0Decoder { } fn supported_frequencies(&self) -> &[u32] { - &[433_920_000] + &[433_920_000, 433_880_000] // 433.88 MHz common for Fiat keyfobs } fn reset(&mut self) { @@ -172,12 +178,14 @@ impl ProtocolDecoder for FiatV0Decoder { fn feed(&mut self, level: bool, duration: u32) -> Option { match self.step { - // Reset: wait for short HIGH (matches reference) + // Reset: wait for HIGH that starts the burst. Ref: short 200µs; live capture often has + // first HIGH 100-700µs (merged/AGC) and first LOW truncated (e.g. 99µs), so accept range. DecoderStep::Reset => { if !level { return None; } - if duration_diff!(duration, TE_SHORT) < TE_DELTA { + let in_range = duration >= RESET_HIGH_MIN_US && duration <= RESET_HIGH_MAX_US; + if in_range { self.data_low = 0; self.data_high = 0; self.step = DecoderStep::Preamble; @@ -189,11 +197,12 @@ impl ProtocolDecoder for FiatV0Decoder { } // Preamble: only process LOW pulses (reference: if(level) return). Count short LOWs; gap = 800µs LOW. + // Use PREAMBLE_SHORT_DELTA so first LOW after long gap (e.g. 99µs) counts as short. DecoderStep::Preamble => { if level { return None; } - let short_ok = duration_diff!(duration, TE_SHORT) < TE_DELTA; + let short_ok = duration_diff!(duration, TE_SHORT) < PREAMBLE_SHORT_DELTA; let gap_ok = duration_diff!(duration, GAP_US) < TE_DELTA; if short_ok { diff --git a/src/protocols/ford_v0.rs b/src/protocols/ford_v0.rs index a72f002..528d3ba 100644 --- a/src/protocols/ford_v0.rs +++ b/src/protocols/ford_v0.rs @@ -10,8 +10,7 @@ //! - BS (byte swap) magic calculation //! - 6 bursts, 4 preamble pairs, 3500µs gap //! -//! HackRF-specific: `TE_DELTA` (200µs) and `GAP_TOLERANCE` (1500µs) are wider than reference -//! (100µs / 250µs) for software demodulator tolerance. +//! Timing matches reference: te_delta 100µs, gap tolerance 250µs. use super::{ProtocolDecoder, ProtocolTiming, DecodedSignal}; use crate::radio::demodulator::LevelDuration; @@ -19,12 +18,12 @@ use crate::duration_diff; const TE_SHORT: u32 = 250; const TE_LONG: u32 = 500; -const TE_DELTA: u32 = 200; // Wider tolerance for HackRF software demodulation (was 100) +const TE_DELTA: u32 = 100; // ref ford_v0.c 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 +const GAP_TOLERANCE: u32 = 250; // ref: DURATION_DIFF(duration, gap_threshold) < 250 // CRC matrix for Ford V0 — GF(2) matrix multiplication // Copied directly from protopirate's ford_v0.c diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 2ca6f35..215c771 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -3,6 +3,11 @@ //! Protocols are aligned with the ProtoPirate reference (`REFERENCES/ProtoPirate/protocols/`). //! Each decoder processes level+duration pairs from the demodulator and optionally supports //! encoding (replay). Shared pieces: [common], [keeloq_common], [keys], [aut64]. +//! +//! **Manchester decoding**: Each protocol that uses Manchester has its own state machine and +//! event mapping (no shared global decoder). Polarity and event conventions match the +//! reference per protocol (e.g. Kia V5 uses opposite polarity to V1/V2; Kia V6 level +//! convention; Fiat/Ford/VAG use level ? ShortLow : ShortHigh). mod common; pub mod keeloq_common; diff --git a/src/protocols/subaru.rs b/src/protocols/subaru.rs index 33832f6..2fe28bb 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 = 300; +const TE_DELTA: u32 = 200; // ref subaru.c #[allow(dead_code)] const MIN_COUNT_BIT: usize = 64; @@ -115,7 +115,7 @@ impl SubaruDecoder { ((hi as u16) << 8) | (lo as u16) } - /// Append level+duration, merging with previous if same level (prevents HackRF from merging consecutive same-level pulses) + /// Append level+duration, merging with previous if same level for correct replay timing fn add_level(signal: &mut Vec, level: bool, duration: u32) { if let Some(last) = signal.last_mut() { if last.level == level { @@ -293,7 +293,7 @@ impl ProtocolDecoder for SubaruDecoder { let key = decoded.data; let mut signal = Vec::with_capacity(512); - // 3 bursts; add_level() merges same-level pulses for correct HackRF timing (matches protopirate subaru encode) + // 3 bursts; add_level() merges same-level pulses for correct replay (ref subaru encode) for burst in 0..3 { if burst > 0 { Self::add_level(&mut signal, false, 25000); diff --git a/src/protocols/vag.rs b/src/protocols/vag.rs index 5eca93f..a5dbed8 100644 --- a/src/protocols/vag.rs +++ b/src/protocols/vag.rs @@ -2,8 +2,8 @@ //! //! Aligned with ProtoPirate reference: `REFERENCES/ProtoPirate/protocols/vag.c`. //! Decode/encode logic (steps, Type 1/2/3/4, AUT64/TEA, dispatch, gap 6000µs) matches reference. -//! Intentional differences for HackRF: TE_DELTA 150 (ref 80), TE_DELTA_12 120 (ref 79); -//! Preamble2/Sync2C accept shorter LOW pulses for asymmetric software demodulation. +//! Reset/Preamble2/Sync2/Data2 use reference thresholds (79/80, 380-620/880-1120µs). +//! Preamble1/Data1 use TE_DELTA_12 80 (ref 79/80). //! //! Protocol characteristics: //! - Manchester encoding; 80 bits (key1 64 + key2 16) @@ -19,14 +19,20 @@ 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 = 150; // Wider tolerance for HackRF software demodulation (was 80) +#[allow(dead_code)] +const TE_DELTA: u32 = 80; // ref vag.c (Type 3/4); exposed via timing() #[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 = 120; // Wider tolerance for HackRF (was 79) +const TE_DELTA_12: u32 = 80; // Preamble1/Data1 (ref vag.c 79/80) + +// Reference-aligned deltas (vag.c) +const REF_RESET_DELTA: u32 = 79; // Reset: (300-duration)<=79, 500±79 +const REF_PREAMBLE_SYNC: u32 = 80; // Preamble2/Sync2: 500±80, 1000±80, 750±80 +const REF_SYNC2C_DELTA: u32 = 79; // Sync2C: diff<=79 // TEA constants const TEA_DELTA: u32 = 0x9E3779B9; @@ -805,12 +811,12 @@ impl ProtocolDecoder for VagDecoder { if !level { return None; } - + // Matches vag.c: duration < 300 and (300-duration)<=79 -> Preamble1; else (duration-300)<=79 -> Preamble1; else (duration-300)>79 and 500±79 -> Preamble2 if duration < TE_SHORT_12 { - if (TE_SHORT_12 - duration) > TE_DELTA_12 { + if (TE_SHORT_12 - duration) > REF_RESET_DELTA { return None; } - // Init pattern 1 (Type 1/2) + // init_pattern1 self.step = DecoderStep::Preamble1; self.data_low = 0; self.data_high = 0; @@ -820,8 +826,8 @@ impl ProtocolDecoder for VagDecoder { self.vag_type = VagType::Unknown; self.te_last = duration; self.manchester_advance(ManchesterEvent::Reset); - } else if (duration.wrapping_sub(TE_SHORT_12)) <= TE_DELTA_12 { - // Still pattern 1 + } else if duration.wrapping_sub(TE_SHORT_12) <= REF_RESET_DELTA { + // Fall-through to init_pattern1 in ref (duration 300..380) self.step = DecoderStep::Preamble1; self.data_low = 0; self.data_high = 0; @@ -832,9 +838,13 @@ impl ProtocolDecoder for VagDecoder { self.te_last = duration; self.manchester_advance(ManchesterEvent::Reset); } else { - // Check for pattern 2 (Type 3/4, 500µs) - let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration }; - if diff <= TE_DELTA_12 { + // (duration - 300) > 79: check 500±79 for Preamble2 + let diff = if duration < TE_SHORT { + TE_SHORT - duration + } else { + duration - TE_SHORT + }; + if diff <= REF_RESET_DELTA { self.step = DecoderStep::Preamble2; self.data_low = 0; self.data_high = 0; @@ -979,21 +989,20 @@ impl ProtocolDecoder for VagDecoder { } DecoderStep::Preamble2 => { + // Matches vag.c: LOW 500±80 and te_last 500±80 to count; then header_count>=41, HIGH 1000±79 and te_last 500±79 -> Sync2A if !level { - // 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 + let diff = if duration < TE_SHORT { + TE_SHORT - duration } else { - TE_SHORT - self.te_last + duration - TE_SHORT }; - 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 { + if diff < REF_PREAMBLE_SYNC { + let prev_diff = if self.te_last < TE_SHORT { + TE_SHORT - self.te_last + } else { + self.te_last - TE_SHORT + }; + if prev_diff < REF_PREAMBLE_SYNC { self.te_last = duration; self.header_count += 1; return None; @@ -1003,54 +1012,63 @@ impl ProtocolDecoder for VagDecoder { return None; } - // 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; } - // 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 { - self.te_last = duration; - self.step = DecoderStep::Sync2A; + let diff = if duration < TE_LONG { + TE_LONG - duration + } else { + duration - TE_LONG + }; + if diff > REF_RESET_DELTA { 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; + let prev_diff = if self.te_last < TE_SHORT { + TE_SHORT - self.te_last + } else { + self.te_last - TE_SHORT + }; + if prev_diff > REF_RESET_DELTA { return None; } - - // Neither preamble nor sync — reset - self.step = DecoderStep::Reset; + self.te_last = duration; + self.step = DecoderStep::Sync2A; } DecoderStep::Sync2A => { + // Matches vag.c: LOW 500±80 and te_last 1000±80 -> Sync2B 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 || duration < TE_SHORT { - self.te_last = duration; - self.step = DecoderStep::Sync2B; - return None; + let diff = if duration < TE_SHORT { + TE_SHORT - duration + } else { + duration - TE_SHORT + }; + if diff < REF_PREAMBLE_SYNC { + let prev_diff = if self.te_last < TE_LONG { + TE_LONG - self.te_last + } else { + self.te_last - TE_LONG + }; + if prev_diff < REF_PREAMBLE_SYNC { + self.te_last = duration; + self.step = DecoderStep::Sync2B; + return None; + } } } self.step = DecoderStep::Reset; } DecoderStep::Sync2B => { + // Matches vag.c: HIGH 750±80 -> Sync2C if level { - // Expect ~750µs HIGH sync pulse - let diff = if duration > 750 { duration - 750 } else { 750 - duration }; - if diff < TE_DELTA { + let diff = if duration < 750 { + 750 - duration + } else { + duration - 750 + }; + if diff < REF_PREAMBLE_SYNC { self.te_last = duration; self.step = DecoderStep::Sync2C; return None; @@ -1060,41 +1078,42 @@ impl ProtocolDecoder for VagDecoder { } DecoderStep::Sync2C => { + // Matches vag.c: LOW 750±79 and te_last 750±79 (diff<=79), mid_count++; at 3 -> Data2 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 }; - let prev_diff = if self.te_last > 750 { - self.te_last - 750 + let diff = if duration < 750 { + 750 - duration } else { - 750 - self.te_last + duration - 750 }; - if (diff <= TE_DELTA || duration < 750) && prev_diff <= TE_DELTA { - self.mid_count += 1; - self.step = DecoderStep::Sync2B; + if diff <= REF_SYNC2C_DELTA { + let prev_diff = if self.te_last < 750 { + 750 - self.te_last + } else { + self.te_last - 750 + }; + if prev_diff <= REF_SYNC2C_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; + 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; } - return None; } } self.step = DecoderStep::Reset; } DecoderStep::Data2 => { - // Determine Manchester event for Type 3/4 (500/1000µs) - // 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 { + // Matches vag.c: short 380-620µs, long 880-1120µs + let event = if duration >= 380 && duration <= 620 { Some(if level { ManchesterEvent::ShortLow } else { ManchesterEvent::ShortHigh }) - } else if long_diff <= TE_DELTA { + } else if duration >= 880 && duration <= 1120 { Some(if level { ManchesterEvent::LongLow } else { ManchesterEvent::LongHigh }) } else { None diff --git a/src/radio/demodulator.rs b/src/radio/demodulator.rs index ae4269d..aaf8c69 100644 --- a/src/radio/demodulator.rs +++ b/src/radio/demodulator.rs @@ -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, + min_duration_us: u32, + max_gap_us: u32, + samples_since_edge: u64, +} + +impl FmDemodulator { + /// Create a new FM demodulator. Hysteresis in Hz (e.g. 300–1000 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> { + 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)] diff --git a/src/radio/hackrf.rs b/src/radio/hackrf.rs index 50bb51b..edbaa5b 100644 --- a/src/radio/hackrf.rs +++ b/src/radio/hackrf.rs @@ -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>, /// Current frequency frequency: Arc>, - /// Demodulator for processing samples - demodulator: Arc>, + /// AM/OOK demodulator + demodulator_am: Arc>, + /// FM/2FSK demodulator + demodulator_fm: Arc>, /// 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) -> Result { - 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, event_tx: Sender, frequency: Arc>, - demodulator: Arc>, + demodulator_am: Arc>, + demodulator_fm: Arc>, capture_id: std::sync::atomic::AtomicU32, } -/// RX callback function for libhackrf +fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec { + 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], user_data: &dyn std::any::Any, ) { - use crate::capture::StoredLevelDuration; - - // Downcast user_data to our state let state = match user_data.downcast_ref::() { Some(s) => s, None => return, }; - if !state.receiving.load(Ordering::SeqCst) { return; } - let current_freq = *state.frequency.lock().unwrap(); + let samples: Vec = buffer.iter().flat_map(|c| [c.re, c.im]).collect(); - // Convert Complex samples to i8 pairs for demodulator - let samples: Vec = 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 = 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, event_tx: Sender, frequency: Arc>, - demodulator: Arc>, + demodulator_am: Arc>, + demodulator_fm: Arc>, gain_settings: Arc>, ) -> 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")?; diff --git a/src/radio/mod.rs b/src/radio/mod.rs index bf167f8..aa3b1fd 100644 --- a/src/radio/mod.rs +++ b/src/radio/mod.rs @@ -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; diff --git a/src/ui/captures_list.rs b/src/ui/captures_list.rs index 26e34c8..6cd96ae 100644 --- a/src/ui/captures_list.rs +++ b/src/ui/captures_list.rs @@ -158,8 +158,8 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) { Span::styled(make, value_style), ])); - // Row 2: Frequency + Encoding (Mod) + RF (AM/FM) + Encryption - left_lines.push(Line::from(vec![ + // Row 2: Freq + Mod + RF (protocol) + Enc + Rx (demodulator path when known) + let mut row2 = vec![ Span::styled(" Freq: ", label_style), Span::styled(capture.frequency_mhz(), value_style), Span::styled(" Mod: ", label_style), @@ -168,7 +168,12 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) { Span::styled(capture.rf_modulation().to_string(), value_style), Span::styled(" Enc: ", label_style), Span::styled(capture.encryption_type(), value_style), - ])); + ]; + if let Some(rf) = capture.received_rf { + row2.push(Span::styled(" Rx: ", label_style)); + row2.push(Span::styled(rf.to_string(), value_style)); + } + left_lines.push(Line::from(row2)); // Row 3: Full Serial + Button left_lines.push(Line::from(vec![ diff --git a/src/ui/layout.rs b/src/ui/layout.rs index fc1a29b..d59f97d 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -102,7 +102,7 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) { RadioState::Transmitting => ("●", Style::default().fg(Color::Red)), }; - let title = format!("KAT v{}", VERSION); + let title = format!("Keyfob Analysis Toolkit v{}", VERSION); // Build radio info string with all settings let amp_str = if app.amp_enabled { "ON" } else { "OFF" }; @@ -140,7 +140,7 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) { fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) { let help_text = match app.input_mode { InputMode::Normal => { - "Enter: Actions | Tab: Settings | r: RX Toggle | :: Command | q: Quit" + "Enter: Actions | d: Delete | Tab: Settings | r: RX Toggle | :: Command | q: Quit" } InputMode::Command => "Enter: Execute | Esc: Cancel", InputMode::SignalMenu => "Up/Down: Navigate | Enter: Select | Esc: Close", @@ -187,7 +187,7 @@ fn render_startup_import_prompt(frame: &mut Frame, app: &App) { let text = vec![ Line::from(""), Line::from(Span::styled( - format!("Found {} .fob file(s) in export directory.", count), + format!("Found {} file(s) (.fob / .sub) in export directory.", count), Style::default().fg(Color::Yellow), )), Line::from(""),