Working changes
This commit is contained in:
+97
-13
@@ -282,10 +282,13 @@ impl App {
|
|||||||
let vga_gain = storage.config.default_vga_gain;
|
let vga_gain = storage.config.default_vga_gain;
|
||||||
let amp_enabled = storage.config.default_amp;
|
let amp_enabled = storage.config.default_amp;
|
||||||
|
|
||||||
// Scan for .fob files in the export directory
|
// Scan for .fob and .sub files in the export directory
|
||||||
let pending_fob_files = crate::export::fob::scan_fob_files(&storage.config.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() {
|
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
|
InputMode::StartupImport
|
||||||
} else {
|
} else {
|
||||||
InputMode::Normal
|
InputMode::Normal
|
||||||
@@ -501,8 +504,12 @@ impl App {
|
|||||||
|
|
||||||
self.frequency = hz;
|
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 let Some(ref mut hackrf) = self.hackrf {
|
||||||
if self.radio_state == RadioState::Receiving {
|
if self.radio_state == RadioState::Receiving {
|
||||||
|
hackrf.stop_receiving()?;
|
||||||
|
hackrf.start_receiving(hz)?;
|
||||||
|
} else {
|
||||||
hackrf.set_frequency(hz)?;
|
hackrf.set_frequency(hz)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -647,7 +654,16 @@ impl App {
|
|||||||
Ok(())
|
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<()> {
|
fn delete_capture(&mut self, id_str: &str) -> Result<()> {
|
||||||
let id: u32 = match id_str.parse() {
|
let id: u32 = match id_str.parse() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
@@ -883,27 +899,94 @@ impl App {
|
|||||||
Ok(())
|
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<()> {
|
pub fn import_fob_files(&mut self) -> Result<()> {
|
||||||
let files = std::mem::take(&mut self.pending_fob_files);
|
let files = std::mem::take(&mut self.pending_fob_files);
|
||||||
let mut imported = 0;
|
let mut imported = 0;
|
||||||
|
|
||||||
for path in &files {
|
for path in &files {
|
||||||
match crate::export::fob::import_fob(path, self.next_capture_id) {
|
let is_sub = path.extension().map_or(false, |e| e == "sub");
|
||||||
Ok(capture) => {
|
|
||||||
self.next_capture_id += 1;
|
if is_sub {
|
||||||
self.captures.push(capture);
|
match crate::export::flipper::import_sub(path, self.next_capture_id) {
|
||||||
imported += 1;
|
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) => {
|
} else {
|
||||||
tracing::warn!("Failed to import {:?}: {}", path, e);
|
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 {
|
if imported > 0 {
|
||||||
self.selected_capture = Some(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(())
|
Ok(())
|
||||||
@@ -1057,6 +1140,7 @@ impl App {
|
|||||||
data_count_bit: 64,
|
data_count_bit: 64,
|
||||||
raw_pairs: vec![],
|
raw_pairs: vec![],
|
||||||
status: crate::capture::CaptureStatus::EncoderCapable,
|
status: crate::capture::CaptureStatus::EncoderCapable,
|
||||||
|
received_rf: None,
|
||||||
};
|
};
|
||||||
self.next_capture_id += 1;
|
self.next_capture_id += 1;
|
||||||
self.captures.push(capture);
|
self.captures.push(capture);
|
||||||
|
|||||||
+16
-1
@@ -58,6 +58,9 @@ pub struct Capture {
|
|||||||
pub raw_pairs: Vec<StoredLevelDuration>,
|
pub raw_pairs: Vec<StoredLevelDuration>,
|
||||||
/// Current status
|
/// Current status
|
||||||
pub status: CaptureStatus,
|
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)
|
/// Modulation type used by protocol (encoding: PWM vs Manchester)
|
||||||
@@ -104,8 +107,19 @@ impl std::fmt::Display for RfModulation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Capture {
|
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 {
|
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 {
|
Self {
|
||||||
id,
|
id,
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
@@ -119,6 +133,7 @@ impl Capture {
|
|||||||
data_count_bit: 0,
|
data_count_bit: 0,
|
||||||
raw_pairs: pairs,
|
raw_pairs: pairs,
|
||||||
status: CaptureStatus::Unknown,
|
status: CaptureStatus::Unknown,
|
||||||
|
received_rf,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-3
@@ -1,12 +1,12 @@
|
|||||||
//! Flipper Zero .sub export format.
|
//! 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.
|
//! positive (high) and negative (low) durations in microseconds.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::capture::Capture;
|
use crate::capture::{Capture, CaptureStatus, StoredLevelDuration};
|
||||||
|
|
||||||
/// Export a capture to Flipper Zero .sub RAW format
|
/// Export a capture to Flipper Zero .sub RAW format
|
||||||
pub fn export_flipper_sub(capture: &Capture, path: &Path) -> Result<()> {
|
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);
|
tracing::info!("Exported Flipper .sub to {:?}", path);
|
||||||
Ok(())
|
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 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<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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result<Capture> {
|
|||||||
data_count_bit: sig.data_bits,
|
data_count_bit: sig.data_bits,
|
||||||
raw_pairs,
|
raw_pairs,
|
||||||
status,
|
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,
|
data_count_bit: cap.data_bits,
|
||||||
raw_pairs,
|
raw_pairs,
|
||||||
status,
|
status,
|
||||||
|
received_rf: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
|||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
app.toggle_receiving()?;
|
app.toggle_receiving()?;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
let _ = app.delete_selected_capture();
|
||||||
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
// Open signal action menu if a capture is selected
|
// Open signal action menu if a capture is selected
|
||||||
if app.selected_capture.is_some() && !app.captures.is_empty() {
|
if app.selected_capture.is_some() && !app.captures.is_empty() {
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const GAP_US: u32 = 800;
|
|||||||
const TOTAL_BURSTS: u8 = 3;
|
const TOTAL_BURSTS: u8 = 3;
|
||||||
const INTER_BURST_GAP: u32 = 25000;
|
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)
|
/// Manchester state machine states (matches Flipper's manchester_decoder.h, same as Ford V0)
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
enum ManchesterState {
|
enum ManchesterState {
|
||||||
@@ -154,7 +160,7 @@ impl ProtocolDecoder for FiatV0Decoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn supported_frequencies(&self) -> &[u32] {
|
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) {
|
fn reset(&mut self) {
|
||||||
@@ -172,12 +178,14 @@ impl ProtocolDecoder for FiatV0Decoder {
|
|||||||
|
|
||||||
fn feed(&mut self, level: bool, duration: u32) -> Option<DecodedSignal> {
|
fn feed(&mut self, level: bool, duration: u32) -> Option<DecodedSignal> {
|
||||||
match self.step {
|
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 => {
|
DecoderStep::Reset => {
|
||||||
if !level {
|
if !level {
|
||||||
return None;
|
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_low = 0;
|
||||||
self.data_high = 0;
|
self.data_high = 0;
|
||||||
self.step = DecoderStep::Preamble;
|
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.
|
// 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 => {
|
DecoderStep::Preamble => {
|
||||||
if level {
|
if level {
|
||||||
return None;
|
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;
|
let gap_ok = duration_diff!(duration, GAP_US) < TE_DELTA;
|
||||||
|
|
||||||
if short_ok {
|
if short_ok {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
//! - BS (byte swap) magic calculation
|
//! - BS (byte swap) magic calculation
|
||||||
//! - 6 bursts, 4 preamble pairs, 3500µs gap
|
//! - 6 bursts, 4 preamble pairs, 3500µs gap
|
||||||
//!
|
//!
|
||||||
//! HackRF-specific: `TE_DELTA` (200µs) and `GAP_TOLERANCE` (1500µs) are wider than reference
|
//! Timing matches reference: te_delta 100µs, gap tolerance 250µs.
|
||||||
//! (100µs / 250µs) for software demodulator tolerance.
|
|
||||||
|
|
||||||
use super::{ProtocolDecoder, ProtocolTiming, DecodedSignal};
|
use super::{ProtocolDecoder, ProtocolTiming, DecodedSignal};
|
||||||
use crate::radio::demodulator::LevelDuration;
|
use crate::radio::demodulator::LevelDuration;
|
||||||
@@ -19,12 +18,12 @@ use crate::duration_diff;
|
|||||||
|
|
||||||
const TE_SHORT: u32 = 250;
|
const TE_SHORT: u32 = 250;
|
||||||
const TE_LONG: u32 = 500;
|
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 MIN_COUNT_BIT: usize = 64;
|
||||||
const TOTAL_BURSTS: u8 = 6;
|
const TOTAL_BURSTS: u8 = 6;
|
||||||
const PREAMBLE_PAIRS: usize = 4;
|
const PREAMBLE_PAIRS: usize = 4;
|
||||||
const GAP_US: u32 = 3500;
|
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
|
// CRC matrix for Ford V0 — GF(2) matrix multiplication
|
||||||
// Copied directly from protopirate's ford_v0.c
|
// Copied directly from protopirate's ford_v0.c
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
//! Protocols are aligned with the ProtoPirate reference (`REFERENCES/ProtoPirate/protocols/`).
|
//! Protocols are aligned with the ProtoPirate reference (`REFERENCES/ProtoPirate/protocols/`).
|
||||||
//! Each decoder processes level+duration pairs from the demodulator and optionally supports
|
//! Each decoder processes level+duration pairs from the demodulator and optionally supports
|
||||||
//! encoding (replay). Shared pieces: [common], [keeloq_common], [keys], [aut64].
|
//! 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;
|
mod common;
|
||||||
pub mod keeloq_common;
|
pub mod keeloq_common;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::duration_diff;
|
|||||||
|
|
||||||
const TE_SHORT: u32 = 800;
|
const TE_SHORT: u32 = 800;
|
||||||
const TE_LONG: u32 = 1600;
|
const TE_LONG: u32 = 1600;
|
||||||
const TE_DELTA: u32 = 300;
|
const TE_DELTA: u32 = 200; // ref subaru.c
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const MIN_COUNT_BIT: usize = 64;
|
const MIN_COUNT_BIT: usize = 64;
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ impl SubaruDecoder {
|
|||||||
((hi as u16) << 8) | (lo as u16)
|
((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) {
|
fn add_level(signal: &mut Vec<LevelDuration>, level: bool, duration: u32) {
|
||||||
if let Some(last) = signal.last_mut() {
|
if let Some(last) = signal.last_mut() {
|
||||||
if last.level == level {
|
if last.level == level {
|
||||||
@@ -293,7 +293,7 @@ impl ProtocolDecoder for SubaruDecoder {
|
|||||||
let key = decoded.data;
|
let key = decoded.data;
|
||||||
let mut signal = Vec::with_capacity(512);
|
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 {
|
for burst in 0..3 {
|
||||||
if burst > 0 {
|
if burst > 0 {
|
||||||
Self::add_level(&mut signal, false, 25000);
|
Self::add_level(&mut signal, false, 25000);
|
||||||
|
|||||||
+95
-76
@@ -2,8 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Aligned with ProtoPirate reference: `REFERENCES/ProtoPirate/protocols/vag.c`.
|
//! 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.
|
//! 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);
|
//! Reset/Preamble2/Sync2/Data2 use reference thresholds (79/80, 380-620/880-1120µs).
|
||||||
//! Preamble2/Sync2C accept shorter LOW pulses for asymmetric software demodulation.
|
//! Preamble1/Data1 use TE_DELTA_12 80 (ref 79/80).
|
||||||
//!
|
//!
|
||||||
//! Protocol characteristics:
|
//! Protocol characteristics:
|
||||||
//! - Manchester encoding; 80 bits (key1 64 + key2 16)
|
//! - 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)
|
// Type 3/4 timing (used as default for ProtocolTiming)
|
||||||
const TE_SHORT: u32 = 500;
|
const TE_SHORT: u32 = 500;
|
||||||
const TE_LONG: u32 = 1000;
|
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)]
|
#[allow(dead_code)]
|
||||||
const MIN_COUNT_BIT: usize = 80;
|
const MIN_COUNT_BIT: usize = 80;
|
||||||
|
|
||||||
// Type 1/2 timing
|
// Type 1/2 timing
|
||||||
const TE_SHORT_12: u32 = 300;
|
const TE_SHORT_12: u32 = 300;
|
||||||
const TE_LONG_12: u32 = 600;
|
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
|
// TEA constants
|
||||||
const TEA_DELTA: u32 = 0x9E3779B9;
|
const TEA_DELTA: u32 = 0x9E3779B9;
|
||||||
@@ -805,12 +811,12 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
if !level {
|
if !level {
|
||||||
return None;
|
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 duration < TE_SHORT_12 {
|
||||||
if (TE_SHORT_12 - duration) > TE_DELTA_12 {
|
if (TE_SHORT_12 - duration) > REF_RESET_DELTA {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Init pattern 1 (Type 1/2)
|
// init_pattern1
|
||||||
self.step = DecoderStep::Preamble1;
|
self.step = DecoderStep::Preamble1;
|
||||||
self.data_low = 0;
|
self.data_low = 0;
|
||||||
self.data_high = 0;
|
self.data_high = 0;
|
||||||
@@ -820,8 +826,8 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
self.vag_type = VagType::Unknown;
|
self.vag_type = VagType::Unknown;
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.manchester_advance(ManchesterEvent::Reset);
|
self.manchester_advance(ManchesterEvent::Reset);
|
||||||
} else if (duration.wrapping_sub(TE_SHORT_12)) <= TE_DELTA_12 {
|
} else if duration.wrapping_sub(TE_SHORT_12) <= REF_RESET_DELTA {
|
||||||
// Still pattern 1
|
// Fall-through to init_pattern1 in ref (duration 300..380)
|
||||||
self.step = DecoderStep::Preamble1;
|
self.step = DecoderStep::Preamble1;
|
||||||
self.data_low = 0;
|
self.data_low = 0;
|
||||||
self.data_high = 0;
|
self.data_high = 0;
|
||||||
@@ -832,9 +838,13 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.manchester_advance(ManchesterEvent::Reset);
|
self.manchester_advance(ManchesterEvent::Reset);
|
||||||
} else {
|
} else {
|
||||||
// Check for pattern 2 (Type 3/4, 500µs)
|
// (duration - 300) > 79: check 500±79 for Preamble2
|
||||||
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
|
let diff = if duration < TE_SHORT {
|
||||||
if diff <= TE_DELTA_12 {
|
TE_SHORT - duration
|
||||||
|
} else {
|
||||||
|
duration - TE_SHORT
|
||||||
|
};
|
||||||
|
if diff <= REF_RESET_DELTA {
|
||||||
self.step = DecoderStep::Preamble2;
|
self.step = DecoderStep::Preamble2;
|
||||||
self.data_low = 0;
|
self.data_low = 0;
|
||||||
self.data_high = 0;
|
self.data_high = 0;
|
||||||
@@ -979,21 +989,20 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Preamble2 => {
|
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 {
|
if !level {
|
||||||
// Low pulse during preamble. With HackRF software demodulation,
|
let diff = if duration < TE_SHORT {
|
||||||
// the LOW pulses can be severely shortened by threshold asymmetry.
|
TE_SHORT - duration
|
||||||
// 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 {
|
} else {
|
||||||
TE_SHORT - self.te_last
|
duration - TE_SHORT
|
||||||
};
|
};
|
||||||
if prev_high_diff < TE_DELTA {
|
if diff < REF_PREAMBLE_SYNC {
|
||||||
// Previous HIGH was valid. Accept LOWs up to 2*TE_SHORT
|
let prev_diff = if self.te_last < TE_SHORT {
|
||||||
// (covers both symmetric and asymmetric demodulation).
|
TE_SHORT - self.te_last
|
||||||
if duration < TE_SHORT * 2 {
|
} else {
|
||||||
|
self.te_last - TE_SHORT
|
||||||
|
};
|
||||||
|
if prev_diff < REF_PREAMBLE_SYNC {
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.header_count += 1;
|
self.header_count += 1;
|
||||||
return None;
|
return None;
|
||||||
@@ -1003,54 +1012,63 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// High pulse — check for preamble continuation or sync transition
|
|
||||||
if self.header_count < 41 {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sufficient preamble — check for 1000µs sync HIGH
|
let diff = if duration < TE_LONG {
|
||||||
let diff = if duration > TE_LONG { duration - TE_LONG } else { TE_LONG - duration };
|
TE_LONG - duration
|
||||||
if diff <= TE_DELTA {
|
} else {
|
||||||
self.te_last = duration;
|
duration - TE_LONG
|
||||||
self.step = DecoderStep::Sync2A;
|
};
|
||||||
|
if diff > REF_RESET_DELTA {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let prev_diff = if self.te_last < TE_SHORT {
|
||||||
// Not sync — might be another preamble HIGH
|
TE_SHORT - self.te_last
|
||||||
let hi_diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
|
} else {
|
||||||
if hi_diff < TE_DELTA {
|
self.te_last - TE_SHORT
|
||||||
self.te_last = duration;
|
};
|
||||||
|
if prev_diff > REF_RESET_DELTA {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
self.te_last = duration;
|
||||||
// Neither preamble nor sync — reset
|
self.step = DecoderStep::Sync2A;
|
||||||
self.step = DecoderStep::Reset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Sync2A => {
|
DecoderStep::Sync2A => {
|
||||||
|
// Matches vag.c: LOW 500±80 and te_last 1000±80 -> Sync2B
|
||||||
if !level {
|
if !level {
|
||||||
// Sync LOW after 1000µs HIGH. Accept LOWs within TE_DELTA of
|
let diff = if duration < TE_SHORT {
|
||||||
// TE_SHORT, or any short LOW from asymmetric demodulation.
|
TE_SHORT - duration
|
||||||
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
|
} else {
|
||||||
if diff < TE_DELTA || duration < TE_SHORT {
|
duration - TE_SHORT
|
||||||
self.te_last = duration;
|
};
|
||||||
self.step = DecoderStep::Sync2B;
|
if diff < REF_PREAMBLE_SYNC {
|
||||||
return None;
|
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;
|
self.step = DecoderStep::Reset;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Sync2B => {
|
DecoderStep::Sync2B => {
|
||||||
|
// Matches vag.c: HIGH 750±80 -> Sync2C
|
||||||
if level {
|
if level {
|
||||||
// Expect ~750µs HIGH sync pulse
|
let diff = if duration < 750 {
|
||||||
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
750 - duration
|
||||||
if diff < TE_DELTA {
|
} else {
|
||||||
|
duration - 750
|
||||||
|
};
|
||||||
|
if diff < REF_PREAMBLE_SYNC {
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.step = DecoderStep::Sync2C;
|
self.step = DecoderStep::Sync2C;
|
||||||
return None;
|
return None;
|
||||||
@@ -1060,41 +1078,42 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Sync2C => {
|
DecoderStep::Sync2C => {
|
||||||
|
// Matches vag.c: LOW 750±79 and te_last 750±79 (diff<=79), mid_count++; at 3 -> Data2
|
||||||
if !level {
|
if !level {
|
||||||
// Expect ~750µs LOW sync pulse, but accept shorter from
|
let diff = if duration < 750 {
|
||||||
// asymmetric demodulation (as low as ~200µs)
|
750 - duration
|
||||||
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
|
||||||
let prev_diff = if self.te_last > 750 {
|
|
||||||
self.te_last - 750
|
|
||||||
} else {
|
} else {
|
||||||
750 - self.te_last
|
duration - 750
|
||||||
};
|
};
|
||||||
if (diff <= TE_DELTA || duration < 750) && prev_diff <= TE_DELTA {
|
if diff <= REF_SYNC2C_DELTA {
|
||||||
self.mid_count += 1;
|
let prev_diff = if self.te_last < 750 {
|
||||||
self.step = DecoderStep::Sync2B;
|
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 {
|
if self.mid_count == 3 {
|
||||||
self.data_low = 1;
|
self.data_low = 1;
|
||||||
self.data_high = 0;
|
self.data_high = 0;
|
||||||
self.bit_count = 1;
|
self.bit_count = 1;
|
||||||
self.manchester_advance(ManchesterEvent::Reset);
|
self.manchester_advance(ManchesterEvent::Reset);
|
||||||
self.step = DecoderStep::Data2;
|
self.step = DecoderStep::Data2;
|
||||||
|
}
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.step = DecoderStep::Reset;
|
self.step = DecoderStep::Reset;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Data2 => {
|
DecoderStep::Data2 => {
|
||||||
// Determine Manchester event for Type 3/4 (500/1000µs)
|
// Matches vag.c: short 380-620µs, long 880-1120µs
|
||||||
// Use nearest-match with wider tolerance for HackRF demodulation.
|
let event = if duration >= 380 && duration <= 620 {
|
||||||
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 })
|
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 })
|
Some(if level { ManchesterEvent::LongLow } else { ManchesterEvent::LongHigh })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
+171
-13
@@ -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
|
//! Two demodulators run in parallel on the same IQ stream:
|
||||||
//! are tagged with AM/FM/Both (from ProtoPirate) for display and export. FM protocols
|
//! - **Demodulator** (AM): envelope detection → level/duration pairs for OOK/AM protocols.
|
||||||
//! may still decode when the received signal is strong enough to produce a usable
|
//! - **FmDemodulator** (FM): phase discriminator → instantaneous frequency → level/duration for 2FSK.
|
||||||
//! envelope; proper FM support would require a separate demodulator path.
|
|
||||||
//!
|
//!
|
||||||
//! This demodulator converts raw IQ samples into a stream of (level, duration_us) pairs
|
//! Protocols are tagged AM/FM/Both (ProtoPirate). Captures record which path produced them
|
||||||
//! that can be processed by protocol decoders, similar to how the Flipper Zero SubGHz
|
//! (`received_rf`). Decoders and encoders are agnostic; TX remains OOK/AM.
|
||||||
//! system works.
|
|
||||||
//!
|
//!
|
||||||
//! Key design decisions for HackRF (vs Flipper's CC1101 hardware slicer):
|
//! AM key design: adaptive threshold, hysteresis, debounce, transition-based threshold updates.
|
||||||
//! - Adaptive threshold with hysteresis to prevent chattering at decision boundary
|
//! FM key design: phase diff → freq (Hz), EMA smoothing, zero-centered threshold with hysteresis.
|
||||||
//! - 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
|
/// A single level+duration pair representing one segment of the signal
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[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. 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<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
|
// Note: duration_diff macro is defined in protocols/mod.rs
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+59
-39
@@ -13,9 +13,10 @@ use std::sync::{
|
|||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use crate::app::RadioEvent;
|
use crate::app::RadioEvent;
|
||||||
use crate::capture::Capture;
|
use crate::capture::{Capture, RfModulation, StoredLevelDuration};
|
||||||
|
|
||||||
use super::demodulator::Demodulator;
|
use super::demodulator::Demodulator;
|
||||||
|
use super::demodulator::FmDemodulator;
|
||||||
use super::demodulator::LevelDuration;
|
use super::demodulator::LevelDuration;
|
||||||
|
|
||||||
/// Sample rate for HackRF (2 MHz is good for keyfob signals)
|
/// Sample rate for HackRF (2 MHz is good for keyfob signals)
|
||||||
@@ -49,8 +50,10 @@ pub struct HackRfController {
|
|||||||
rx_thread: Option<JoinHandle<()>>,
|
rx_thread: Option<JoinHandle<()>>,
|
||||||
/// Current frequency
|
/// Current frequency
|
||||||
frequency: Arc<Mutex<u32>>,
|
frequency: Arc<Mutex<u32>>,
|
||||||
/// Demodulator for processing samples
|
/// AM/OOK demodulator
|
||||||
demodulator: Arc<Mutex<Demodulator>>,
|
demodulator_am: Arc<Mutex<Demodulator>>,
|
||||||
|
/// FM/2FSK demodulator
|
||||||
|
demodulator_fm: Arc<Mutex<FmDemodulator>>,
|
||||||
/// Whether HackRF is available
|
/// Whether HackRF is available
|
||||||
hackrf_available: bool,
|
hackrf_available: bool,
|
||||||
/// Shared gain settings (read by receiver thread)
|
/// Shared gain settings (read by receiver thread)
|
||||||
@@ -60,7 +63,8 @@ pub struct HackRfController {
|
|||||||
impl HackRfController {
|
impl HackRfController {
|
||||||
/// Create a new HackRF controller
|
/// Create a new HackRF controller
|
||||||
pub fn new(event_tx: Sender<RadioEvent>) -> Result<Self> {
|
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
|
// Check if HackRF is available
|
||||||
let hackrf_available = check_hackrf_available();
|
let hackrf_available = check_hackrf_available();
|
||||||
@@ -76,7 +80,8 @@ impl HackRfController {
|
|||||||
receiving: Arc::new(AtomicBool::new(false)),
|
receiving: Arc::new(AtomicBool::new(false)),
|
||||||
rx_thread: None,
|
rx_thread: None,
|
||||||
frequency: Arc::new(Mutex::new(433_920_000)),
|
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,
|
hackrf_available,
|
||||||
gain_settings: Arc::new(Mutex::new(GainSettings::default())),
|
gain_settings: Arc::new(Mutex::new(GainSettings::default())),
|
||||||
})
|
})
|
||||||
@@ -100,14 +105,21 @@ impl HackRfController {
|
|||||||
let receiving = self.receiving.clone();
|
let receiving = self.receiving.clone();
|
||||||
let event_tx = self.event_tx.clone();
|
let event_tx = self.event_tx.clone();
|
||||||
let freq = self.frequency.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 hackrf_available = self.hackrf_available;
|
||||||
let gain_settings = self.gain_settings.clone();
|
let gain_settings = self.gain_settings.clone();
|
||||||
|
|
||||||
self.rx_thread = Some(thread::spawn(move || {
|
self.rx_thread = Some(thread::spawn(move || {
|
||||||
if hackrf_available {
|
if hackrf_available {
|
||||||
if let Err(e) =
|
if let Err(e) = run_receiver_hackrf(
|
||||||
run_receiver_hackrf(receiving.clone(), event_tx.clone(), freq, demodulator, gain_settings)
|
receiving.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
freq,
|
||||||
|
demodulator_am,
|
||||||
|
demodulator_fm,
|
||||||
|
gain_settings,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
let _ = event_tx.send(RadioEvent::Error(format!("Receiver error: {}", e)));
|
let _ = event_tx.send(RadioEvent::Error(format!("Receiver error: {}", e)));
|
||||||
}
|
}
|
||||||
@@ -249,46 +261,58 @@ struct RxState {
|
|||||||
receiving: Arc<AtomicBool>,
|
receiving: Arc<AtomicBool>,
|
||||||
event_tx: Sender<RadioEvent>,
|
event_tx: Sender<RadioEvent>,
|
||||||
frequency: Arc<Mutex<u32>>,
|
frequency: Arc<Mutex<u32>>,
|
||||||
demodulator: Arc<Mutex<Demodulator>>,
|
demodulator_am: Arc<Mutex<Demodulator>>,
|
||||||
|
demodulator_fm: Arc<Mutex<FmDemodulator>>,
|
||||||
capture_id: std::sync::atomic::AtomicU32,
|
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(
|
fn rx_callback(
|
||||||
_hackrf: &libhackrf::HackRf,
|
_hackrf: &libhackrf::HackRf,
|
||||||
buffer: &[num_complex::Complex<i8>],
|
buffer: &[num_complex::Complex<i8>],
|
||||||
user_data: &dyn std::any::Any,
|
user_data: &dyn std::any::Any,
|
||||||
) {
|
) {
|
||||||
use crate::capture::StoredLevelDuration;
|
|
||||||
|
|
||||||
// Downcast user_data to our state
|
|
||||||
let state = match user_data.downcast_ref::<RxState>() {
|
let state = match user_data.downcast_ref::<RxState>() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !state.receiving.load(Ordering::SeqCst) {
|
if !state.receiving.load(Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_freq = *state.frequency.lock().unwrap();
|
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
|
if let Ok(mut demod) = state.demodulator_am.lock() {
|
||||||
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 Some(pairs) = demod.process_samples(&samples) {
|
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 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));
|
let _ = state.event_tx.send(RadioEvent::SignalCaptured(capture));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,14 +323,14 @@ fn run_receiver_hackrf(
|
|||||||
receiving: Arc<AtomicBool>,
|
receiving: Arc<AtomicBool>,
|
||||||
event_tx: Sender<RadioEvent>,
|
event_tx: Sender<RadioEvent>,
|
||||||
frequency: Arc<Mutex<u32>>,
|
frequency: Arc<Mutex<u32>>,
|
||||||
demodulator: Arc<Mutex<Demodulator>>,
|
demodulator_am: Arc<Mutex<Demodulator>>,
|
||||||
|
demodulator_fm: Arc<Mutex<FmDemodulator>>,
|
||||||
gain_settings: Arc<Mutex<GainSettings>>,
|
gain_settings: Arc<Mutex<GainSettings>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
tracing::info!("HackRF receiver thread starting...");
|
tracing::info!("HackRF receiver thread starting...");
|
||||||
|
|
||||||
// Open HackRF device
|
|
||||||
let hackrf = libhackrf::HackRf::open()
|
let hackrf = libhackrf::HackRf::open()
|
||||||
.context("Failed to open HackRF device")?;
|
.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
|
freq, SAMPLE_RATE, initial_gains.lna_gain, initial_gains.vga_gain, initial_gains.amp_enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure HackRF
|
|
||||||
hackrf.set_sample_rate(SAMPLE_RATE)
|
hackrf.set_sample_rate(SAMPLE_RATE)
|
||||||
.context("Failed to set sample rate")?;
|
.context("Failed to set sample rate")?;
|
||||||
|
|
||||||
hackrf.set_freq(freq as u64)
|
hackrf.set_freq(freq as u64)
|
||||||
.context("Failed to set frequency")?;
|
.context("Failed to set frequency")?;
|
||||||
|
|
||||||
hackrf.set_lna_gain(initial_gains.lna_gain)
|
hackrf.set_lna_gain(initial_gains.lna_gain)
|
||||||
.context("Failed to set LNA gain")?;
|
.context("Failed to set LNA gain")?;
|
||||||
|
|
||||||
hackrf.set_rxvga_gain(initial_gains.vga_gain)
|
hackrf.set_rxvga_gain(initial_gains.vga_gain)
|
||||||
.context("Failed to set RXVGA gain")?;
|
.context("Failed to set RXVGA gain")?;
|
||||||
|
|
||||||
hackrf.set_amp_enable(initial_gains.amp_enabled)
|
hackrf.set_amp_enable(initial_gains.amp_enabled)
|
||||||
.context("Failed to enable amp")?;
|
.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 {
|
let state = RxState {
|
||||||
receiving: receiving.clone(),
|
receiving: receiving.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
frequency: frequency.clone(),
|
frequency: frequency.clone(),
|
||||||
demodulator,
|
demodulator_am,
|
||||||
|
demodulator_fm,
|
||||||
capture_id: std::sync::atomic::AtomicU32::new(0),
|
capture_id: std::sync::atomic::AtomicU32::new(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Start receiving
|
// Start receiving
|
||||||
hackrf.start_rx(rx_callback, state)
|
hackrf.start_rx(rx_callback, state)
|
||||||
.context("Failed to start RX")?;
|
.context("Failed to start RX")?;
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ pub use hackrf::HackRfController;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use demodulator::Demodulator;
|
pub use demodulator::Demodulator;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
pub use demodulator::FmDemodulator;
|
||||||
|
#[allow(unused_imports)]
|
||||||
pub use modulator::Modulator;
|
pub use modulator::Modulator;
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
Span::styled(make, value_style),
|
Span::styled(make, value_style),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Row 2: Frequency + Encoding (Mod) + RF (AM/FM) + Encryption
|
// Row 2: Freq + Mod + RF (protocol) + Enc + Rx (demodulator path when known)
|
||||||
left_lines.push(Line::from(vec![
|
let mut row2 = vec![
|
||||||
Span::styled(" Freq: ", label_style),
|
Span::styled(" Freq: ", label_style),
|
||||||
Span::styled(capture.frequency_mhz(), value_style),
|
Span::styled(capture.frequency_mhz(), value_style),
|
||||||
Span::styled(" Mod: ", label_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(capture.rf_modulation().to_string(), value_style),
|
||||||
Span::styled(" Enc: ", label_style),
|
Span::styled(" Enc: ", label_style),
|
||||||
Span::styled(capture.encryption_type(), value_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
|
// Row 3: Full Serial + Button
|
||||||
left_lines.push(Line::from(vec![
|
left_lines.push(Line::from(vec![
|
||||||
|
|||||||
+3
-3
@@ -102,7 +102,7 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
RadioState::Transmitting => ("●", Style::default().fg(Color::Red)),
|
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
|
// Build radio info string with all settings
|
||||||
let amp_str = if app.amp_enabled { "ON" } else { "OFF" };
|
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) {
|
fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let help_text = match app.input_mode {
|
let help_text = match app.input_mode {
|
||||||
InputMode::Normal => {
|
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::Command => "Enter: Execute | Esc: Cancel",
|
||||||
InputMode::SignalMenu => "Up/Down: Navigate | Enter: Select | Esc: Close",
|
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![
|
let text = vec![
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(Span::styled(
|
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),
|
Style::default().fg(Color::Yellow),
|
||||||
)),
|
)),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
|
|||||||
Reference in New Issue
Block a user