Working changes

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