Release 1.1.3: command field, unknown signals default, 80ms gap, 5-pair min, export hex uppercase

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
KaraZajac
2026-02-22 20:59:43 -05:00
parent 39944e072b
commit 334491db4e
10 changed files with 237 additions and 37 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to KAT are documented here.
## [1.1.3] - 2026-02-20
### Added
- **Command field** — Capture metadata (press **i**) and .fob export now include **Command** (e.g. Unlock, Lock, Trunk, Panic). Export filename for unknown protocol uses Year_Make_Model_Region_Command; 8-hex suffix is shown in the filename field and saved in uppercase (e.g. `…_A1B2C3D4.fob`). .fob vehicle info and import support optional `command`.
### Changed
- **Unknown signals** — Shown by default (`research_mode` default is now `true`). Config comment and storage docs clarify that no keystore directory is used or created (keys are embedded).
- **Signal separation** — End-of-signal gap increased from 20ms to **80ms** so one button press (multiple bursts with 2550ms gaps) produces a single capture instead of 34.
- **Short signals** — Demodulator now emits captures with **5+** level-duration pairs (was 10), so short or weak unknown keyfob bursts are no longer dropped (RSSI spike but no capture).
### Fixed
- **Export filename** — Unknown-protocol .fob exports always get the 8-hex suffix (including when filename ends with `Unknown`); suffix is uppercase.
---
## [1.1.2] - 2026-02-20
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kat"
version = "1.1.2"
version = "1.1.3"
edition = "2021"
description = "Keyfob Analysis Toolkit - HackRF/RTL-SDR signal capture, decode, and transmit (HackRF only)"
authors = ["KAT Team"]
+113 -7
View File
@@ -125,13 +125,16 @@ pub enum InputMode {
FobMetaModel,
/// Fob export metadata: editing region field
FobMetaRegion,
/// Fob export metadata: editing command field
FobMetaCommand,
/// Fob export metadata: editing notes field
FobMetaNotes,
/// Capture metadata (Year/Make/Model/Region) for vuln lookup — press i on a capture
/// Capture metadata (Year/Make/Model/Region/Command) for vuln lookup — press i on a capture
CaptureMetaYear,
CaptureMetaMake,
CaptureMetaModel,
CaptureMetaRegion,
CaptureMetaCommand,
/// License overlay (centered box)
License,
/// Credits overlay (centered box)
@@ -357,14 +360,17 @@ pub struct App {
pub fob_meta_model: String,
/// Region input buffer (e.g. NA, EU, APAC, etc.)
pub fob_meta_region: String,
/// Command input buffer (e.g. Unlock, Lock)
pub fob_meta_command: String,
/// Notes input buffer
pub fob_meta_notes: String,
// -- Capture metadata (Year/Make/Model/Region for vuln lookup, set via 'i') --
// -- Capture metadata (Year/Make/Model/Region/Command for vuln lookup, set via 'i') --
pub capture_meta_year: String,
pub capture_meta_make: String,
pub capture_meta_model: String,
pub capture_meta_region: String,
pub capture_meta_command: String,
/// Which capture is being edited (when in CaptureMeta* modes)
pub capture_meta_capture_id: Option<u32>,
@@ -486,11 +492,13 @@ impl App {
fob_meta_make: String::new(),
fob_meta_model: String::new(),
fob_meta_region: String::new(),
fob_meta_command: String::new(),
fob_meta_notes: String::new(),
capture_meta_year: String::new(),
capture_meta_make: String::new(),
capture_meta_model: String::new(),
capture_meta_region: String::new(),
capture_meta_command: String::new(),
capture_meta_capture_id: None,
pending_transmit_queue: Vec::new(),
pending_transmit_restore: None,
@@ -1299,13 +1307,53 @@ impl App {
.unwrap_or_else(|_| path.to_string_lossy().to_string())
}
/// For unknown protocol: Year_Make_Model_Region_Command (user can edit; we append _<8 hex> on save).
/// For known protocol: protocol_serial as before.
fn default_export_filename(capture: &Capture) -> String {
if capture.protocol_name().eq_ignore_ascii_case("unknown") {
let year = capture
.year
.as_deref()
.unwrap_or("Unknown")
.trim()
.replace(' ', "_");
let make = capture
.make
.as_deref()
.unwrap_or("Unknown")
.trim()
.replace(' ', "_");
let model = capture
.model
.as_deref()
.unwrap_or("Unknown")
.trim()
.replace(' ', "_");
let region = capture
.region
.as_deref()
.unwrap_or("Unknown")
.trim()
.replace(' ', "_");
let cmd_str = capture
.command
.as_deref()
.unwrap_or_else(|| capture.button_name())
.trim();
let command = if cmd_str.is_empty() || cmd_str == "-" {
"Unknown".to_string()
} else {
cmd_str.replace(' ', "_")
};
format!("{}_{}_{}_{}_{}", year, make, model, region, command)
} else {
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<()> {
@@ -1314,13 +1362,23 @@ impl App {
return Ok(());
}
// Pre-fill filename from protocol + serial
let default_name = self.captures.iter().find(|c| c.id == id)
// Pre-fill filename from protocol + serial; for unknown captures include 8-hex suffix so user sees it
let capture = self.captures.iter().find(|c| c.id == id);
let default_name = capture
.map(|c| Self::default_export_filename(c))
.unwrap_or_else(|| format!("capture_{}", id));
self.export_filename = if capture.map(|c| c.protocol.is_none()).unwrap_or(false) {
let suffix_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32;
format!("{}_{:08X}", default_name, suffix)
} else {
default_name.clone()
};
// Pre-fill metadata from capture if set, otherwise make from protocol
let capture = self.captures.iter().find(|c| c.id == id);
let make = capture
.and_then(|c| c.make.as_ref().map(String::clone))
.filter(|s| !s.is_empty())
@@ -1330,7 +1388,6 @@ impl App {
.unwrap_or_default()
});
self.export_capture_id = Some(id);
self.export_filename = default_name;
self.export_format = Some(ExportFormat::Fob);
self.fob_meta_year = capture
.and_then(|c| c.year.as_ref())
@@ -1345,6 +1402,16 @@ impl App {
.and_then(|c| c.region.as_ref())
.map(String::clone)
.unwrap_or_default();
self.fob_meta_command = capture
.and_then(|c| c.command.clone())
.unwrap_or_else(|| {
let b = capture.map(|c| c.button_name().to_string()).unwrap_or_default();
if b.is_empty() || b == "-" {
String::new()
} else {
b
}
});
self.fob_meta_notes = String::new();
self.input_mode = InputMode::ExportFilename;
Ok(())
@@ -1378,10 +1445,37 @@ impl App {
make: self.fob_meta_make.clone(),
model: self.fob_meta_model.clone(),
region: self.fob_meta_region.clone(),
command: self.fob_meta_command.clone(),
notes: self.fob_meta_notes.clone(),
};
let filename = format!("{}.fob", self.export_filename);
// Unknown captures or user-edited vehicle-style filenames always get 8-hex suffix to avoid overwrites.
let use_hex_suffix = capture.protocol.is_none()
|| self.export_filename.ends_with("_Unknown")
|| self.export_filename == "Unknown";
let already_has_8hex = self.export_filename.len() >= 9
&& self.export_filename.as_bytes()[self.export_filename.len() - 9] == b'_'
&& self.export_filename[self.export_filename.len() - 8..]
.chars()
.all(|c| c.is_ascii_hexdigit());
let filename = if use_hex_suffix && already_has_8hex {
format!("{}.fob", self.export_filename.trim())
} else if use_hex_suffix {
let base = self.export_filename.trim();
let suffix_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32;
let hex_suffix = format!("{:08X}", suffix);
if base.is_empty() {
format!("unknown_{}_{}.fob", id, hex_suffix)
} else {
format!("{}_{}.fob", base, hex_suffix)
}
} else {
format!("{}.fob", self.export_filename)
};
let path = export_dir.join(&filename);
crate::export::fob::export_fob(
@@ -1416,6 +1510,16 @@ impl App {
.and_then(|c| c.region.as_ref())
.map(|s| s.to_string())
.unwrap_or_default();
self.capture_meta_command = capture
.and_then(|c| c.command.clone())
.unwrap_or_else(|| {
let b = capture.map(|c| c.button_name().to_string()).unwrap_or_default();
if b.is_empty() || b == "-" {
String::new()
} else {
b
}
});
self.capture_meta_capture_id = Some(capture_id);
self.input_mode = InputMode::CaptureMetaYear;
}
@@ -1435,6 +1539,7 @@ impl App {
capture.make = Some(self.capture_meta_make.clone()).filter(|s| !s.is_empty());
capture.model = Some(self.capture_meta_model.clone()).filter(|s| !s.is_empty());
capture.region = Some(self.capture_meta_region.clone()).filter(|s| !s.is_empty());
capture.command = Some(self.capture_meta_command.clone()).filter(|s| !s.is_empty());
}
self.input_mode = InputMode::Normal;
self.capture_meta_capture_id = None;
@@ -1835,6 +1940,7 @@ impl App {
make: None,
model: None,
region: None,
command: None,
source_file: None,
};
self.next_capture_id += 1;
+4
View File
@@ -76,6 +76,9 @@ pub struct Capture {
/// Region (e.g. NA, EU) for vulnerability lookup and .fob export.
#[serde(default)]
pub region: Option<String>,
/// User-editable command label (e.g. Unlock, Lock) for .fob export and filename; set via 'i' or export form.
#[serde(default)]
pub command: Option<String>,
/// Source file path when imported from .sub or .fob; None for live captures.
#[serde(default)]
pub source_file: Option<String>,
@@ -157,6 +160,7 @@ impl Capture {
make: None,
model: None,
region: None,
command: None,
source_file: None,
}
}
+21 -4
View File
@@ -14,6 +14,8 @@ pub struct FobMetadata {
pub make: String,
pub model: String,
pub region: String,
/// Command label (e.g. Unlock, Lock) for export filename and .fob vehicle info.
pub command: String,
pub notes: String,
}
@@ -61,6 +63,9 @@ pub struct FobVehicleInfo {
pub model: Option<String>,
#[serde(default)]
pub region: Option<String>,
/// Command label (e.g. Unlock, Lock); optional for backwards compatibility.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
@@ -119,6 +124,14 @@ pub fn export_fob(
Some(m.notes.clone())
}
});
let command = metadata.and_then(|m| {
let s = m.command.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
});
let raw_pairs = if include_raw && !capture.raw_pairs.is_empty() {
Some(
@@ -163,6 +176,7 @@ pub fn export_fob(
make,
model,
region,
command,
notes,
},
capture: FobCapture {
@@ -287,6 +301,7 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result<Capture> {
CaptureStatus::Unknown
};
let vehicle = &fob.vehicle;
Ok(Capture {
id: next_id,
timestamp,
@@ -302,10 +317,11 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result<Capture> {
raw_pairs,
status,
received_rf: None,
year: None,
make: None,
model: None,
region: None,
year: vehicle.year.map(|y| y.to_string()),
make: Some(vehicle.make.clone()).filter(|s| !s.is_empty()),
model: vehicle.model.clone().filter(|s| !s.is_empty()),
region: vehicle.region.clone().filter(|s| !s.is_empty()),
command: vehicle.command.clone().filter(|s| !s.is_empty()),
source_file: None,
})
}
@@ -377,6 +393,7 @@ fn import_fob_v1(fob: &FobFileV1, next_id: u32) -> Result<Capture> {
make: None,
model: None,
region: None,
command: None,
source_file: None,
})
}
+37 -4
View File
@@ -388,10 +388,10 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
_ => {}
},
// .fob export metadata: Region -> Notes
// .fob export metadata: Region -> Command
InputMode::FobMetaRegion => match key.code {
KeyCode::Enter => {
app.input_mode = InputMode::FobMetaNotes;
app.input_mode = InputMode::FobMetaCommand;
}
KeyCode::Char(c) => {
app.fob_meta_region.push(c);
@@ -406,6 +406,24 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
_ => {}
},
// .fob export metadata: Command -> Notes
InputMode::FobMetaCommand => match key.code {
KeyCode::Enter => {
app.input_mode = InputMode::FobMetaNotes;
}
KeyCode::Char(c) => {
app.fob_meta_command.push(c);
}
KeyCode::Backspace => {
app.fob_meta_command.pop();
}
KeyCode::Esc => {
app.export_capture_id = None;
app.input_mode = InputMode::Normal;
}
_ => {}
},
// .fob export metadata: Notes -> Export
InputMode::FobMetaNotes => match key.code {
KeyCode::Enter => {
@@ -425,7 +443,7 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
_ => {}
},
// Capture metadata (Year/Make/Model/Region for vuln lookup)
// Capture metadata (Year/Make/Model/Region/Command for vuln lookup)
InputMode::CaptureMetaYear => match key.code {
KeyCode::Enter => {
app.input_mode = InputMode::CaptureMetaMake;
@@ -475,7 +493,7 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
},
InputMode::CaptureMetaRegion => match key.code {
KeyCode::Enter => {
app.save_capture_meta();
app.input_mode = InputMode::CaptureMetaCommand;
}
KeyCode::Char(c) => {
app.capture_meta_region.push(c);
@@ -488,6 +506,21 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
}
_ => {}
},
InputMode::CaptureMetaCommand => match key.code {
KeyCode::Enter => {
app.save_capture_meta();
}
KeyCode::Char(c) => {
app.capture_meta_command.push(c);
}
KeyCode::Backspace => {
app.capture_meta_command.pop();
}
KeyCode::Esc => {
app.cancel_capture_meta();
}
_ => {}
},
InputMode::LoadFileBrowser => {
const VISIBLE: usize = 16;
+7 -7
View File
@@ -113,7 +113,7 @@ impl Demodulator {
pairs: Vec::with_capacity(2048),
total_samples: 0,
min_duration_us: 40, // 40µs debounce (was 50 — slightly more permissive)
max_gap_us: 20_000, // 20ms gap = end of signal (was 10ms — wider to avoid splitting signals with internal gaps)
max_gap_us: 80_000, // 80ms gap = end of signal; keeps multi-burst keyfob presses (e.g. 34 bursts with 2550ms gaps) as one capture
samples_since_edge: 0,
}
}
@@ -253,11 +253,11 @@ impl Demodulator {
.push(LevelDuration::new(self.current_level, duration_us));
}
// Return the pairs and reset
// Return the pairs and reset (min 5 pairs so short/unknown keyfob bursts still show)
let result = std::mem::take(&mut self.pairs);
self.reset_state();
if result.len() >= 10 {
if result.len() >= 5 {
return Some(result);
}
}
@@ -405,7 +405,7 @@ impl FmDemodulator {
pending_count: 0,
pairs: Vec::with_capacity(2048),
min_duration_us: 40,
max_gap_us: 20_000,
max_gap_us: 80_000, // match AM: 80ms so one button press (multi-burst) stays one capture
samples_since_edge: 0,
}
}
@@ -495,7 +495,7 @@ impl FmDemodulator {
}
let result = std::mem::take(&mut self.pairs);
self.fm_reset_state();
if result.len() >= 10 {
if result.len() >= 5 {
return Some(result);
}
}
@@ -559,8 +559,8 @@ mod tests {
// Process (won't return signal yet since no long gap)
let _ = demod.process_samples(&buf);
// Add a long gap to flush
let gap_buf: Vec<i8> = vec![1, 0].repeat(50_000); // 25ms LOW
// Add a long gap to flush (>= max_gap_us: 80ms at 2MHz = 160k samples)
let gap_buf: Vec<i8> = vec![1, 0].repeat(80_000); // 80ms LOW
if let Some(pairs) = demod.process_samples(&gap_buf) {
// Verify no consecutive same-level pairs
for window in pairs.windows(2) {
+5 -3
View File
@@ -1,6 +1,7 @@
//! Storage management for configuration, exports, and keystores.
//! Storage management for configuration and exports.
//!
//! All application data lives under `~/.config/KAT/`:
//! All application data lives under `~/.config/KAT/`. **No keystore directory is created**
//! — keys are embedded in the binary (see [crate::protocols::keys] and [crate::keystore]).
//!
//! ```text
//! ~/.config/KAT/
@@ -57,7 +58,7 @@ impl Config {
export_directory: config_dir.join("exports"),
import_directory: config_dir.join("import"),
max_captures: 100,
research_mode: false,
research_mode: true, // show unknown signals by default (researchers need to see them)
default_frequency: 433_920_000,
default_lna_gain: 24,
default_vga_gain: 20,
@@ -160,6 +161,7 @@ impl Config {
; Location: {path}
;
; Edit this file to change default settings.
; Keys are embedded in the program — no keystore directory is used or created.
; Lines starting with ; or # are comments.
[general]
+3 -1
View File
@@ -53,6 +53,7 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaCommand
| InputMode::FobMetaNotes => (
String::new(),
"EXPORT",
@@ -61,7 +62,8 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
InputMode::CaptureMetaYear
| InputMode::CaptureMetaMake
| InputMode::CaptureMetaModel
| InputMode::CaptureMetaRegion => (
| InputMode::CaptureMetaRegion
| InputMode::CaptureMetaCommand => (
String::new(),
"META",
Style::default().fg(Color::Cyan),
+23 -5
View File
@@ -105,6 +105,7 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaCommand
| InputMode::FobMetaNotes
) {
render_export_form(frame, app);
@@ -116,6 +117,7 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
| InputMode::CaptureMetaMake
| InputMode::CaptureMetaModel
| InputMode::CaptureMetaRegion
| InputMode::CaptureMetaCommand
) {
render_capture_meta_form(frame, app);
}
@@ -250,12 +252,14 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion => "Enter: Next Field | Esc: Cancel Export",
| InputMode::FobMetaRegion
| InputMode::FobMetaCommand => "Enter: Next Field | Esc: Cancel Export",
InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export",
InputMode::CaptureMetaYear
| InputMode::CaptureMetaMake
| InputMode::CaptureMetaModel => "Enter: Next Field | Esc: Cancel",
InputMode::CaptureMetaRegion => "Enter: Save | Esc: Cancel",
| InputMode::CaptureMetaModel
| InputMode::CaptureMetaRegion => "Enter: Next Field | Esc: Cancel",
InputMode::CaptureMetaCommand => "Enter: Save | Esc: Cancel",
InputMode::License | InputMode::Credits => "Esc/Enter: Close | Up/Down: Scroll",
InputMode::LoadFileBrowser => "Up/Down: Navigate | Enter: Open/Import | Esc: Close",
};
@@ -464,6 +468,7 @@ fn render_export_form(frame: &mut Frame, app: &App) {
InputMode::FobMetaMake,
InputMode::FobMetaModel,
InputMode::FobMetaRegion,
InputMode::FobMetaCommand,
InputMode::FobMetaNotes,
]
} else {
@@ -566,11 +571,17 @@ fn render_export_form(frame: &mut Frame, app: &App) {
placeholder: "(e.g. NA, EU, APAC, MEA)",
idx: 4,
},
FormField {
label: " Command: ",
value: &app.fob_meta_command,
placeholder: "(e.g. Unlock, Lock, Trunk, Panic)",
idx: 5,
},
FormField {
label: " Notes: ",
value: &app.fob_meta_notes,
placeholder: "(optional — color, trim, VIN, etc.)",
idx: 5,
idx: 6,
},
]);
}
@@ -670,6 +681,7 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) {
InputMode::CaptureMetaMake,
InputMode::CaptureMetaModel,
InputMode::CaptureMetaRegion,
InputMode::CaptureMetaCommand,
];
let current_idx = field_modes
.iter()
@@ -726,7 +738,7 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) {
placeholder: &'a str,
idx: usize,
}
let fields: [FormField; 4] = [
let fields: [FormField; 5] = [
FormField {
label: " Year: ",
value: &app.capture_meta_year,
@@ -751,6 +763,12 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) {
placeholder: "(e.g. NA, EU, or ALL)",
idx: 3,
},
FormField {
label: " Command: ",
value: &app.capture_meta_command,
placeholder: "(e.g. Unlock, Lock, Trunk, Panic)",
idx: 4,
},
];
for field in &fields {