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