fob export fixes

This commit is contained in:
KaraZajac
2026-02-22 21:37:56 -05:00
parent 334491db4e
commit 5d0bd97954
3 changed files with 77 additions and 69 deletions
Generated
+1 -1
View File
@@ -444,7 +444,7 @@ dependencies = [
[[package]] [[package]]
name = "kat" name = "kat"
version = "1.1.2" version = "1.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"atty", "atty",
+22 -5
View File
@@ -119,11 +119,12 @@ Press `Enter` on a capture to open the action menu. When using RTL-SDR (receive-
When exporting to `.fob`, a metadata form collects filename and optional vehicle info: When exporting to `.fob`, a metadata form collects filename and optional vehicle info:
- **File** — output filename (extension added by format) - **File** — output filename (extension added by format). For unknown protocol, a unique 8-character hex suffix (e.g. `A1B2C3D4`) is shown in the field and appended to the filename so each export has a distinct file.
- **Year** — vehicle model year - **Year** — vehicle model year
- **Make** — manufacturer (auto-suggested from protocol) - **Make** — manufacturer (auto-suggested from protocol)
- **Model** — vehicle model - **Model** — vehicle model
- **Region** — region/market - **Region** — region/market
- **Command** — button/command label (e.g. Unlock, Lock, Trunk, Panic); used in the default filename for unknown protocol and stored in the .fob vehicle section.
- **Notes** — free-form notes - **Notes** — free-form notes
The exported `.fob` file is a versioned JSON document (v2.0, format `kat-fob`) containing: The exported `.fob` file is a versioned JSON document (v2.0, format `kat-fob`) containing:
@@ -153,17 +154,22 @@ The exported `.fob` file is a versioned JSON document (v2.0, format `kat-fob`) c
"year": 2023, "year": 2023,
"make": "Kia", "make": "Kia",
"model": "Sportage", "model": "Sportage",
"region": "", "region": "NA",
"command": "Lock",
"notes": "" "notes": ""
}, },
"capture": { "capture": {
"timestamp": "2026-02-07T12:00:00Z", "timestamp": "2026-02-07T12:00:00Z",
"raw_data_hex": "0x...",
"raw_pair_count": 0,
"raw_pairs": [{"level": true, "duration_us": 400}, {"level": false, "duration_us": 800}] "raw_pairs": [{"level": true, "duration_us": 400}, {"level": false, "duration_us": 800}]
} }
} }
``` ```
`rf_modulation` is AM, FM, or AM/FM per protocol (from ProtoPirate). Raw pairs are included when config `include_raw_pairs` is true. - **vehicle.command** — optional; user-editable command label (e.g. Unlock, Lock). Set in the export form or via **i** (capture metadata). Used for unknown-protocol export filenames (`Year_Make_Model_Region_Command_8HEX.fob`).
- **rf_modulation** — AM, FM, or AM/FM per protocol (from ProtoPirate). Omitted when unknown.
- **capture** — `raw_data_hex`, `raw_pair_count`, and optionally `raw_pairs` when config `include_raw_pairs` is true.
### VIM-Style Commands ### VIM-Style Commands
@@ -213,7 +219,7 @@ include_raw_pairs = true
``` ```
- **import_directory** — directory scanned at startup for .fob and .sub files to import (default `~/.config/KAT/import`). Exports are still saved to **export_directory**. - **import_directory** — directory scanned at startup for .fob and .sub files to import (default `~/.config/KAT/import`). Exports are still saved to **export_directory**.
- **research_mode** — when `false` (default), only successfully decoded signals appear in the list; when `true`, unknown (unidentified) signals are also shown. - **research_mode** — when `true` (default), unknown (unidentified) signals are shown in addition to decoded ones; when `false`, only successfully decoded signals appear.
- **include_raw_pairs** — when `true`, .fob exports include raw level/duration pairs for replay. - **include_raw_pairs** — when `true`, .fob exports include raw level/duration pairs for replay.
## Supported Protocols ## Supported Protocols
@@ -268,7 +274,7 @@ The **AM/OOK** demodulator turns IQ samples into level/duration pairs for protoc
- **Exponential moving average** — magnitude smoothing - **Exponential moving average** — magnitude smoothing
- **Schmitt trigger hysteresis** — reduces chattering at the decision boundary - **Schmitt trigger hysteresis** — reduces chattering at the decision boundary
- **Debounce** — 40µs minimum pulse width to reject noise spikes - **Debounce** — 40µs minimum pulse width to reject noise spikes
- **Gap detection** — 20 ms gap treated as end of signal - **Gap detection** — 80 ms gap treated as end of signal (multi-burst keyfob presses stay one capture)
## IMPORTS folder ## IMPORTS folder
@@ -314,6 +320,17 @@ src/
└── status_bar.rs # Status bar └── status_bar.rs # Status bar
``` ```
## Call for researchers
**We need your help.** KATs protocol decoders and future analysis depend on real-world keyfob captures. If you use KAT for security research, authorized testing, or protocol development, please consider:
- **Contributing to protocol analysis** — share timing, encoding, or decoder feedback; report bugs or suggest new protocols.
- **Contributing captures to the research library** — add your `.fob` files to the **[FOB Research Library](https://github.com/KaraZajac/FOB_Research_Library)** so others can use them for decoder development, CVE validation, and protocol studies.
The [FOB Research Library](https://github.com/KaraZajac/FOB_Research_Library) is a community collection of KAT `.fob` captures organized by manufacturer. Your submissions (with proper metadata: year, make, model, region, command) help grow test vectors and support the wider keyfob research community.
---
## Credits ## Credits
KAT is developed by **Kara Zajac (.leviathan)**. KAT would not be possible without [ProtoPirate](https://protopirate.net/ProtoPirate/ProtoPirate)—the protocol decoders, reference implementations, and community work are the foundation this tool is built on. Truly standing on the shoulders of giants. KAT is developed by **Kara Zajac (.leviathan)**. KAT would not be possible without [ProtoPirate](https://protopirate.net/ProtoPirate/ProtoPirate)—the protocol decoders, reference implementations, and community work are the foundation this tool is built on. Truly standing on the shoulders of giants.
+54 -63
View File
@@ -1307,52 +1307,52 @@ impl App {
.unwrap_or_else(|_| path.to_string_lossy().to_string()) .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). /// Default export filename for .fob: Year_Make_Model_Region_Command (same format for all captures).
/// For known protocol: protocol_serial as before. /// Uses capture metadata when set; fallbacks: make from protocol for known, command from button_name(), else "Unknown".
fn default_export_filename(capture: &Capture) -> String { fn default_export_filename(capture: &Capture) -> String {
if capture.protocol_name().eq_ignore_ascii_case("unknown") { let year = capture
let year = capture .year
.year .as_deref()
.as_deref() .unwrap_or("Unknown")
.unwrap_or("Unknown") .trim()
.trim() .replace(' ', "_");
.replace(' ', "_"); let make = capture
let make = capture .make
.make .as_deref()
.as_deref() .filter(|s| !s.trim().is_empty())
.unwrap_or("Unknown") .map(|s| s.trim().replace(' ', "_"))
.trim() .unwrap_or_else(|| {
.replace(' ', "_"); if capture.protocol_name().eq_ignore_ascii_case("unknown") {
let model = capture "Unknown".to_string()
.model } else {
.as_deref() Self::get_make_for_protocol(capture.protocol_name())
.unwrap_or("Unknown") .trim()
.trim() .replace(' ', "_")
.replace(' ', "_"); }
let region = capture });
.region let model = capture
.as_deref() .model
.unwrap_or("Unknown") .as_deref()
.trim() .unwrap_or("Unknown")
.replace(' ', "_"); .trim()
let cmd_str = capture .replace(' ', "_");
.command let region = capture
.as_deref() .region
.unwrap_or_else(|| capture.button_name()) .as_deref()
.trim(); .unwrap_or("Unknown")
let command = if cmd_str.is_empty() || cmd_str == "-" { .trim()
"Unknown".to_string() .replace(' ', "_");
} else { let cmd_str = capture
cmd_str.replace(' ', "_") .command
}; .as_deref()
format!("{}_{}_{}_{}_{}", year, make, model, region, command) .unwrap_or_else(|| capture.button_name())
.trim();
let command = if cmd_str.is_empty() || cmd_str == "-" {
"Unknown".to_string()
} else { } else {
format!( cmd_str.replace(' ', "_")
"{}_{}", };
capture.protocol_name().replace(' ', "_").to_lowercase(), format!("{}_{}_{}_{}_{}", year, make, model, region, command)
capture.serial_hex()
)
}
} }
/// Start .fob export by entering filename input mode /// Start .fob export by entering filename input mode
@@ -1362,21 +1362,17 @@ impl App {
return Ok(()); return Ok(());
} }
// Pre-fill filename from protocol + serial; for unknown captures include 8-hex suffix so user sees it // Pre-fill filename: Year_Make_Model_Region_Command_8HEX for all .fob exports
let capture = self.captures.iter().find(|c| c.id == id); let capture = self.captures.iter().find(|c| c.id == id);
let default_name = capture let default_name = capture
.map(|c| Self::default_export_filename(c)) .map(|c| Self::default_export_filename(c))
.unwrap_or_else(|| format!("capture_{}", id)); .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()
let suffix_nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)
.duration_since(std::time::UNIX_EPOCH) .unwrap()
.unwrap() .as_nanos() as u64;
.as_nanos() as u64; let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32;
let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32; self.export_filename = format!("{}_{:08X}", default_name, suffix);
format!("{}_{:08X}", default_name, suffix)
} else {
default_name.clone()
};
// Pre-fill metadata from capture if set, otherwise make from protocol // Pre-fill metadata from capture if set, otherwise make from protocol
let make = capture let make = capture
@@ -1449,18 +1445,15 @@ impl App {
notes: self.fob_meta_notes.clone(), notes: self.fob_meta_notes.clone(),
}; };
// Unknown captures or user-edited vehicle-style filenames always get 8-hex suffix to avoid overwrites. // All .fob exports use Year_Make_Model_Region_Command_8HEX; append 8-hex if user removed it
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 let already_has_8hex = self.export_filename.len() >= 9
&& self.export_filename.as_bytes()[self.export_filename.len() - 9] == b'_' && self.export_filename.as_bytes()[self.export_filename.len() - 9] == b'_'
&& self.export_filename[self.export_filename.len() - 8..] && self.export_filename[self.export_filename.len() - 8..]
.chars() .chars()
.all(|c| c.is_ascii_hexdigit()); .all(|c| c.is_ascii_hexdigit());
let filename = if use_hex_suffix && already_has_8hex { let filename = if already_has_8hex {
format!("{}.fob", self.export_filename.trim()) format!("{}.fob", self.export_filename.trim())
} else if use_hex_suffix { } else {
let base = self.export_filename.trim(); let base = self.export_filename.trim();
let suffix_nanos = std::time::SystemTime::now() let suffix_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -1473,8 +1466,6 @@ impl App {
} else { } else {
format!("{}_{}.fob", base, hex_suffix) format!("{}_{}.fob", base, hex_suffix)
} }
} else {
format!("{}.fob", self.export_filename)
}; };
let path = export_dir.join(&filename); let path = export_dir.join(&filename);