diff --git a/Cargo.lock b/Cargo.lock index 5391576..8671b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -195,7 +201,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.114", ] [[package]] @@ -206,9 +212,15 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.114", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dirs" version = "5.0.1" @@ -230,6 +242,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "either" version = "1.15.0" @@ -258,12 +276,27 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "funty" version = "2.0.0" @@ -372,7 +405,16 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", ] [[package]] @@ -415,6 +457,7 @@ dependencies = [ "num-complex", "pkg-config", "ratatui", + "rtl-sdr-rs", "serde", "serde_json", "thiserror 2.0.18", @@ -455,6 +498,18 @@ dependencies = [ "libc", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -512,6 +567,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -592,6 +680,36 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools 0.10.5", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -628,7 +746,7 @@ dependencies = [ "crossterm", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -657,6 +775,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -674,6 +804,28 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rtl-sdr-rs" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e39d1d957178d9bc3d2641e41ea083205f304b6b2db4f6efd60bc45498aa82" +dependencies = [ + "byteorder", + "log", + "mockall", + "rusb", +] + +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustix" version = "0.38.44" @@ -732,7 +884,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -850,7 +1002,18 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -870,6 +1033,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -896,7 +1065,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -907,7 +1076,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -944,7 +1113,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1007,7 +1176,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1067,7 +1236,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -1090,6 +1259,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1128,7 +1303,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -1184,7 +1359,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -1195,7 +1370,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 306a340..ebfb64a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,56 +1,59 @@ -[package] -name = "kat" -version = "1.0.1" -edition = "2021" -description = "Keyfob Analysis Toolkit - HackRF signal capture, decode, and transmit tool" -authors = ["KAT Team"] -license = "BSD-3-Clause" - -[dependencies] -# TUI -ratatui = "0.29" -crossterm = "0.28" - -# HackRF library -libhackrf = "0.1" - -# Signal processing -num-complex = "0.4" - -# Async runtime -tokio = { version = "1.43", features = ["full"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -toml = "0.8" - -# INI config -configparser = "3" - -# Config directory -dirs = "5.0" - -# Time handling -chrono = { version = "0.4", features = ["serde"] } - -# Error handling -anyhow = "1.0" -thiserror = "2.0" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Bit manipulation -bitvec = "1.0" - -# TTY detection -atty = "0.2" - -[build-dependencies] -pkg-config = "0.3" - -[profile.release] -opt-level = 3 -lto = true +[package] +name = "kat" +version = "1.0.1" +edition = "2021" +description = "Keyfob Analysis Toolkit - HackRF/RTL-SDR signal capture, decode, and transmit (HackRF only)" +authors = ["KAT Team"] +license = "BSD-3-Clause" + +[dependencies] +# TUI +ratatui = "0.29" +crossterm = "0.28" + +# HackRF library +libhackrf = "0.1" + +# RTL-SDR (receive-only support, e.g. RTL433 hardware) +rtl-sdr-rs = "0.3" + +# Signal processing +num-complex = "0.4" + +# Async runtime +tokio = { version = "1.43", features = ["full"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" + +# INI config +configparser = "3" + +# Config directory +dirs = "5.0" + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Error handling +anyhow = "1.0" +thiserror = "2.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Bit manipulation +bitvec = "1.0" + +# TTY detection +atty = "0.2" + +[build-dependencies] +pkg-config = "0.3" + +[profile.release] +opt-level = 3 +lto = true diff --git a/README.md b/README.md index f9d55d8..eef85f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # KAT — Keyfob Analysis Toolkit -A terminal-based RF signal analysis tool for capturing, decoding, and retransmitting automotive keyfob signals using HackRF One. Built in Rust with a real-time TUI powered by `ratatui`. Protocol decoders are aligned with the [ProtoPirate](REFERENCES/ProtoPirate/) reference. +A terminal-based RF signal analysis tool for capturing, decoding, and retransmitting automotive keyfob signals. Built in Rust with a real-time TUI powered by `ratatui`. Protocol decoders are aligned with the [ProtoPirate](REFERENCES/ProtoPirate/) reference. + +**Supported hardware:** KAT uses **HackRF One** (or compatible) when present for full receive and transmit. If no HackRF is found, it will use an **RTL-SDR** (e.g. RTL433-style dongles) for **receive-only** capture and decode; transmit (Lock/Unlock/Trunk/Panic, Replay) is disabled when using RTL-SDR. ![Keyfob Analysis Toolkit screenshot](images/kat-screenshot.png) @@ -24,9 +26,14 @@ A terminal-based RF signal analysis tool for capturing, decoding, and retransmit ## Requirements -- **HackRF One** (or compatible SDR) +- **Radio hardware (one of):** + - **HackRF One** (or compatible) — full receive and transmit + - **RTL-SDR** (e.g. RTL433 dongles) — receive-only; no transmit - **Rust 1.75+** (for building from source) -- **libhackrf** — HackRF C library and headers +- **libhackrf** — HackRF C library and headers (required at build time even when using RTL-SDR) +- **libusb** — for RTL-SDR (usually provided by OS) + +At runtime, KAT tries **HackRF first**; if none is found, it uses the first available **RTL-SDR**. If neither is connected, it runs in demo mode (no live capture). ### Installing Dependencies @@ -34,26 +41,29 @@ A terminal-based RF signal analysis tool for capturing, decoding, and retransmit ```bash brew install hackrf +# RTL-SDR: no extra system lib required; rtl-sdr-rs uses rusb ``` **Debian / Ubuntu:** ```bash -sudo apt install libhackrf-dev pkg-config +sudo apt install libhackrf-dev pkg-config libusb-1.0-0-dev ``` **Fedora:** ```bash -sudo dnf install hackrf-devel pkg-config +sudo dnf install hackrf-devel pkg-config libusb1-devel ``` **Arch Linux:** ```bash -sudo pacman -S hackrf +sudo pacman -S hackrf libusb ``` +On **Linux**, if an RTL-SDR is in use, you may need to unload DVB-T kernel modules so the device is not claimed by the kernel (see [rtl-sdr-rs](https://crates.io/crates/rtl-sdr-rs)). + ## Building ```bash @@ -69,7 +79,7 @@ The binary is placed at `target/release/kat`. ./target/release/kat ``` -KAT starts in an interactive terminal UI. If a HackRF device is not connected, the application runs in demo/offline mode so you can still view, import, and export captures. +KAT starts in an interactive terminal UI. If no HackRF or RTL-SDR is detected, the application runs in demo/offline mode so you can still view, import, and export captures. The header shows the active device (e.g. **HackRF** or **RTL-SDR (RX only)**). When using RTL-SDR, transmit actions (Replay, TX Lock/Unlock/Trunk/Panic) are disabled and show a message that the device is receive-only. ### Keyboard Controls @@ -267,6 +277,7 @@ src/ │ └── ... ├── radio/ │ ├── hackrf.rs # HackRF device control (RX/TX) +│ ├── rtlsdr.rs # RTL-SDR device control (RX only) │ ├── demodulator.rs # AM/OOK demodulator (IQ -> level/duration) │ └── modulator.rs # Level/duration -> TX waveform └── ui/ diff --git a/src/app.rs b/src/app.rs index da37034..bef4a43 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,1261 +1,1368 @@ -//! 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, LevelDuration}; -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 warning: HackRF not detected (dismiss to continue) - HackRfNotDetected, - /// 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, - /// License overlay (centered box) - License, - /// Credits overlay (centered box) - Credits, -} - -/// 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 { - Replay, - Lock, - Unlock, - Trunk, - Panic, - ExportFob, - ExportFlipper, - Delete, -} - -impl SignalAction { - pub const ALL: [SignalAction; 8] = [ - SignalAction::Replay, - SignalAction::Lock, - SignalAction::Unlock, - SignalAction::Trunk, - SignalAction::Panic, - SignalAction::ExportFob, - SignalAction::ExportFlipper, - SignalAction::Delete, - ]; - - pub fn label(&self) -> &'static str { - match self { - SignalAction::Replay => "Replay", - 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), -} - -/// License text (embedded at compile time) -pub const LICENSE_TEXT: &str = include_str!("../LICENSE"); - -/// 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, - /// Currently selected capture index - pub selected_capture: Option, - /// 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, - /// Last status message - pub status_message: Option, - - // -- Signal action menu state -- - /// Currently selected signal menu item index - pub signal_menu_index: usize, - - // -- License/Credits overlay -- - /// Scroll offset for license/credits overlay (lines) - pub overlay_scroll: 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, - /// Channel for radio events - radio_event_rx: Receiver, - /// Sender for radio events (cloned to radio thread) - #[allow(dead_code)] - radio_event_tx: Sender, - - // -- Startup import state -- - /// .fob files found on startup in export_dir - pub pending_fob_files: Vec, - - // -- Export state -- - /// Capture ID being exported - pub export_capture_id: Option, - /// Export filename input buffer (without extension) - pub export_filename: String, - /// Which export format is in progress - pub export_format: Option, - - // -- .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 { - 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 - } - }; - - // HackRF controller always returns Ok; when no device is present it has is_available() == false - let hackrf_detected = hackrf - .as_ref() - .map_or(false, |h| h.is_available()); - - let radio_state = if hackrf_detected { - 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 = 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 !hackrf_detected { - InputMode::HackRfNotDetected - } else 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, - overlay_scroll: 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 ".to_string()); - return Ok(()); - } - match parts[1].parse::() { - 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)?, - "license" | "licence" => { - self.input_mode = InputMode::License; - self.overlay_scroll = 0; - } - "credits" => { - self.input_mode = InputMode::Credits; - self.overlay_scroll = 0; - } - "delete" => { - if parts.len() < 2 { - self.last_error = Some("Usage: :delete 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::() { - 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::() { - 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: :{:?} ", 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, - extra: capture.data_extra, - }; - - // 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(()) - } - - /// Replay a capture by re-transmitting its raw level/duration pairs (no re-encoding). - pub fn replay_capture(&mut self, id: u32) -> Result<()> { - let capture = match self.captures.iter().find(|c| c.id == id) { - Some(c) => c, - None => { - self.last_error = Some(format!("Capture {} not found", id)); - return Ok(()); - } - }; - - if capture.raw_pairs.is_empty() { - self.last_error = Some("No raw signal to replay (capture has no level/duration data)".to_string()); - return Ok(()); - } - - let signal: Vec = capture - .raw_pairs - .iter() - .map(|p| LevelDuration::new(p.level, p.duration_us)) - .collect(); - - if let Some(ref mut hackrf) = self.hackrf { - hackrf.transmit(&signal, capture.frequency)?; - self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, signal.len())); - } 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(()) - } - - /// True if a capture with the same protocol, data, serial, and button already exists. - fn capture_duplicate_of_existing(&self, capture: &Capture) -> bool { - self.captures.iter().any(|c| { - c.protocol == capture.protocol - && c.data == capture.data - && c.serial == capture.serial - && c.button == capture.button - }) - } - - /// 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 = 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.data_extra = decoded.extra; - 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 { - // Same IQ is fed to AM and FM demodulators; both can emit for one keypress. - // Skip if we already have this exact signal (protocol + data + serial + button). - if self.capture_duplicate_of_existing(&capture) { - self.status_message = Some("Duplicate signal ignored".to_string()); - } else { - 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::Replay => { - self.replay_capture(capture_id)?; - } - 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::().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 = 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); - // Deduplicate: same signal can decode at multiple stream positions (e.g. Ford V0 across bursts) - let mut seen: std::collections::HashSet<(String, u64, Option, Option)> = - std::collections::HashSet::new(); - for (protocol_name, decoded, segment_pairs) in decoded_list { - let key = ( - protocol_name.clone(), - decoded.data, - decoded.serial, - decoded.button, - ); - if seen.contains(&key) { - continue; - } - seen.insert(key); - let raw: Vec = 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, - ); - 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.data_extra = decoded.extra; - capture.status = if decoded.encoder_capable { - crate::capture::CaptureStatus::EncoderCapable - } else { - crate::capture::CaptureStatus::Decoded - }; - if research_mode || capture.protocol.is_some() { - if !self.capture_duplicate_of_existing(&capture) { - self.next_capture_id += 1; - 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 = 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.data_extra = decoded.extra; - 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, - data_extra: None, - raw_pairs: vec![], - status: crate::capture::CaptureStatus::EncoderCapable, - received_rf: None, - }; - self.next_capture_id += 1; - self.captures.push(capture); - } -} +//! 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, LevelDuration, RtlSdrController}; +use crate::storage::Storage; + +/// Active radio device: HackRF (full TX/RX) or RTL-SDR (receive-only). +pub enum RadioDevice { + HackRf(HackRfController), + RtlSdr(RtlSdrController), +} + +impl RadioDevice { + pub fn is_available(&self) -> bool { + match self { + RadioDevice::HackRf(h) => h.is_available(), + RadioDevice::RtlSdr(r) => r.is_available(), + } + } + + pub fn supports_tx(&self) -> bool { + match self { + RadioDevice::HackRf(h) => h.supports_tx(), + RadioDevice::RtlSdr(r) => r.supports_tx(), + } + } + + pub fn start_receiving(&mut self, frequency: u32) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.start_receiving(frequency), + RadioDevice::RtlSdr(r) => r.start_receiving(frequency), + } + } + + pub fn stop_receiving(&mut self) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.stop_receiving(), + RadioDevice::RtlSdr(r) => r.stop_receiving(), + } + } + + pub fn set_frequency(&mut self, frequency: u32) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.set_frequency(frequency), + RadioDevice::RtlSdr(r) => r.set_frequency(frequency), + } + } + + pub fn set_lna_gain(&mut self, gain: u32) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.set_lna_gain(gain), + RadioDevice::RtlSdr(r) => r.set_lna_gain(gain), + } + } + + pub fn set_vga_gain(&mut self, gain: u32) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.set_vga_gain(gain), + RadioDevice::RtlSdr(r) => r.set_vga_gain(gain), + } + } + + pub fn set_amp_enable(&mut self, enabled: bool) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.set_amp_enable(enabled), + RadioDevice::RtlSdr(r) => r.set_amp_enable(enabled), + } + } + + pub fn transmit(&mut self, signal: &[LevelDuration], frequency: u32) -> anyhow::Result<()> { + match self { + RadioDevice::HackRf(h) => h.transmit(signal, frequency), + RadioDevice::RtlSdr(r) => r.transmit(signal, frequency), + } + } + + /// Display name for UI (e.g. "HackRF", "RTL-SDR (RX only)"). + pub fn display_name(&self) -> &'static str { + match self { + RadioDevice::HackRf(_) => "HackRF", + RadioDevice::RtlSdr(_) => "RTL-SDR (RX only)", + } + } +} + +/// 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 warning: HackRF not detected (dismiss to continue) + HackRfNotDetected, + /// 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, + /// License overlay (centered box) + License, + /// Credits overlay (centered box) + Credits, +} + +/// 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 { + Replay, + Lock, + Unlock, + Trunk, + Panic, + ExportFob, + ExportFlipper, + Delete, +} + +impl SignalAction { + pub const ALL: [SignalAction; 8] = [ + SignalAction::Replay, + SignalAction::Lock, + SignalAction::Unlock, + SignalAction::Trunk, + SignalAction::Panic, + SignalAction::ExportFob, + SignalAction::ExportFlipper, + SignalAction::Delete, + ]; + + pub fn label(&self) -> &'static str { + match self { + SignalAction::Replay => "Replay", + 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), +} + +/// License text (embedded at compile time) +pub const LICENSE_TEXT: &str = include_str!("../LICENSE"); + +/// 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, + /// Currently selected capture index + pub selected_capture: Option, + /// 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, + /// Last status message + pub status_message: Option, + + // -- Signal action menu state -- + /// Currently selected signal menu item index + pub signal_menu_index: usize, + + // -- License/Credits overlay -- + /// Scroll offset for license/credits overlay (lines) + pub overlay_scroll: 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, + /// Active radio device (HackRF or RTL-SDR), if any + radio: Option, + /// Channel for radio events + radio_event_rx: Receiver, + /// Sender for radio events (cloned to radio thread) + #[allow(dead_code)] + radio_event_tx: Sender, + /// Set by :q / :quit so the main loop can exit cleanly (terminal cleanup) + pub quit_requested: bool, + + // -- Startup import state -- + /// .fob files found on startup in export_dir + pub pending_fob_files: Vec, + + // -- Export state -- + /// Capture ID being exported + pub export_capture_id: Option, + /// Export filename input buffer (without extension) + pub export_filename: String, + /// Which export format is in progress + pub export_format: Option, + + // -- .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 { + 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 HackRF first, then RTL-SDR + let radio: Option = match HackRfController::new(radio_event_tx.clone()) { + Ok(mut h) if h.is_available() => { + tracing::info!("HackRF initialized successfully"); + 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(RadioDevice::HackRf(h)) + } + _ => match RtlSdrController::new(radio_event_tx.clone()) { + Ok(mut r) if r.is_available() => { + tracing::info!("RTL-SDR initialized (receive-only)"); + let _ = r.set_lna_gain(storage.config.default_lna_gain); + let _ = r.set_vga_gain(storage.config.default_vga_gain); + Some(RadioDevice::RtlSdr(r)) + } + _ => None + } + }; + + let device_detected = radio.as_ref().map_or(false, |r| r.is_available()); + + let radio_state = if device_detected { + 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 = 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 !device_detected { + InputMode::HackRfNotDetected + } else 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, + overlay_scroll: 0, + settings_field_index: 0, + settings_value_index: 0, + next_capture_id, + storage, + protocols, + radio, + radio_event_rx, + radio_event_tx, + quit_requested: false, + 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 + } + + /// Display name of the active radio device, if any (e.g. "HackRF", "RTL-SDR (RX only)"). + pub fn radio_device_name(&self) -> Option<&'static str> { + self.radio.as_ref().map(|r| r.display_name()) + } + + /// True if the active device supports transmit (HackRF only; RTL-SDR is receive-only). + pub fn can_transmit(&self) -> bool { + self.radio.as_ref().map_or(false, |r| r.supports_tx()) + } + + /// 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("No radio device connected".to_string()); + } + RadioState::Idle => { + if let Some(ref mut radio) = self.radio { + radio.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 radio) = self.radio { + radio.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" => { + self.quit_requested = true; + } + "freq" => { + if parts.len() < 2 { + self.last_error = Some("Usage: :freq ".to_string()); + return Ok(()); + } + match parts[1].parse::() { + 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)?, + "license" | "licence" => { + self.input_mode = InputMode::License; + self.overlay_scroll = 0; + } + "credits" => { + self.input_mode = InputMode::Credits; + self.overlay_scroll = 0; + } + "delete" => { + if parts.len() < 2 { + self.last_error = Some("Usage: :delete 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::() { + 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::() { + 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 + if let Some(ref mut radio) = self.radio { + if self.radio_state == RadioState::Receiving { + radio.stop_receiving()?; + radio.start_receiving(hz)?; + } else { + radio.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 radio) = self.radio { + radio.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 radio) = self.radio { + radio.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 radio) = self.radio { + radio.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; + + if let Some(ref radio) = self.radio { + if !radio.supports_tx() { + self.last_error = Some("Transmit not available – RTL-SDR is receive-only".to_string()); + return Ok(()); + } + } else { + self.last_error = Some("No radio device connected".to_string()); + return Ok(()); + } + + let id_str = match id_str { + Some(s) => s, + None => { + self.last_error = Some(format!("Usage: :{:?} ", 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, + extra: capture.data_extra, + }; + + // 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(()); + } + }; + + if let Some(ref mut radio) = self.radio { + radio.transmit(&signal, capture.frequency)?; + self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id)); + } + + Ok(()) + } + + /// Replay a capture by re-transmitting its raw level/duration pairs (no re-encoding). + pub fn replay_capture(&mut self, id: u32) -> Result<()> { + if let Some(ref radio) = self.radio { + if !radio.supports_tx() { + self.last_error = Some("Transmit not available – RTL-SDR is receive-only".to_string()); + return Ok(()); + } + } else { + self.last_error = Some("No radio device connected".to_string()); + return Ok(()); + } + + let capture = match self.captures.iter().find(|c| c.id == id) { + Some(c) => c, + None => { + self.last_error = Some(format!("Capture {} not found", id)); + return Ok(()); + } + }; + + if capture.raw_pairs.is_empty() { + self.last_error = Some("No raw signal to replay (capture has no level/duration data)".to_string()); + return Ok(()); + } + + let signal: Vec = capture + .raw_pairs + .iter() + .map(|p| LevelDuration::new(p.level, p.duration_us)) + .collect(); + + if let Some(ref mut radio) = self.radio { + radio.transmit(&signal, capture.frequency)?; + self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, signal.len())); + } + + 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(()) + } + + /// True if a capture with the same protocol, data, serial, and button already exists. + fn capture_duplicate_of_existing(&self, capture: &Capture) -> bool { + self.captures.iter().any(|c| { + c.protocol == capture.protocol + && c.data == capture.data + && c.serial == capture.serial + && c.button == capture.button + }) + } + + /// 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 = 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.data_extra = decoded.extra; + 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 { + // Same IQ is fed to AM and FM demodulators; both can emit for one keypress. + // Skip if we already have this exact signal (protocol + data + serial + button). + if self.capture_duplicate_of_existing(&capture) { + self.status_message = Some("Duplicate signal ignored".to_string()); + } else { + 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::Replay => { + self.replay_capture(capture_id)?; + } + 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::().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 = 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); + // Deduplicate: same signal can decode at multiple stream positions (e.g. Ford V0 across bursts) + let mut seen: std::collections::HashSet<(String, u64, Option, Option)> = + std::collections::HashSet::new(); + for (protocol_name, decoded, segment_pairs) in decoded_list { + let key = ( + protocol_name.clone(), + decoded.data, + decoded.serial, + decoded.button, + ); + if seen.contains(&key) { + continue; + } + seen.insert(key); + let raw: Vec = 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, + ); + 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.data_extra = decoded.extra; + capture.status = if decoded.encoder_capable { + crate::capture::CaptureStatus::EncoderCapable + } else { + crate::capture::CaptureStatus::Decoded + }; + if research_mode || capture.protocol.is_some() { + if !self.capture_duplicate_of_existing(&capture) { + self.next_capture_id += 1; + 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 = 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.data_extra = decoded.extra; + 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, + data_extra: None, + raw_pairs: vec![], + status: crate::capture::CaptureStatus::EncoderCapable, + received_rf: None, + }; + self.next_capture_id += 1; + self.captures.push(capture); + } +} diff --git a/src/main.rs b/src/main.rs index 0e23bc7..eff948f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,6 +160,9 @@ fn run_app(terminal: &mut Terminal, app: &mut A KeyCode::Enter => { let command = app.command_input.clone(); app.execute_command(&command)?; + if app.quit_requested { + return Ok(()); + } app.command_input.clear(); if app.input_mode == InputMode::Command { app.input_mode = InputMode::Normal; diff --git a/src/radio/hackrf.rs b/src/radio/hackrf.rs index edbaa5b..daa4cc6 100644 --- a/src/radio/hackrf.rs +++ b/src/radio/hackrf.rs @@ -93,6 +93,11 @@ impl HackRfController { self.hackrf_available } + /// HackRF supports transmit. + pub fn supports_tx(&self) -> bool { + true + } + /// Start receiving at the specified frequency pub fn start_receiving(&mut self, frequency: u32) -> Result<()> { if self.receiving.load(Ordering::SeqCst) { diff --git a/src/radio/mod.rs b/src/radio/mod.rs index aa3b1fd..692c420 100644 --- a/src/radio/mod.rs +++ b/src/radio/mod.rs @@ -1,11 +1,13 @@ -//! Radio subsystem for HackRF control. +//! Radio subsystem for HackRF and RTL-SDR control. pub mod demodulator; mod hackrf; mod modulator; +mod rtlsdr; pub use demodulator::LevelDuration; pub use hackrf::HackRfController; +pub use rtlsdr::RtlSdrController; #[allow(unused_imports)] pub use demodulator::Demodulator; diff --git a/src/radio/rtlsdr.rs b/src/radio/rtlsdr.rs new file mode 100644 index 0000000..e2497be --- /dev/null +++ b/src/radio/rtlsdr.rs @@ -0,0 +1,316 @@ +//! RTL-SDR device control (receive-only). +//! +//! Provides an RX path compatible with the same IQ stream format and demodulators +//! as the HackRF path. Transmit is not supported; use HackRF for TX. + +use anyhow::Result; +use std::sync::mpsc::Sender; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::thread::{self, JoinHandle}; + +use crate::app::RadioEvent; +use crate::capture::{Capture, RfModulation, StoredLevelDuration}; + +use super::demodulator::Demodulator; +use super::demodulator::FmDemodulator; +use super::demodulator::LevelDuration; + +use rtl_sdr_rs::{DeviceId, RtlSdr, TunerGain, DEFAULT_BUF_LENGTH}; + +/// Sample rate (2 MHz, same as HackRF path for keyfob signals) +const SAMPLE_RATE: u32 = 2_000_000; + +/// Tuner gain setting for RTL-SDR (manual gain in 0.1 dB units; None = auto) +#[derive(Debug, Clone, Copy)] +struct TunerGainSetting { + /// Manual gain in tenths of dB, or None for auto + gain_tenths_db: Option, +} + +impl Default for TunerGainSetting { + fn default() -> Self { + Self { + gain_tenths_db: None, // auto + } + } +} + +/// RTL-SDR controller: receive-only, no transmit. +pub struct RtlSdrController { + event_tx: Sender, + receiving: Arc, + rx_thread: Option>, + frequency: Arc>, + demodulator_am: Arc>, + demodulator_fm: Arc>, + rtlsdr_available: bool, + gain_settings: Arc>, +} + +impl RtlSdrController { + /// Create a new RTL-SDR controller (receive-only). + pub fn new(event_tx: Sender) -> Result { + let demodulator_am = Demodulator::new(SAMPLE_RATE); + let demodulator_fm = FmDemodulator::new(SAMPLE_RATE); + + let rtlsdr_available = check_rtlsdr_available(); + + if rtlsdr_available { + tracing::info!("RTL-SDR device detected (receive-only)"); + } else { + tracing::debug!("RTL-SDR not detected"); + } + + Ok(Self { + event_tx, + receiving: Arc::new(AtomicBool::new(false)), + rx_thread: None, + frequency: Arc::new(Mutex::new(433_920_000)), + demodulator_am: Arc::new(Mutex::new(demodulator_am)), + demodulator_fm: Arc::new(Mutex::new(demodulator_fm)), + rtlsdr_available, + gain_settings: Arc::new(Mutex::new(TunerGainSetting::default())), + }) + } + + /// Returns true if an RTL-SDR device was found. + pub fn is_available(&self) -> bool { + self.rtlsdr_available + } + + /// RTL-SDR is receive-only; transmit is never supported. + pub fn supports_tx(&self) -> bool { + false + } + + /// Start receiving at the specified frequency. + pub fn start_receiving(&mut self, frequency: u32) -> Result<()> { + if self.receiving.load(Ordering::SeqCst) { + return Ok(()); + } + + *self.frequency.lock().unwrap() = frequency; + self.receiving.store(true, Ordering::SeqCst); + + let receiving = self.receiving.clone(); + let event_tx = self.event_tx.clone(); + let freq = self.frequency.clone(); + let demodulator_am = self.demodulator_am.clone(); + let demodulator_fm = self.demodulator_fm.clone(); + let rtlsdr_available = self.rtlsdr_available; + let gain_settings = self.gain_settings.clone(); + + self.rx_thread = Some(thread::spawn(move || { + if rtlsdr_available { + if let Err(e) = run_receiver_rtlsdr( + receiving.clone(), + event_tx.clone(), + freq, + demodulator_am, + demodulator_fm, + gain_settings, + ) { + let _ = event_tx.send(RadioEvent::Error(format!("RTL-SDR receiver error: {}", e))); + } + } else { + run_demo_receiver(receiving, event_tx, freq); + } + })); + + tracing::info!("Started RTL-SDR receiving at {} Hz", frequency); + Ok(()) + } + + /// Stop receiving. + pub fn stop_receiving(&mut self) -> Result<()> { + self.receiving.store(false, Ordering::SeqCst); + + if let Some(handle) = self.rx_thread.take() { + let _ = handle.join(); + } + + tracing::info!("Stopped RTL-SDR receiving"); + Ok(()) + } + + /// Set the receive frequency. + pub fn set_frequency(&mut self, frequency: u32) -> Result<()> { + *self.frequency.lock().unwrap() = frequency; + tracing::info!("Set RTL-SDR frequency to {} Hz", frequency); + Ok(()) + } + + /// No-op: RTL-SDR cannot transmit. + pub fn transmit(&mut self, _signal: &[LevelDuration], _frequency: u32) -> Result<()> { + tracing::warn!("Transmit not available – RTL-SDR is receive-only"); + Ok(()) + } + + /// Set tuner gain. For RTL-SDR we use a single gain; map LNA-style (0–40 dB) to tuner gain. + /// Pass None for auto gain. + pub fn set_tuner_gain_tenths_db(&mut self, gain_tenths_db: Option) -> Result<()> { + tracing::info!( + "Set RTL-SDR tuner gain to {}", + gain_tenths_db + .map(|g| format!("{} dB", g as f32 / 10.0)) + .unwrap_or_else(|| "auto".to_string()) + ); + if let Ok(mut settings) = self.gain_settings.lock() { + settings.gain_tenths_db = gain_tenths_db; + } + Ok(()) + } + + /// Set gain from a 0–40 dB value (e.g. LNA-style); stored as tenths of dB for RTL-SDR. + pub fn set_lna_gain(&mut self, gain: u32) -> Result<()> { + self.set_tuner_gain_tenths_db(Some((gain * 10) as i32)) + } + + /// RTL-SDR has a single tuner gain; VGA change maps to same tuner gain. + pub fn set_vga_gain(&mut self, gain: u32) -> Result<()> { + self.set_tuner_gain_tenths_db(Some((gain * 10) as i32)) + } + + /// RTL-SDR has no separate amp enable; no-op for UI compatibility. + pub fn set_amp_enable(&mut self, _enabled: bool) -> Result<()> { + Ok(()) + } +} + +impl Drop for RtlSdrController { + fn drop(&mut self) { + self.receiving.store(false, Ordering::SeqCst); + if let Some(handle) = self.rx_thread.take() { + let _ = handle.join(); + } + } +} + +fn check_rtlsdr_available() -> bool { + match RtlSdr::open(DeviceId::Index(0)) { + Ok(mut dev) => { + if let Err(e) = dev.close() { + tracing::debug!("RTL-SDR close after probe: {:?}", e); + } + true + } + Err(e) => { + tracing::debug!("RTL-SDR not available: {:?}", e); + false + } + } +} + +fn run_demo_receiver( + receiving: Arc, + _event_tx: Sender, + _frequency: Arc>, +) { + tracing::info!("Demo receiver thread started (no RTL-SDR)"); + + while receiving.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + tracing::info!("Demo receiver thread stopped"); +} + +fn pairs_to_stored(pairs: &[LevelDuration]) -> Vec { + pairs + .iter() + .map(|p| StoredLevelDuration { + level: p.level, + duration_us: p.duration_us, + }) + .collect() +} + +/// Convert RTL-SDR u8 IQ (0–255) to signed i8 (-128..127) for the demodulators. +fn u8_iq_to_i8(buf: &[u8]) -> Vec { + buf.iter() + .map(|&b| (b as i16 - 128) as i8) + .collect() +} + +/// Run the receiver loop with an RTL-SDR device. +fn run_receiver_rtlsdr( + receiving: Arc, + event_tx: Sender, + frequency: Arc>, + demodulator_am: Arc>, + demodulator_fm: Arc>, + gain_settings: Arc>, +) -> Result<()> { + use anyhow::Context; + + tracing::info!("RTL-SDR receiver thread starting..."); + + let mut sdr = RtlSdr::open(DeviceId::Index(0)).context("Failed to open RTL-SDR device")?; + + let freq = *frequency.lock().unwrap(); + let initial_gain = *gain_settings.lock().unwrap(); + + sdr.reset_buffer().context("Failed to reset RTL-SDR buffer")?; + sdr.set_center_freq(freq).context("Failed to set RTL-SDR frequency")?; + sdr.set_sample_rate(SAMPLE_RATE).context("Failed to set RTL-SDR sample rate")?; + sdr.set_bias_tee(false).context("Failed to set bias-tee")?; + + if let Some(tenths) = initial_gain.gain_tenths_db { + sdr.set_tuner_gain(TunerGain::Manual(tenths)).context("Failed to set RTL-SDR gain")?; + } else { + sdr.set_tuner_gain(TunerGain::Auto).context("Failed to set RTL-SDR gain")?; + } + + tracing::info!( + "RTL-SDR configured: freq={} Hz, sample_rate={} Hz", + freq, SAMPLE_RATE + ); + + let capture_id = std::sync::atomic::AtomicU32::new(0); + let mut buf = vec![0u8; DEFAULT_BUF_LENGTH]; + + while receiving.load(Ordering::SeqCst) { + match sdr.read_sync(&mut buf) { + Ok(n) if n == buf.len() => { + let current_freq = *frequency.lock().unwrap(); + let samples = u8_iq_to_i8(&buf[..n]); + + if let Ok(mut demod) = demodulator_am.lock() { + if let Some(pairs) = demod.process_samples(&samples) { + let id = capture_id.fetch_add(1, Ordering::SeqCst); + let capture = Capture::from_pairs_with_rf( + id, + current_freq, + pairs_to_stored(&pairs), + Some(RfModulation::AM), + ); + let _ = event_tx.send(RadioEvent::SignalCaptured(capture)); + } + } + if let Ok(mut demod) = demodulator_fm.lock() { + if let Some(pairs) = demod.process_samples(&samples) { + let id = capture_id.fetch_add(1, Ordering::SeqCst); + let capture = Capture::from_pairs_with_rf( + id, + current_freq, + pairs_to_stored(&pairs), + Some(RfModulation::FM), + ); + let _ = event_tx.send(RadioEvent::SignalCaptured(capture)); + } + } + } + Ok(_) => {} + Err(e) => { + tracing::warn!("RTL-SDR read error: {:?}", e); + } + } + } + + sdr.close().context("Failed to close RTL-SDR")?; + tracing::info!("RTL-SDR receiver thread stopped"); + Ok(()) +} diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 8d6c78b..9e925dd 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -114,7 +114,7 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { /// Render the header with title and radio status fn render_header(frame: &mut Frame, area: Rect, app: &App) { let (status_symbol, status_style) = match app.radio_state { - RadioState::Disconnected => ("○", Style::default().fg(Color::DarkGray)), + RadioState::Disconnected => ("○", Style::default().fg(Color::Red)), RadioState::Idle => ("○", Style::default().fg(Color::Yellow)), RadioState::Receiving => ("●", Style::default().fg(Color::Green)), RadioState::Transmitting => ("●", Style::default().fg(Color::Red)), @@ -122,12 +122,16 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) { let title = format!("Keyfob Analysis Toolkit v{}", VERSION); - // Build radio info string with all settings + // Build radio info string: device name (if any), state, freq, gains let amp_str = if app.amp_enabled { "ON" } else { "OFF" }; + let device_str = app + .radio_device_name() + .unwrap_or("No device"); let radio_info = format!( - "{} {} | {:.2} MHz | LNA:{} VGA:{} AMP:{}", + "{} {} | {} | {:.2} MHz | LNA:{} VGA:{} AMP:{}", status_symbol, app.radio_state, + device_str, app.frequency_mhz(), app.lna_gain, app.vga_gain, @@ -196,10 +200,10 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { Rect::new(x, y, width.min(area.width), height.min(area.height)) } -/// Render the HackRF not detected warning (red box at startup) +/// Render the no-device warning (red box at startup when neither HackRF nor RTL-SDR found) fn render_hackrf_not_detected(frame: &mut Frame, _app: &App) { let area = frame.area(); - let popup = centered_rect(52, 7, area); + let popup = centered_rect(56, 8, area); frame.render_widget(Clear, popup); @@ -207,12 +211,16 @@ fn render_hackrf_not_detected(frame: &mut Frame, _app: &App) { let text = vec![ Line::from(""), Line::from(Span::styled( - " Your HackRF was not detected.", + " No HackRF or RTL-SDR detected.", Style::default().fg(red).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled( - " Connect a HackRF One and restart, or continue in demo mode.", + " Connect a HackRF (TX+RX) or RTL-SDR (RX only) and restart,", + Style::default().fg(red), + )), + Line::from(Span::styled( + " or continue without TX/RX support.", Style::default().fg(red), )), Line::from(""), diff --git a/src/ui/signal_menu.rs b/src/ui/signal_menu.rs index 3a79323..aad0b4e 100644 --- a/src/ui/signal_menu.rs +++ b/src/ui/signal_menu.rs @@ -77,8 +77,19 @@ pub fn render_signal_menu(frame: &mut Frame, app: &App) { " " }; + let is_tx_action = matches!( + action, + SignalAction::Replay | SignalAction::Lock | SignalAction::Unlock + | SignalAction::Trunk | SignalAction::Panic + ); + let suffix = if is_tx_action && !app.can_transmit() { + " (no TX)" + } else { + "" + }; + items.push(ListItem::new(Line::from(Span::styled( - format!("{}{}", prefix, action.label()), + format!("{}{}{}", prefix, action.label(), suffix), style, )))); }