Version 1.0.0
This commit is contained in:
+999
@@ -0,0 +1,999 @@
|
||||
//! 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,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
|
||||
// -- .fob export metadata state --
|
||||
/// Capture ID being exported (set before entering FobMeta modes)
|
||||
pub export_capture_id: Option<u32>,
|
||||
/// 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()?;
|
||||
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(h) => {
|
||||
tracing::info!("HackRF initialized successfully");
|
||||
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,
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Start .fob export by entering metadata 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 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.fob_meta_year = String::new();
|
||||
self.fob_meta_make = make;
|
||||
self.fob_meta_model = String::new();
|
||||
self.fob_meta_region = String::new();
|
||||
self.fob_meta_notes = String::new();
|
||||
self.input_mode = InputMode::FobMetaYear;
|
||||
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",
|
||||
capture.protocol_name().replace(' ', "_").to_lowercase(),
|
||||
capture.serial_hex()
|
||||
);
|
||||
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.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());
|
||||
}
|
||||
|
||||
/// Export capture as Flipper .sub file
|
||||
pub fn export_flipper(&mut self, id: u32) -> Result<()> {
|
||||
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",
|
||||
capture.protocol_name().replace(' ', "_").to_lowercase(),
|
||||
capture.serial_hex()
|
||||
);
|
||||
let path = export_dir.join(&filename);
|
||||
|
||||
crate::export::flipper::export_flipper_sub(&capture, &path)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user