1064 lines
36 KiB
Rust
1064 lines
36 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 keystore ──────────────────
|
|
let keystore_dir = storage.keystore_dir();
|
|
crate::protocols::keys::create_default_keystore(&keystore_dir);
|
|
crate::protocols::keys::load_keystore_from_dir(&keystore_dir);
|
|
|
|
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;
|
|
|
|
// Scan for .fob files in the export directory
|
|
let pending_fob_files = crate::export::fob::scan_fob_files(&storage.config.export_directory);
|
|
let initial_mode = if !pending_fob_files.is_empty() {
|
|
tracing::info!("Found {} .fob files in export dir", 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 let Some(ref mut hackrf) = self.hackrf {
|
|
if self.radio_state == RadioState::Receiving {
|
|
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 a capture (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) => {
|
|
// Assign ID
|
|
capture.id = self.next_capture_id;
|
|
self.next_capture_id += 1;
|
|
|
|
// 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
|
|
};
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
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 files into captures list
|
|
pub fn import_fob_files(&mut self) -> Result<()> {
|
|
let files = std::mem::take(&mut self.pending_fob_files);
|
|
let mut imported = 0;
|
|
|
|
for path in &files {
|
|
match crate::export::fob::import_fob(path, self.next_capture_id) {
|
|
Ok(capture) => {
|
|
self.next_capture_id += 1;
|
|
self.captures.push(capture);
|
|
imported += 1;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to import {:?}: {}", path, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if imported > 0 {
|
|
self.selected_capture = Some(0);
|
|
self.status_message = Some(format!("Imported {} .fob file(s)", imported));
|
|
}
|
|
|
|
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,
|
|
};
|
|
self.next_capture_id += 1;
|
|
self.captures.push(capture);
|
|
}
|
|
}
|