Files
KAT/src/app.rs
T
leviathan b306ff0574 closer
2026-02-13 21:46:04 -05:00

1160 lines
42 KiB
Rust

//! Application state management.
use anyhow::Result;
use std::sync::mpsc::{self, Receiver, Sender};
use crate::capture::{ButtonCommand, Capture};
use crate::protocols::ProtocolRegistry;
use crate::radio::HackRfController;
use crate::storage::Storage;
/// Input mode for the application
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
/// Normal navigation mode
Normal,
/// Command input mode (after pressing :)
Command,
/// Signal action popup menu
SignalMenu,
/// Tab bar - selecting which radio setting
SettingsSelect,
/// Editing a radio setting value
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
FobMetaMake,
/// Fob export metadata: editing model field
FobMetaModel,
/// Fob export metadata: editing region field
FobMetaRegion,
/// Fob export metadata: editing notes field
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 {
Lock,
Unlock,
Trunk,
Panic,
ExportFob,
ExportFlipper,
Delete,
}
impl SignalAction {
pub const ALL: [SignalAction; 7] = [
SignalAction::Lock,
SignalAction::Unlock,
SignalAction::Trunk,
SignalAction::Panic,
SignalAction::ExportFob,
SignalAction::ExportFlipper,
SignalAction::Delete,
];
pub fn label(&self) -> &'static str {
match self {
SignalAction::Lock => "TX Lock",
SignalAction::Unlock => "TX Unlock",
SignalAction::Trunk => "TX Trunk",
SignalAction::Panic => "TX Panic",
SignalAction::ExportFob => "Export .fob",
SignalAction::ExportFlipper => "Export .sub (Flipper)",
SignalAction::Delete => "Delete Signal",
}
}
}
/// Radio settings selectable via Tab
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
Freq,
Lna,
Vga,
Amp,
}
impl SettingsField {
pub const ALL: [SettingsField; 4] = [
SettingsField::Freq,
SettingsField::Lna,
SettingsField::Vga,
SettingsField::Amp,
];
pub fn label(&self) -> &'static str {
match self {
SettingsField::Freq => "Freq",
SettingsField::Lna => "LNA",
SettingsField::Vga => "VGA",
SettingsField::Amp => "AMP",
}
}
}
/// Common keyfob frequencies (Hz)
pub const PRESET_FREQUENCIES: [(u32, &str); 9] = [
(300_000_000, "300.00 MHz"),
(303_875_000, "303.875 MHz"),
(310_000_000, "310.00 MHz"),
(315_000_000, "315.00 MHz"),
(318_000_000, "318.00 MHz"),
(390_000_000, "390.00 MHz"),
(433_920_000, "433.92 MHz"),
(868_350_000, "868.35 MHz"),
(915_000_000, "915.00 MHz"),
];
/// LNA gain steps (dB)
pub const LNA_STEPS: [u32; 6] = [0, 8, 16, 24, 32, 40];
/// VGA gain steps (dB, subset for menu)
pub const VGA_STEPS: [u32; 8] = [0, 8, 16, 20, 24, 32, 40, 62];
/// Radio state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadioState {
/// Not connected
Disconnected,
/// Connected but idle
Idle,
/// Receiving signals
Receiving,
/// Transmitting
#[allow(dead_code)]
Transmitting,
}
impl std::fmt::Display for RadioState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RadioState::Disconnected => write!(f, "DISCONNECTED"),
RadioState::Idle => write!(f, "IDLE"),
RadioState::Receiving => write!(f, "RX"),
RadioState::Transmitting => write!(f, "TX"),
}
}
}
/// Events from the radio subsystem
pub enum RadioEvent {
/// New signal captured
SignalCaptured(Capture),
/// Error occurred
Error(String),
/// State changed
#[allow(dead_code)]
StateChanged(RadioState),
}
/// Main application state
pub struct App {
/// Current input mode
pub input_mode: InputMode,
/// Command input buffer
pub command_input: String,
/// List of captures
pub captures: Vec<Capture>,
/// Currently selected capture index
pub selected_capture: Option<usize>,
/// Scroll offset for captures list
pub scroll_offset: usize,
/// Current frequency in Hz
pub frequency: u32,
/// LNA gain (0-40 dB, 8 dB steps)
pub lna_gain: u32,
/// VGA gain (0-62 dB, 2 dB steps)
pub vga_gain: u32,
/// Amplifier enabled
pub amp_enabled: bool,
/// Radio state
pub radio_state: RadioState,
/// Last error message
pub last_error: Option<String>,
/// Last status message
pub status_message: Option<String>,
// -- Signal action menu state --
/// Currently selected signal menu item index
pub signal_menu_index: usize,
// -- Settings menu state --
/// Currently selected settings field
pub settings_field_index: usize,
/// Currently selected value index within the settings field editor
pub settings_value_index: usize,
/// Next capture ID
next_capture_id: u32,
/// Storage manager
pub storage: Storage,
/// Protocol registry
protocols: ProtocolRegistry,
/// HackRF controller (optional - may not be connected)
hackrf: Option<HackRfController>,
/// Channel for radio events
radio_event_rx: Receiver<RadioEvent>,
/// Sender for radio events (cloned to radio thread)
#[allow(dead_code)]
radio_event_tx: Sender<RadioEvent>,
// -- Startup import state --
/// .fob files found on startup in export_dir
pub pending_fob_files: Vec<std::path::PathBuf>,
// -- 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
pub fob_meta_make: String,
/// Model input buffer
pub fob_meta_model: String,
/// Region input buffer (e.g. NA, EU, APAC, etc.)
pub fob_meta_region: String,
/// Notes input buffer
pub fob_meta_notes: String,
}
impl App {
/// Create a new application instance
pub fn new() -> Result<Self> {
let storage = Storage::new()?;
// ── Load protocol encryption keys from embedded keystore ─────────
crate::protocols::keys::load_keystore_from_embedded();
let protocols = ProtocolRegistry::new();
let (radio_event_tx, radio_event_rx) = mpsc::channel();
// Try to initialize HackRF
let hackrf = match HackRfController::new(radio_event_tx.clone()) {
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) => {
tracing::warn!("Failed to initialize HackRF: {}", e);
None
}
};
let radio_state = if hackrf.is_some() {
RadioState::Idle
} else {
RadioState::Disconnected
};
// Captures start empty — they are in-memory only and discarded on exit.
// The user is offered the chance to import .fob files from their exports folder.
let captures: Vec<Capture> = Vec::new();
let next_capture_id = 1u32;
// Use config defaults for radio settings
let frequency = storage.config.default_frequency;
let lna_gain = storage.config.default_lna_gain;
let vga_gain = storage.config.default_vga_gain;
let amp_enabled = storage.config.default_amp;
// Recursively scan import directory for .fob and .sub at startup (separate from export dir)
let pending_fob_files =
crate::export::scan_import_files_recursive(storage.import_dir());
let initial_mode = if !pending_fob_files.is_empty() {
tracing::info!(
"Found {} importable file(s) in import dir (recursive)",
pending_fob_files.len()
);
InputMode::StartupImport
} else {
InputMode::Normal
};
Ok(Self {
input_mode: initial_mode,
command_input: String::new(),
captures,
selected_capture: None,
scroll_offset: 0,
frequency,
lna_gain,
vga_gain,
amp_enabled,
radio_state,
last_error: None,
status_message: None,
signal_menu_index: 0,
settings_field_index: 0,
settings_value_index: 0,
next_capture_id,
storage,
protocols,
hackrf,
radio_event_rx,
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(),
fob_meta_region: String::new(),
fob_meta_notes: String::new(),
})
}
/// Get the frequency in MHz
pub fn frequency_mhz(&self) -> f64 {
self.frequency as f64 / 1_000_000.0
}
/// Select the next capture in the list
pub fn next_capture(&mut self) {
if self.captures.is_empty() {
return;
}
self.selected_capture = Some(match self.selected_capture {
Some(i) => (i + 1).min(self.captures.len() - 1),
None => 0,
});
// Update scroll to keep selection visible
self.ensure_selection_visible();
}
/// Select the previous capture in the list
pub fn previous_capture(&mut self) {
if self.captures.is_empty() {
return;
}
self.selected_capture = Some(match self.selected_capture {
Some(i) => i.saturating_sub(1),
None => 0,
});
// Update scroll to keep selection visible
self.ensure_selection_visible();
}
/// Ensure the selected capture is visible in the scroll view
fn ensure_selection_visible(&mut self) {
if let Some(selected) = self.selected_capture {
// Assume visible area is about 15 items (will be adjusted by UI)
let visible_rows = 15;
if selected < self.scroll_offset {
self.scroll_offset = selected;
} else if selected >= self.scroll_offset + visible_rows {
self.scroll_offset = selected.saturating_sub(visible_rows - 1);
}
}
}
/// Toggle receiving state
pub fn toggle_receiving(&mut self) -> Result<()> {
// Clear any previous error when user takes action
self.last_error = None;
match self.radio_state {
RadioState::Disconnected => {
self.last_error = Some("HackRF not connected".to_string());
}
RadioState::Idle => {
if let Some(ref mut hackrf) = self.hackrf {
hackrf.start_receiving(self.frequency)?;
self.radio_state = RadioState::Receiving;
self.status_message = Some(format!("Receiving on {:.2} MHz", self.frequency_mhz()));
}
}
RadioState::Receiving => {
if let Some(ref mut hackrf) = self.hackrf {
hackrf.stop_receiving()?;
self.radio_state = RadioState::Idle;
self.status_message = Some("Stopped receiving".to_string());
}
}
RadioState::Transmitting => {
self.last_error = Some("Cannot change state while transmitting".to_string());
}
}
Ok(())
}
/// Execute a command
pub fn execute_command(&mut self, command: &str) -> Result<()> {
let parts: Vec<&str> = command.trim().split_whitespace().collect();
if parts.is_empty() {
return Ok(());
}
self.last_error = None;
self.status_message = None;
match parts[0] {
"q" | "quit" => {
// Will be handled by main loop
std::process::exit(0);
}
"freq" => {
if parts.len() < 2 {
self.last_error = Some("Usage: :freq <MHz>".to_string());
return Ok(());
}
match parts[1].parse::<f64>() {
Ok(mhz) => {
let hz = (mhz * 1_000_000.0) as u32;
self.set_frequency(hz)?;
}
Err(_) => {
self.last_error = Some("Invalid frequency".to_string());
}
}
}
"unlock" => self.transmit_command(parts.get(1), ButtonCommand::Unlock)?,
"lock" => self.transmit_command(parts.get(1), ButtonCommand::Lock)?,
"trunk" => self.transmit_command(parts.get(1), ButtonCommand::Trunk)?,
"panic" => self.transmit_command(parts.get(1), ButtonCommand::Panic)?,
"delete" => {
if parts.len() < 2 {
self.last_error = Some("Usage: :delete <ID> or :delete all".to_string());
return Ok(());
}
if parts[1].eq_ignore_ascii_case("all") {
self.delete_all_captures()?;
} else {
self.delete_capture(parts[1])?;
}
}
"lna" => {
if parts.len() < 2 {
self.last_error = Some("Usage: :lna <0-40>".to_string());
return Ok(());
}
match parts[1].parse::<u32>() {
Ok(gain) => self.set_lna_gain(gain)?,
Err(_) => {
self.last_error = Some("Invalid LNA gain value".to_string());
}
}
}
"vga" => {
if parts.len() < 2 {
self.last_error = Some("Usage: :vga <0-62>".to_string());
return Ok(());
}
match parts[1].parse::<u32>() {
Ok(gain) => self.set_vga_gain(gain)?,
Err(_) => {
self.last_error = Some("Invalid VGA gain value".to_string());
}
}
}
"amp" => {
if parts.len() < 2 {
// Toggle if no argument
self.toggle_amp()?;
} else {
match parts[1].to_lowercase().as_str() {
"on" | "1" | "true" => self.set_amp(true)?,
"off" | "0" | "false" => self.set_amp(false)?,
_ => {
self.last_error = Some("Usage: :amp [on|off]".to_string());
}
}
}
}
_ => {
self.last_error = Some(format!("Unknown command: {}", parts[0]));
}
}
Ok(())
}
/// Set the receive frequency
fn set_frequency(&mut self, hz: u32) -> Result<()> {
// Validate frequency range (common keyfob frequencies)
if hz < 300_000_000 || hz > 928_000_000 {
self.last_error = Some("Frequency must be between 300-928 MHz".to_string());
return Ok(());
}
self.frequency = hz;
// If receiving, restart receiver so the new frequency takes effect (HackRF thread reads freq only at start)
if let Some(ref mut hackrf) = self.hackrf {
if self.radio_state == RadioState::Receiving {
hackrf.stop_receiving()?;
hackrf.start_receiving(hz)?;
} else {
hackrf.set_frequency(hz)?;
}
}
self.status_message = Some(format!("Frequency set to {:.2} MHz", hz as f64 / 1_000_000.0));
Ok(())
}
/// Set the LNA gain
fn set_lna_gain(&mut self, gain: u32) -> Result<()> {
// LNA gain is 0-40 dB in 8 dB steps
if gain > 40 {
self.last_error = Some("LNA gain must be 0-40 dB".to_string());
return Ok(());
}
// Round to nearest 8 dB step
let gain = (gain / 8) * 8;
self.lna_gain = gain;
if let Some(ref mut hackrf) = self.hackrf {
hackrf.set_lna_gain(gain)?;
}
self.status_message = Some(format!("LNA gain set to {} dB", gain));
Ok(())
}
/// Set the VGA gain
fn set_vga_gain(&mut self, gain: u32) -> Result<()> {
// VGA gain is 0-62 dB in 2 dB steps
if gain > 62 {
self.last_error = Some("VGA gain must be 0-62 dB".to_string());
return Ok(());
}
// Round to nearest 2 dB step
let gain = (gain / 2) * 2;
self.vga_gain = gain;
if let Some(ref mut hackrf) = self.hackrf {
hackrf.set_vga_gain(gain)?;
}
self.status_message = Some(format!("VGA gain set to {} dB", gain));
Ok(())
}
/// Toggle amplifier
fn toggle_amp(&mut self) -> Result<()> {
self.set_amp(!self.amp_enabled)
}
/// Set amplifier state
fn set_amp(&mut self, enabled: bool) -> Result<()> {
self.amp_enabled = enabled;
if let Some(ref mut hackrf) = self.hackrf {
hackrf.set_amp_enable(enabled)?;
}
self.status_message = Some(format!("Amp {}", if enabled { "enabled" } else { "disabled" }));
Ok(())
}
/// Transmit a command for a capture
fn transmit_command(&mut self, id_str: Option<&&str>, command: ButtonCommand) -> Result<()> {
use crate::protocols::DecodedSignal;
let id_str = match id_str {
Some(s) => s,
None => {
self.last_error = Some(format!("Usage: :{:?} <ID>", command).to_lowercase());
return Ok(());
}
};
let id: u32 = match id_str.parse() {
Ok(i) => i,
Err(_) => {
self.last_error = Some("Invalid capture ID".to_string());
return Ok(());
}
};
let capture = match self.captures.iter().find(|c| c.id == id) {
Some(c) => c.clone(),
None => {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
};
if capture.protocol.is_none() {
self.last_error = Some("Cannot transmit: unknown protocol".to_string());
return Ok(());
}
let protocol_name = capture.protocol.as_ref().unwrap();
let protocol = match self.protocols.get(protocol_name) {
Some(p) => p,
None => {
self.last_error = Some(format!("Protocol {} not supported for encoding", protocol_name));
return Ok(());
}
};
if !protocol.supports_encoding() {
self.last_error = Some(format!("Protocol {} does not support encoding", protocol_name));
return Ok(());
}
// Create a DecodedSignal from the capture
let decoded = DecodedSignal {
serial: capture.serial,
button: capture.button,
counter: capture.counter,
crc_valid: capture.crc_valid,
data: capture.data,
data_count_bit: capture.data_count_bit,
encoder_capable: true,
};
// Generate the signal with the new button
let button_code = command.code();
let signal = match protocol.encode(&decoded, button_code) {
Some(s) => s,
None => {
self.last_error = Some("Failed to encode signal".to_string());
return Ok(());
}
};
// Transmit
if let Some(ref mut hackrf) = self.hackrf {
hackrf.transmit(&signal, capture.frequency)?;
self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id));
} else {
self.last_error = Some("HackRF not connected".to_string());
}
Ok(())
}
/// Delete the currently selected capture (if any). No-op if none selected or list empty.
pub fn delete_selected_capture(&mut self) -> Result<()> {
let id = match self.selected_capture {
Some(idx) if idx < self.captures.len() => self.captures[idx].id,
_ => return Ok(()),
};
self.delete_capture(&id.to_string())
}
/// Delete a capture by ID (in-memory only — captures are not persisted)
fn delete_capture(&mut self, id_str: &str) -> Result<()> {
let id: u32 = match id_str.parse() {
Ok(i) => i,
Err(_) => {
self.last_error = Some("Invalid capture ID".to_string());
return Ok(());
}
};
let idx = match self.captures.iter().position(|c| c.id == id) {
Some(i) => i,
None => {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
};
self.captures.remove(idx);
// Adjust selection
if let Some(sel) = self.selected_capture {
if sel >= self.captures.len() && !self.captures.is_empty() {
self.selected_capture = Some(self.captures.len() - 1);
} else if self.captures.is_empty() {
self.selected_capture = None;
}
}
self.status_message = Some(format!("Deleted capture {}", id));
Ok(())
}
/// Delete all captures (in-memory only)
fn delete_all_captures(&mut self) -> Result<()> {
let count = self.captures.len();
if count == 0 {
self.status_message = Some("No captures to delete".to_string());
return Ok(());
}
// Clear the list
self.captures.clear();
self.selected_capture = None;
self.scroll_offset = 0;
self.status_message = Some(format!("Deleted all {} captures", count));
Ok(())
}
/// Process pending radio events
pub fn process_radio_events(&mut self) -> Result<()> {
while let Ok(event) = self.radio_event_rx.try_recv() {
match event {
RadioEvent::SignalCaptured(mut capture) => {
// Convert stored pairs to the format protocols expect
let pairs: Vec<crate::radio::LevelDuration> = capture.raw_pairs
.iter()
.map(|p| crate::radio::LevelDuration::new(p.level, p.duration_us))
.collect();
// Try to decode with registered protocols
if let Some((protocol_name, decoded)) = self.protocols.process_signal(&pairs, capture.frequency) {
capture.protocol = Some(protocol_name);
capture.serial = decoded.serial;
capture.button = decoded.button;
capture.counter = decoded.counter;
capture.crc_valid = decoded.crc_valid;
capture.data = decoded.data;
capture.data_count_bit = decoded.data_count_bit;
capture.status = if decoded.encoder_capable {
crate::capture::CaptureStatus::EncoderCapable
} else {
crate::capture::CaptureStatus::Decoded
};
}
// When research_mode is off, only add successfully decoded signals.
let show = self.storage.config.research_mode || capture.protocol.is_some();
if show {
capture.id = self.next_capture_id;
self.next_capture_id += 1;
// Captures are in-memory only — no auto-save to disk.
// Use Export (.fob / .sub) to persist a signal.
self.captures.push(capture);
// Auto-select and scroll to new capture
let new_idx = self.captures.len() - 1;
self.selected_capture = Some(new_idx);
self.ensure_selection_visible();
self.status_message = Some("New signal captured".to_string());
}
// When research_mode is off and decode failed, the signal is dropped (not shown).
}
RadioEvent::Error(e) => {
self.last_error = Some(e);
}
RadioEvent::StateChanged(state) => {
self.radio_state = state;
}
}
}
Ok(())
}
// -- Signal Action Menu helpers --
/// Execute the currently selected signal action
pub fn execute_signal_action(&mut self) -> Result<()> {
let action = SignalAction::ALL[self.signal_menu_index];
let capture_id = match self.selected_capture {
Some(idx) if idx < self.captures.len() => self.captures[idx].id,
_ => {
self.last_error = Some("No capture selected".to_string());
return Ok(());
}
};
match action {
SignalAction::Lock => {
let id_str = capture_id.to_string();
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Lock)?;
}
SignalAction::Unlock => {
let id_str = capture_id.to_string();
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Unlock)?;
}
SignalAction::Trunk => {
let id_str = capture_id.to_string();
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Trunk)?;
}
SignalAction::Panic => {
let id_str = capture_id.to_string();
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Panic)?;
}
SignalAction::ExportFob => {
self.export_fob(capture_id)?;
}
SignalAction::ExportFlipper => {
self.export_flipper(capture_id)?;
}
SignalAction::Delete => {
let id_str = capture_id.to_string();
self.delete_capture(&id_str)?;
}
}
Ok(())
}
/// 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::ExportFilename;
Ok(())
}
/// Complete the .fob export with collected metadata
pub fn complete_fob_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 => {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
};
let export_dir = self.storage.export_dir().clone();
if !export_dir.exists() {
std::fs::create_dir_all(&export_dir)?;
}
let metadata = crate::export::fob::FobMetadata {
year: self.fob_meta_year.parse::<u32>().ok(),
make: self.fob_meta_make.clone(),
model: self.fob_meta_model.clone(),
region: self.fob_meta_region.clone(),
notes: self.fob_meta_notes.clone(),
};
let filename = format!("{}.fob", self.export_filename);
let path = export_dir.join(&filename);
crate::export::fob::export_fob(
&capture,
&path,
self.storage.config.include_raw_pairs,
Some(&metadata),
)?;
self.export_capture_id = None;
self.export_format = None;
self.status_message = Some(format!("Exported to {}", filename));
Ok(())
}
/// Import pending .fob and .sub files into captures list.
/// .sub files are decoded with registered protocols after load (no metadata in file).
/// When research_mode is off, only decoded captures are added (same as live capture).
pub fn import_fob_files(&mut self) -> Result<()> {
let files = std::mem::take(&mut self.pending_fob_files);
let mut imported = 0;
let research_mode = self.storage.config.research_mode;
for path in &files {
let is_sub = path.extension().map_or(false, |e| e == "sub");
if is_sub {
match crate::export::flipper::import_sub_raw(path) {
Ok((frequency, raw_pairs)) => {
let pairs: Vec<crate::radio::LevelDuration> = raw_pairs
.iter()
.map(|p| crate::radio::LevelDuration::new(p.level, p.duration_us))
.collect();
let decoded_list =
self.protocols.process_signal_stream(&pairs, frequency);
for (protocol_name, decoded, segment_pairs) in decoded_list {
let raw: Vec<crate::capture::StoredLevelDuration> = segment_pairs
.iter()
.map(|p| crate::capture::StoredLevelDuration {
level: p.level,
duration_us: p.duration_us,
})
.collect();
let mut capture = crate::capture::Capture::from_pairs_with_rf(
self.next_capture_id,
frequency,
raw,
None,
);
self.next_capture_id += 1;
capture.protocol = Some(protocol_name);
capture.serial = decoded.serial;
capture.button = decoded.button;
capture.counter = decoded.counter;
capture.crc_valid = decoded.crc_valid;
capture.data = decoded.data;
capture.data_count_bit = decoded.data_count_bit;
capture.status = if decoded.encoder_capable {
crate::capture::CaptureStatus::EncoderCapable
} else {
crate::capture::CaptureStatus::Decoded
};
if research_mode || capture.protocol.is_some() {
self.captures.push(capture);
imported += 1;
}
}
}
Err(e) => tracing::warn!("Failed to import {:?}: {}", path, e),
}
} else {
match crate::export::fob::import_fob(path, self.next_capture_id) {
Ok(mut capture) => {
self.next_capture_id += 1;
// Re-run decoder when Unknown and raw_pairs present (same as .sub)
if capture.status == crate::capture::CaptureStatus::Unknown
&& !capture.raw_pairs.is_empty()
{
let pairs: Vec<crate::radio::LevelDuration> = capture
.raw_pairs
.iter()
.map(|p| crate::radio::LevelDuration::new(p.level, p.duration_us))
.collect();
if let Some((protocol_name, decoded)) =
self.protocols.process_signal(&pairs, capture.frequency)
{
capture.protocol = Some(protocol_name);
capture.serial = decoded.serial;
capture.button = decoded.button;
capture.counter = decoded.counter;
capture.crc_valid = decoded.crc_valid;
capture.data = decoded.data;
capture.data_count_bit = decoded.data_count_bit;
capture.status = if decoded.encoder_capable {
crate::capture::CaptureStatus::EncoderCapable
} else {
crate::capture::CaptureStatus::Decoded
};
}
}
if research_mode || capture.protocol.is_some() {
self.captures.push(capture);
imported += 1;
}
}
Err(e) => tracing::warn!("Failed to import {:?}: {}", path, e),
}
}
}
if imported > 0 {
self.selected_capture = Some(0);
self.status_message = Some(format!("Imported {} file(s)", imported));
}
Ok(())
}
/// Skip .fob import and start blank
pub fn skip_fob_import(&mut self) {
self.pending_fob_files.clear();
self.status_message = Some("Starting with no imported signals".to_string());
}
/// 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 => {
self.last_error = Some(format!("Capture {} not found", id));
return Ok(());
}
};
let export_dir = self.storage.export_dir().clone();
if !export_dir.exists() {
std::fs::create_dir_all(&export_dir)?;
}
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(())
}
// -- Settings Menu helpers --
/// Get the current value index for the active settings field
pub fn current_settings_value_index(&self) -> usize {
let field = SettingsField::ALL[self.settings_field_index];
match field {
SettingsField::Freq => {
PRESET_FREQUENCIES.iter().position(|(f, _)| *f == self.frequency).unwrap_or(0)
}
SettingsField::Lna => {
LNA_STEPS.iter().position(|&g| g == self.lna_gain).unwrap_or(0)
}
SettingsField::Vga => {
VGA_STEPS.iter().position(|&g| g == self.vga_gain).unwrap_or(0)
}
SettingsField::Amp => {
if self.amp_enabled { 0 } else { 1 }
}
}
}
/// Get the number of values for the active settings field
pub fn settings_value_count(&self) -> usize {
let field = SettingsField::ALL[self.settings_field_index];
match field {
SettingsField::Freq => PRESET_FREQUENCIES.len(),
SettingsField::Lna => LNA_STEPS.len(),
SettingsField::Vga => VGA_STEPS.len(),
SettingsField::Amp => 2, // ON / OFF
}
}
/// Apply the selected settings value
pub fn apply_settings_value(&mut self) -> Result<()> {
let field = SettingsField::ALL[self.settings_field_index];
match field {
SettingsField::Freq => {
if self.settings_value_index < PRESET_FREQUENCIES.len() {
let (hz, _) = PRESET_FREQUENCIES[self.settings_value_index];
self.set_frequency(hz)?;
}
}
SettingsField::Lna => {
if self.settings_value_index < LNA_STEPS.len() {
self.set_lna_gain(LNA_STEPS[self.settings_value_index])?;
}
}
SettingsField::Vga => {
if self.settings_value_index < VGA_STEPS.len() {
self.set_vga_gain(VGA_STEPS[self.settings_value_index])?;
}
}
SettingsField::Amp => {
self.set_amp(self.settings_value_index == 0)?;
}
}
Ok(())
}
/// Get the make for a protocol name
pub fn get_make_for_protocol(protocol: &str) -> &'static str {
match protocol {
p if p.starts_with("Kia") => "Kia/Hyundai",
p if p.starts_with("Ford") => "Ford",
p if p.starts_with("Fiat") => "Fiat",
"Subaru" => "Subaru",
"Suzuki" => "Suzuki",
"VAG" | "VW" => "VW/Audi/Seat/Skoda",
"PSA" => "Peugeot/Citroen",
"Star Line" => "Star Line",
"Scher-Khan" => "Scher-Khan",
_ => "Unknown",
}
}
/// Add a demo capture (for testing without HackRF)
#[allow(dead_code)]
pub fn add_demo_capture(&mut self) {
let capture = Capture {
id: self.next_capture_id,
timestamp: chrono::Utc::now(),
frequency: 433_920_000,
protocol: Some("Ford V0".to_string()),
serial: Some(0x1A2B3C4D),
button: Some(0x01),
counter: Some(1234),
crc_valid: true,
data: 0x5A2B3C4D00001234,
data_count_bit: 64,
raw_pairs: vec![],
status: crate::capture::CaptureStatus::EncoderCapable,
received_rf: None,
};
self.next_capture_id += 1;
self.captures.push(capture);
}
}