better working signals
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
/REFERENCES
|
/REFERENCES
|
||||||
|
/EXPORTS
|
||||||
|
|||||||
+74
-16
@@ -23,6 +23,8 @@ pub enum InputMode {
|
|||||||
SettingsEdit,
|
SettingsEdit,
|
||||||
/// Startup prompt: found .fob files, import? (y/n)
|
/// Startup prompt: found .fob files, import? (y/n)
|
||||||
StartupImport,
|
StartupImport,
|
||||||
|
/// Export: editing filename (before format-specific steps)
|
||||||
|
ExportFilename,
|
||||||
/// Fob export metadata: editing year field
|
/// Fob export metadata: editing year field
|
||||||
FobMetaYear,
|
FobMetaYear,
|
||||||
/// Fob export metadata: editing make field
|
/// Fob export metadata: editing make field
|
||||||
@@ -35,6 +37,13 @@ pub enum InputMode {
|
|||||||
FobMetaNotes,
|
FobMetaNotes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export format being used
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ExportFormat {
|
||||||
|
Fob,
|
||||||
|
Flipper,
|
||||||
|
}
|
||||||
|
|
||||||
/// Items available in the signal action menu
|
/// Items available in the signal action menu
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SignalAction {
|
pub enum SignalAction {
|
||||||
@@ -208,9 +217,15 @@ pub struct App {
|
|||||||
/// .fob files found on startup in export_dir
|
/// .fob files found on startup in export_dir
|
||||||
pub pending_fob_files: Vec<std::path::PathBuf>,
|
pub pending_fob_files: Vec<std::path::PathBuf>,
|
||||||
|
|
||||||
// -- .fob export metadata state --
|
// -- Export state --
|
||||||
/// Capture ID being exported (set before entering FobMeta modes)
|
/// Capture ID being exported
|
||||||
pub export_capture_id: Option<u32>,
|
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
|
/// Year input buffer
|
||||||
pub fob_meta_year: String,
|
pub fob_meta_year: String,
|
||||||
/// Make input buffer
|
/// Make input buffer
|
||||||
@@ -238,8 +253,12 @@ impl App {
|
|||||||
|
|
||||||
// Try to initialize HackRF
|
// Try to initialize HackRF
|
||||||
let hackrf = match HackRfController::new(radio_event_tx.clone()) {
|
let hackrf = match HackRfController::new(radio_event_tx.clone()) {
|
||||||
Ok(h) => {
|
Ok(mut h) => {
|
||||||
tracing::info!("HackRF initialized successfully");
|
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)
|
Some(h)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -298,6 +317,8 @@ impl App {
|
|||||||
radio_event_tx,
|
radio_event_tx,
|
||||||
pending_fob_files,
|
pending_fob_files,
|
||||||
export_capture_id: None,
|
export_capture_id: None,
|
||||||
|
export_filename: String::new(),
|
||||||
|
export_format: None,
|
||||||
fob_meta_year: String::new(),
|
fob_meta_year: String::new(),
|
||||||
fob_meta_make: String::new(),
|
fob_meta_make: String::new(),
|
||||||
fob_meta_model: String::new(),
|
fob_meta_model: String::new(),
|
||||||
@@ -776,25 +797,41 @@ impl App {
|
|||||||
Ok(())
|
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<()> {
|
pub fn export_fob(&mut self, id: u32) -> Result<()> {
|
||||||
if !self.captures.iter().any(|c| c.id == id) {
|
if !self.captures.iter().any(|c| c.id == id) {
|
||||||
self.last_error = Some(format!("Capture {} not found", id));
|
self.last_error = Some(format!("Capture {} not found", id));
|
||||||
return Ok(());
|
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
|
// Pre-fill make from protocol
|
||||||
let make = self.captures.iter().find(|c| c.id == id).map(|c| {
|
let make = self.captures.iter().find(|c| c.id == id).map(|c| {
|
||||||
Self::get_make_for_protocol(c.protocol_name()).to_string()
|
Self::get_make_for_protocol(c.protocol_name()).to_string()
|
||||||
}).unwrap_or_default();
|
}).unwrap_or_default();
|
||||||
|
|
||||||
self.export_capture_id = Some(id);
|
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_year = String::new();
|
||||||
self.fob_meta_make = make;
|
self.fob_meta_make = make;
|
||||||
self.fob_meta_model = String::new();
|
self.fob_meta_model = String::new();
|
||||||
self.fob_meta_region = String::new();
|
self.fob_meta_region = String::new();
|
||||||
self.fob_meta_notes = String::new();
|
self.fob_meta_notes = String::new();
|
||||||
self.input_mode = InputMode::FobMetaYear;
|
self.input_mode = InputMode::ExportFilename;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,11 +866,7 @@ impl App {
|
|||||||
notes: self.fob_meta_notes.clone(),
|
notes: self.fob_meta_notes.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let filename = format!(
|
let filename = format!("{}.fob", self.export_filename);
|
||||||
"{}_{}.fob",
|
|
||||||
capture.protocol_name().replace(' ', "_").to_lowercase(),
|
|
||||||
capture.serial_hex()
|
|
||||||
);
|
|
||||||
let path = export_dir.join(&filename);
|
let path = export_dir.join(&filename);
|
||||||
|
|
||||||
crate::export::fob::export_fob(
|
crate::export::fob::export_fob(
|
||||||
@@ -844,6 +877,7 @@ impl App {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
self.export_capture_id = None;
|
self.export_capture_id = None;
|
||||||
|
self.export_format = None;
|
||||||
self.status_message = Some(format!("Exported to {}", filename));
|
self.status_message = Some(format!("Exported to {}", filename));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -880,8 +914,34 @@ impl App {
|
|||||||
self.status_message = Some("Starting with no imported signals".to_string());
|
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<()> {
|
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) {
|
let capture = match self.captures.iter().find(|c| c.id == id) {
|
||||||
Some(c) => c.clone(),
|
Some(c) => c.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -895,14 +955,12 @@ impl App {
|
|||||||
std::fs::create_dir_all(&export_dir)?;
|
std::fs::create_dir_all(&export_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let filename = format!(
|
let filename = format!("{}.sub", self.export_filename);
|
||||||
"{}_{}.sub",
|
|
||||||
capture.protocol_name().replace(' ', "_").to_lowercase(),
|
|
||||||
capture.serial_hex()
|
|
||||||
);
|
|
||||||
let path = export_dir.join(&filename);
|
let path = export_dir.join(&filename);
|
||||||
|
|
||||||
crate::export::flipper::export_flipper_sub(&capture, &path)?;
|
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));
|
self.status_message = Some(format!("Exported to {}", filename));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -22,7 +22,7 @@ use std::io::{self, Write};
|
|||||||
use std::panic;
|
use std::panic;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use app::{App, InputMode, SignalAction, SettingsField};
|
use app::{App, InputMode, SignalAction, SettingsField, ExportFormat};
|
||||||
use ui::draw_ui;
|
use ui::draw_ui;
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
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
|
// .fob export metadata: Year
|
||||||
InputMode::FobMetaYear => match key.code {
|
InputMode::FobMetaYear => match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
|
|||||||
+24
-11
@@ -15,11 +15,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 = 100;
|
const TE_DELTA: u32 = 200; // Wider tolerance for HackRF software demodulation (was 100)
|
||||||
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
|
||||||
|
|
||||||
// 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
|
||||||
@@ -552,13 +553,16 @@ impl ProtocolDecoder for FordV0Decoder {
|
|||||||
// ─── Step 3: PreambleCheck — count preamble pairs or transition to gap ───
|
// ─── Step 3: PreambleCheck — count preamble pairs or transition to gap ───
|
||||||
DecoderStep::PreambleCheck => {
|
DecoderStep::PreambleCheck => {
|
||||||
if level {
|
if level {
|
||||||
if duration_diff!(duration, TE_LONG) < TE_DELTA {
|
let short_diff = duration_diff!(duration, TE_SHORT);
|
||||||
// Long HIGH: another preamble pair
|
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.header_count += 1;
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.step = DecoderStep::Preamble;
|
self.step = DecoderStep::Preamble;
|
||||||
} else if duration_diff!(duration, TE_SHORT) < TE_DELTA {
|
} else if short_diff < TE_DELTA {
|
||||||
// Short HIGH: end of preamble, transition to gap
|
// Short HIGH (closer to TE_SHORT): end of preamble, transition to gap
|
||||||
self.step = DecoderStep::Gap;
|
self.step = DecoderStep::Gap;
|
||||||
} else {
|
} else {
|
||||||
self.step = DecoderStep::Reset;
|
self.step = DecoderStep::Reset;
|
||||||
@@ -568,24 +572,33 @@ impl ProtocolDecoder for FordV0Decoder {
|
|||||||
|
|
||||||
// ─── Step 4: Gap — wait for ~3500µs LOW gap ───
|
// ─── Step 4: Gap — wait for ~3500µs LOW gap ───
|
||||||
DecoderStep::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
|
// Gap detected, start data collection
|
||||||
// First bit is implicitly 1 (matches protopirate)
|
// First bit is implicitly 1 (matches protopirate)
|
||||||
self.decode_data = 1;
|
self.decode_data = 1;
|
||||||
self.bit_count = 1;
|
self.bit_count = 1;
|
||||||
self.step = DecoderStep::Data;
|
self.step = DecoderStep::Data;
|
||||||
} else if !level && duration > GAP_US + 250 {
|
} else if !level && duration > GAP_US + GAP_TOLERANCE {
|
||||||
self.step = DecoderStep::Reset;
|
self.step = DecoderStep::Reset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 5: Data — Manchester decode 80 bits ───
|
// ─── Step 5: Data — Manchester decode 80 bits ───
|
||||||
DecoderStep::Data => {
|
DecoderStep::Data => {
|
||||||
// Map level+duration to Manchester event
|
// Map level+duration to Manchester event using NEAREST-MATCH.
|
||||||
// Flipper convention: level=true (HIGH) → Low event, level=false (LOW) → High event
|
// With TE_DELTA=200, SHORT(250) and LONG(500) ranges overlap at 300–450µs.
|
||||||
let event = if duration_diff!(duration, TE_SHORT) < TE_DELTA {
|
// 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
|
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
|
if level { 2 } else { 3 } // LongLow / LongHigh
|
||||||
} else {
|
} else {
|
||||||
self.step = DecoderStep::Reset;
|
self.step = DecoderStep::Reset;
|
||||||
|
|||||||
+40
-17
@@ -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 = 200;
|
const TE_DELTA: u32 = 300;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const MIN_COUNT_BIT: usize = 64;
|
const MIN_COUNT_BIT: usize = 64;
|
||||||
|
|
||||||
@@ -115,6 +115,19 @@ impl SubaruDecoder {
|
|||||||
((hi as u16) << 8) | (lo as u16)
|
((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
|
/// Process the decoded data
|
||||||
fn process_data(&self) -> Option<DecodedSignal> {
|
fn process_data(&self) -> Option<DecodedSignal> {
|
||||||
if self.bit_count < 64 {
|
if self.bit_count < 64 {
|
||||||
@@ -160,7 +173,7 @@ impl ProtocolDecoder for SubaruDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn supported_frequencies(&self) -> &[u32] {
|
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) {
|
fn reset(&mut self) {
|
||||||
@@ -284,38 +297,48 @@ 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);
|
||||||
|
|
||||||
// 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 {
|
for burst in 0..3 {
|
||||||
if burst > 0 {
|
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
|
// Preamble: 79 full pairs + 80th HIGH only.
|
||||||
for _ in 0..80 {
|
// The gap LOW replaces the 80th preamble LOW.
|
||||||
signal.push(LevelDuration::new(true, TE_LONG));
|
for i in 0..80 {
|
||||||
signal.push(LevelDuration::new(false, TE_LONG));
|
Self::add_level(&mut signal, true, TE_LONG);
|
||||||
|
if i < 79 {
|
||||||
|
Self::add_level(&mut signal, false, TE_LONG);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gap
|
// Gap (replaces the 80th preamble LOW)
|
||||||
signal.push(LevelDuration::new(false, GAP_US));
|
Self::add_level(&mut signal, false, GAP_US);
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
signal.push(LevelDuration::new(true, SYNC_US));
|
Self::add_level(&mut signal, true, SYNC_US);
|
||||||
signal.push(LevelDuration::new(false, TE_LONG));
|
Self::add_level(&mut signal, false, TE_LONG);
|
||||||
|
|
||||||
// Data: 64 bits (MSB first)
|
// Data: 64 bits (MSB first)
|
||||||
// Short HIGH = 1, Long HIGH = 0
|
// Short HIGH = 1, Long HIGH = 0
|
||||||
for bit in (0..64).rev() {
|
for bit in (0..64).rev() {
|
||||||
if (key >> bit) & 1 == 1 {
|
if (key >> bit) & 1 == 1 {
|
||||||
signal.push(LevelDuration::new(true, TE_SHORT));
|
Self::add_level(&mut signal, true, TE_SHORT);
|
||||||
} else {
|
} 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
|
// End-of-burst gap (extends the last data LOW)
|
||||||
signal.push(LevelDuration::new(false, TE_LONG * 2));
|
Self::add_level(&mut signal, false, TE_LONG * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(signal)
|
Some(signal)
|
||||||
|
|||||||
+45
-35
@@ -21,14 +21,14 @@ 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 = 80;
|
const TE_DELTA: u32 = 150; // Wider tolerance for HackRF software demodulation (was 80)
|
||||||
#[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 = 79;
|
const TE_DELTA_12: u32 = 120; // Wider tolerance for HackRF (was 79)
|
||||||
|
|
||||||
// TEA constants
|
// TEA constants
|
||||||
const TEA_DELTA: u32 = 0x9E3779B9;
|
const TEA_DELTA: u32 = 0x9E3779B9;
|
||||||
@@ -982,15 +982,20 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
|
|
||||||
DecoderStep::Preamble2 => {
|
DecoderStep::Preamble2 => {
|
||||||
if !level {
|
if !level {
|
||||||
// Low pulse - check if matches 500µs
|
// Low pulse during preamble. With HackRF software demodulation,
|
||||||
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
|
// the LOW pulses can be severely shortened by threshold asymmetry.
|
||||||
if diff < TE_DELTA {
|
// Instead of requiring LOWs to be ~500µs, accept any LOW that's
|
||||||
let prev_diff = if self.te_last > TE_SHORT {
|
// 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
|
self.te_last - TE_SHORT
|
||||||
} else {
|
} else {
|
||||||
TE_SHORT - self.te_last
|
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.te_last = duration;
|
||||||
self.header_count += 1;
|
self.header_count += 1;
|
||||||
return None;
|
return None;
|
||||||
@@ -1000,51 +1005,52 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// High pulse after sufficient preamble
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 };
|
let diff = if duration > TE_LONG { duration - TE_LONG } else { TE_LONG - duration };
|
||||||
if diff > TE_DELTA_12 {
|
if diff <= TE_DELTA {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.step = DecoderStep::Sync2A;
|
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 => {
|
DecoderStep::Sync2A => {
|
||||||
if !level {
|
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 };
|
let diff = if duration > TE_SHORT { duration - TE_SHORT } else { TE_SHORT - duration };
|
||||||
if diff < TE_DELTA {
|
if diff < TE_DELTA || duration < TE_SHORT {
|
||||||
let prev_diff = if self.te_last > TE_LONG {
|
|
||||||
self.te_last - TE_LONG
|
|
||||||
} else {
|
|
||||||
TE_LONG - self.te_last
|
|
||||||
};
|
|
||||||
if prev_diff < TE_DELTA {
|
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
self.step = DecoderStep::Sync2B;
|
self.step = DecoderStep::Sync2B;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.step = DecoderStep::Reset;
|
self.step = DecoderStep::Reset;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecoderStep::Sync2B => {
|
DecoderStep::Sync2B => {
|
||||||
if level {
|
if level {
|
||||||
|
// Expect ~750µs HIGH sync pulse
|
||||||
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
||||||
if diff < TE_DELTA {
|
if diff < TE_DELTA {
|
||||||
self.te_last = duration;
|
self.te_last = duration;
|
||||||
@@ -1057,14 +1063,15 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
|
|
||||||
DecoderStep::Sync2C => {
|
DecoderStep::Sync2C => {
|
||||||
if !level {
|
if !level {
|
||||||
|
// Expect ~750µs LOW sync pulse, but accept shorter from
|
||||||
|
// asymmetric demodulation (as low as ~200µs)
|
||||||
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
let diff = if duration > 750 { duration - 750 } else { 750 - duration };
|
||||||
if diff <= TE_DELTA_12 {
|
|
||||||
let prev_diff = if self.te_last > 750 {
|
let prev_diff = if self.te_last > 750 {
|
||||||
self.te_last - 750
|
self.te_last - 750
|
||||||
} else {
|
} else {
|
||||||
750 - self.te_last
|
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.mid_count += 1;
|
||||||
self.step = DecoderStep::Sync2B;
|
self.step = DecoderStep::Sync2B;
|
||||||
|
|
||||||
@@ -1078,15 +1085,18 @@ impl ProtocolDecoder for VagDecoder {
|
|||||||
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)
|
// 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 })
|
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 })
|
Some(if level { ManchesterEvent::LongLow } else { ManchesterEvent::LongHigh })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
+266
-47
@@ -3,6 +3,12 @@
|
|||||||
//! This demodulator converts raw IQ samples into a stream of (level, duration_us) pairs
|
//! 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
|
//! that can be processed by protocol decoders, similar to how the Flipper Zero SubGHz
|
||||||
//! system works.
|
//! 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
|
/// A single level+duration pair representing one segment of the signal
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -26,25 +32,53 @@ pub struct Demodulator {
|
|||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
/// Samples per microsecond
|
/// Samples per microsecond
|
||||||
samples_per_us: f64,
|
samples_per_us: f64,
|
||||||
|
|
||||||
|
// ── Adaptive threshold with hysteresis ──
|
||||||
/// Current threshold for high/low detection
|
/// Current threshold for high/low detection
|
||||||
threshold: f32,
|
threshold: f32,
|
||||||
/// Adaptive threshold - high level estimate
|
/// Adaptive threshold - high level estimate
|
||||||
high_level: f32,
|
high_level: f32,
|
||||||
/// Adaptive threshold - low level estimate
|
/// Adaptive threshold - low level estimate
|
||||||
low_level: f32,
|
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,
|
current_level: bool,
|
||||||
/// Sample count at current level
|
/// Sample count at current confirmed level
|
||||||
level_sample_count: u64,
|
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
|
/// Accumulated level+duration pairs
|
||||||
pairs: Vec<LevelDuration>,
|
pairs: Vec<LevelDuration>,
|
||||||
/// Total samples processed
|
/// Total samples processed (for adaptive threshold speed)
|
||||||
total_samples: u64,
|
total_samples: u64,
|
||||||
/// Minimum duration to consider valid (in µs)
|
/// Minimum duration to consider valid (in µs) — also debounce threshold
|
||||||
min_duration_us: u32,
|
min_duration_us: u32,
|
||||||
/// Maximum gap before considering signal complete (in µs)
|
/// Maximum gap before considering signal complete (in µs)
|
||||||
max_gap_us: u32,
|
max_gap_us: u32,
|
||||||
/// Samples since last edge
|
/// Samples since last confirmed edge (for gap detection)
|
||||||
samples_since_edge: u64,
|
samples_since_edge: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,15 +88,32 @@ impl Demodulator {
|
|||||||
Self {
|
Self {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
samples_per_us: sample_rate as f64 / 1_000_000.0,
|
samples_per_us: sample_rate as f64 / 1_000_000.0,
|
||||||
threshold: 0.15,
|
|
||||||
high_level: 0.3,
|
// Start with lower initial threshold — the HackRF's signal levels
|
||||||
low_level: 0.05,
|
// 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,
|
current_level: false,
|
||||||
level_sample_count: 0,
|
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),
|
pairs: Vec::with_capacity(2048),
|
||||||
total_samples: 0,
|
total_samples: 0,
|
||||||
min_duration_us: 50, // Minimum 50µs pulse
|
min_duration_us: 40, // 40µs debounce (was 50 — slightly more permissive)
|
||||||
max_gap_us: 10_000, // 10ms gap = end of signal
|
max_gap_us: 20_000, // 20ms gap = end of signal (was 10ms — wider to avoid splitting signals with internal gaps)
|
||||||
samples_since_edge: 0,
|
samples_since_edge: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,41 +133,124 @@ impl Demodulator {
|
|||||||
let q = chunk[1] as f32 / 128.0;
|
let q = chunk[1] as f32 / 128.0;
|
||||||
let magnitude = (i * i + q * q).sqrt();
|
let magnitude = (i * i + q * q).sqrt();
|
||||||
|
|
||||||
// Update adaptive threshold
|
// Smooth the magnitude with EMA to reduce per-sample noise.
|
||||||
self.update_threshold(magnitude);
|
// 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
|
// During initial calibration, use fast per-sample threshold updates.
|
||||||
let is_high = magnitude > self.threshold;
|
// After calibration, threshold is updated at transitions (see below)
|
||||||
|
// to avoid the duty-cycle bias that causes pulse asymmetry.
|
||||||
// Check for level change
|
if self.total_samples < 10_000 {
|
||||||
if is_high != self.current_level && self.level_sample_count > 0 {
|
self.update_threshold_fast(self.mag_smooth);
|
||||||
// Calculate duration of the previous level
|
|
||||||
let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32;
|
|
||||||
|
|
||||||
// Only record if above minimum duration (noise filtering)
|
|
||||||
if duration_us >= self.min_duration_us {
|
|
||||||
self.pairs.push(LevelDuration::new(self.current_level, duration_us));
|
|
||||||
self.samples_since_edge = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.current_level = is_high;
|
// Determine level using hysteresis (Schmitt trigger behavior):
|
||||||
self.level_sample_count = 1;
|
// 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 {
|
} else {
|
||||||
self.level_sample_count += 1;
|
// Currently LOW: go HIGH only if magnitude rises well above threshold
|
||||||
self.samples_since_edge += 1;
|
self.mag_smooth > (self.threshold + self.hysteresis)
|
||||||
}
|
};
|
||||||
|
|
||||||
self.total_samples += 1;
|
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)
|
// 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;
|
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.pairs.is_empty() && self.samples_since_edge > gap_samples {
|
||||||
// Add the final level duration
|
// Flush any pending transition
|
||||||
let duration_us = (self.level_sample_count as f64 / self.samples_per_us) as u32;
|
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 {
|
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
|
// 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 {
|
if self.pairs.len() > 4096 {
|
||||||
self.reset_state();
|
self.reset_state();
|
||||||
}
|
}
|
||||||
@@ -136,40 +270,87 @@ impl Demodulator {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update adaptive threshold based on signal levels
|
/// Fast per-sample threshold update — used only during initial calibration
|
||||||
fn update_threshold(&mut self, magnitude: f32) {
|
/// (first ~5ms / 10K samples). Updates high/low estimates every sample for
|
||||||
const ALPHA: f32 = 0.001; // Slow adaptation
|
/// 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 {
|
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 {
|
} 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;
|
self.threshold = (self.low_level + self.high_level) / 2.0;
|
||||||
|
|
||||||
// Ensure reasonable bounds
|
// Ensure reasonable bounds — very low threshold for weak signals,
|
||||||
self.threshold = self.threshold.max(0.05).min(0.5);
|
// 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) {
|
fn reset_state(&mut self) {
|
||||||
self.pairs.clear();
|
self.pairs.clear();
|
||||||
self.level_sample_count = 0;
|
self.level_sample_count = 0;
|
||||||
|
self.level_mag_sum = 0.0;
|
||||||
|
self.level_mag_count = 0;
|
||||||
self.samples_since_edge = 0;
|
self.samples_since_edge = 0;
|
||||||
self.current_level = false;
|
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)
|
/// Reset completely (including threshold adaptation)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.reset_state();
|
self.reset_state();
|
||||||
self.threshold = 0.15;
|
self.threshold = 0.08;
|
||||||
self.high_level = 0.3;
|
self.high_level = 0.15;
|
||||||
self.low_level = 0.05;
|
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!(ld.level);
|
||||||
assert_eq!(ld.duration_us, 500);
|
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)
|
/// Sample rate for HackRF (2 MHz is good for keyfob signals)
|
||||||
const SAMPLE_RATE: u32 = 2_000_000;
|
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
|
/// HackRF controller for receiving and transmitting signals
|
||||||
pub struct HackRfController {
|
pub struct HackRfController {
|
||||||
/// Event sender for notifying the app
|
/// Event sender for notifying the app
|
||||||
@@ -35,6 +53,8 @@ pub struct HackRfController {
|
|||||||
demodulator: Arc<Mutex<Demodulator>>,
|
demodulator: Arc<Mutex<Demodulator>>,
|
||||||
/// Whether HackRF is available
|
/// Whether HackRF is available
|
||||||
hackrf_available: bool,
|
hackrf_available: bool,
|
||||||
|
/// Shared gain settings (read by receiver thread)
|
||||||
|
gain_settings: Arc<Mutex<GainSettings>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HackRfController {
|
impl HackRfController {
|
||||||
@@ -58,6 +78,7 @@ impl HackRfController {
|
|||||||
frequency: Arc::new(Mutex::new(433_920_000)),
|
frequency: Arc::new(Mutex::new(433_920_000)),
|
||||||
demodulator: Arc::new(Mutex::new(demodulator)),
|
demodulator: Arc::new(Mutex::new(demodulator)),
|
||||||
hackrf_available,
|
hackrf_available,
|
||||||
|
gain_settings: Arc::new(Mutex::new(GainSettings::default())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +102,12 @@ impl HackRfController {
|
|||||||
let freq = self.frequency.clone();
|
let freq = self.frequency.clone();
|
||||||
let demodulator = self.demodulator.clone();
|
let demodulator = self.demodulator.clone();
|
||||||
let hackrf_available = self.hackrf_available;
|
let hackrf_available = self.hackrf_available;
|
||||||
|
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(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)));
|
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)
|
/// Set LNA gain (0-40 dB, 8 dB steps)
|
||||||
pub fn set_lna_gain(&mut self, gain: u32) -> Result<()> {
|
pub fn set_lna_gain(&mut self, gain: u32) -> Result<()> {
|
||||||
tracing::info!("Set LNA gain to {} dB", gain);
|
tracing::info!("Set LNA gain to {} dB", gain);
|
||||||
// Note: gain changes take effect on next start_receiving
|
if let Ok(mut settings) = self.gain_settings.lock() {
|
||||||
// For now, just log - actual application happens in run_receiver_hackrf
|
settings.lna_gain = gain;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set VGA gain (0-62 dB, 2 dB steps)
|
/// Set VGA gain (0-62 dB, 2 dB steps)
|
||||||
pub fn set_vga_gain(&mut self, gain: u32) -> Result<()> {
|
pub fn set_vga_gain(&mut self, gain: u32) -> Result<()> {
|
||||||
tracing::info!("Set VGA gain to {} dB", gain);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable/disable the RF amplifier
|
/// Enable/disable the RF amplifier
|
||||||
pub fn set_amp_enable(&mut self, enabled: bool) -> Result<()> {
|
pub fn set_amp_enable(&mut self, enabled: bool) -> Result<()> {
|
||||||
tracing::info!("Set amp enable to {}", enabled);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,6 +300,7 @@ fn run_receiver_hackrf(
|
|||||||
event_tx: Sender<RadioEvent>,
|
event_tx: Sender<RadioEvent>,
|
||||||
frequency: Arc<Mutex<u32>>,
|
frequency: Arc<Mutex<u32>>,
|
||||||
demodulator: Arc<Mutex<Demodulator>>,
|
demodulator: Arc<Mutex<Demodulator>>,
|
||||||
|
gain_settings: Arc<Mutex<GainSettings>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
@@ -283,7 +311,11 @@ fn run_receiver_hackrf(
|
|||||||
.context("Failed to open HackRF device")?;
|
.context("Failed to open HackRF device")?;
|
||||||
|
|
||||||
let freq = *frequency.lock().unwrap();
|
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
|
// Configure HackRF
|
||||||
hackrf.set_sample_rate(SAMPLE_RATE)
|
hackrf.set_sample_rate(SAMPLE_RATE)
|
||||||
@@ -292,13 +324,13 @@ fn run_receiver_hackrf(
|
|||||||
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(32)
|
hackrf.set_lna_gain(initial_gains.lna_gain)
|
||||||
.context("Failed to set 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")?;
|
.context("Failed to set RXVGA gain")?;
|
||||||
|
|
||||||
hackrf.set_amp_enable(true)
|
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...");
|
||||||
@@ -316,9 +348,40 @@ fn run_receiver_hackrf(
|
|||||||
hackrf.start_rx(rx_callback, state)
|
hackrf.start_rx(rx_callback, state)
|
||||||
.context("Failed to start RX")?;
|
.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) {
|
while receiving.load(Ordering::SeqCst) {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
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
|
// Stop receiving
|
||||||
|
|||||||
+2
-1
@@ -43,7 +43,8 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
"IMPORT",
|
"IMPORT",
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
),
|
),
|
||||||
InputMode::FobMetaYear
|
InputMode::ExportFilename
|
||||||
|
| InputMode::FobMetaYear
|
||||||
| InputMode::FobMetaMake
|
| InputMode::FobMetaMake
|
||||||
| InputMode::FobMetaModel
|
| InputMode::FobMetaModel
|
||||||
| InputMode::FobMetaRegion
|
| InputMode::FobMetaRegion
|
||||||
|
|||||||
+58
-16
@@ -82,13 +82,14 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
|
|||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
app.input_mode,
|
app.input_mode,
|
||||||
InputMode::FobMetaYear
|
InputMode::ExportFilename
|
||||||
|
| InputMode::FobMetaYear
|
||||||
| InputMode::FobMetaMake
|
| InputMode::FobMetaMake
|
||||||
| InputMode::FobMetaModel
|
| InputMode::FobMetaModel
|
||||||
| InputMode::FobMetaRegion
|
| InputMode::FobMetaRegion
|
||||||
| InputMode::FobMetaNotes
|
| 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::SettingsSelect => "Left/Right: Select | Tab: Cycle | Enter: Edit | Esc: Back",
|
||||||
InputMode::SettingsEdit => "Up/Down: Change Value | Enter: Apply | Esc: Cancel",
|
InputMode::SettingsEdit => "Up/Down: Change Value | Enter: Apply | Esc: Cancel",
|
||||||
InputMode::StartupImport => "y: Import | n: Skip",
|
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::FobMetaYear
|
||||||
| InputMode::FobMetaMake
|
| InputMode::FobMetaMake
|
||||||
| InputMode::FobMetaModel
|
| InputMode::FobMetaModel
|
||||||
@@ -204,10 +212,17 @@ fn render_startup_import_prompt(frame: &mut Frame, app: &App) {
|
|||||||
frame.render_widget(paragraph, popup);
|
frame.render_widget(paragraph, popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the .fob export metadata form overlay with signal summary
|
/// Render the export form overlay (filename + optional .fob metadata)
|
||||||
fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
|
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 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);
|
frame.render_widget(Clear, popup);
|
||||||
|
|
||||||
@@ -226,14 +241,21 @@ fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
|
|||||||
.add_modifier(Modifier::RAPID_BLINK),
|
.add_modifier(Modifier::RAPID_BLINK),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine which field is active
|
// Build the ordered list of field modes for this export type
|
||||||
let field_modes = [
|
// Filename is always first; .fob adds metadata fields after
|
||||||
|
let field_modes: Vec<InputMode> = if is_fob {
|
||||||
|
vec![
|
||||||
|
InputMode::ExportFilename,
|
||||||
InputMode::FobMetaYear,
|
InputMode::FobMetaYear,
|
||||||
InputMode::FobMetaMake,
|
InputMode::FobMetaMake,
|
||||||
InputMode::FobMetaModel,
|
InputMode::FobMetaModel,
|
||||||
InputMode::FobMetaRegion,
|
InputMode::FobMetaRegion,
|
||||||
InputMode::FobMetaNotes,
|
InputMode::FobMetaNotes,
|
||||||
];
|
]
|
||||||
|
} else {
|
||||||
|
vec![InputMode::ExportFilename]
|
||||||
|
};
|
||||||
|
|
||||||
let current_idx = field_modes
|
let current_idx = field_modes
|
||||||
.iter()
|
.iter()
|
||||||
.position(|m| *m == app.input_mode)
|
.position(|m| *m == app.input_mode)
|
||||||
@@ -292,38 +314,52 @@ fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
|
|||||||
idx: usize,
|
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 {
|
FormField {
|
||||||
label: " Year: ",
|
label: " Year: ",
|
||||||
value: &app.fob_meta_year,
|
value: &app.fob_meta_year,
|
||||||
placeholder: "(e.g. 2024)",
|
placeholder: "(e.g. 2024)",
|
||||||
idx: 0,
|
idx: 1,
|
||||||
},
|
},
|
||||||
FormField {
|
FormField {
|
||||||
label: " Make: ",
|
label: " Make: ",
|
||||||
value: &app.fob_meta_make,
|
value: &app.fob_meta_make,
|
||||||
placeholder: "(auto-detected from protocol)",
|
placeholder: "(auto-detected from protocol)",
|
||||||
idx: 1,
|
idx: 2,
|
||||||
},
|
},
|
||||||
FormField {
|
FormField {
|
||||||
label: " Model: ",
|
label: " Model: ",
|
||||||
value: &app.fob_meta_model,
|
value: &app.fob_meta_model,
|
||||||
placeholder: "(e.g. Sportage, F-150)",
|
placeholder: "(e.g. Sportage, F-150)",
|
||||||
idx: 2,
|
idx: 3,
|
||||||
},
|
},
|
||||||
FormField {
|
FormField {
|
||||||
label: " Region: ",
|
label: " Region: ",
|
||||||
value: &app.fob_meta_region,
|
value: &app.fob_meta_region,
|
||||||
placeholder: "(e.g. NA, EU, APAC, MEA)",
|
placeholder: "(e.g. NA, EU, APAC, MEA)",
|
||||||
idx: 3,
|
idx: 4,
|
||||||
},
|
},
|
||||||
FormField {
|
FormField {
|
||||||
label: " Notes: ",
|
label: " Notes: ",
|
||||||
value: &app.fob_meta_notes,
|
value: &app.fob_meta_notes,
|
||||||
placeholder: "(optional — color, trim, VIN, etc.)",
|
placeholder: "(optional — color, trim, VIN, etc.)",
|
||||||
idx: 4,
|
idx: 5,
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
for field in &fields {
|
for field in &fields {
|
||||||
let label_s = style_for(field.idx);
|
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),
|
Span::styled(hint, dim_style),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
let title = if is_fob {
|
||||||
|
" Export .fob — Filename & Vehicle Details "
|
||||||
|
} else {
|
||||||
|
" Export .sub (Flipper Zero) "
|
||||||
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Export .fob — Vehicle Details ")
|
.title(title)
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::Green));
|
.border_style(Style::default().fg(Color::Green));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user