Files
KAT/src/export/fob.rs
T
2026-02-13 18:10:29 -05:00

390 lines
11 KiB
Rust

//! .fob export/import format - rich JSON metadata for captured keyfob signals.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::app::App;
use crate::capture::{Capture, CaptureStatus, StoredLevelDuration};
/// User-provided metadata for .fob export
#[derive(Debug, Clone, Default)]
pub struct FobMetadata {
pub year: Option<u32>,
pub make: String,
pub model: String,
pub region: String,
pub notes: String,
}
/// Top-level .fob file structure
#[derive(Serialize, Deserialize)]
pub struct FobFile {
pub version: String,
pub format: String,
pub signal: FobSignalInfo,
pub vehicle: FobVehicleInfo,
pub capture: FobCapture,
}
/// Signal-level metadata (derived from protocol)
#[derive(Serialize, Deserialize)]
pub struct FobSignalInfo {
pub protocol: String,
pub frequency: u32,
pub frequency_mhz: String,
pub modulation: String,
/// RF carrier modulation: AM, FM, or AM/FM (from ProtoPirate). KAT receives AM only.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub rf_modulation: Option<String>,
pub encryption: String,
pub data_bits: usize,
pub data_hex: String,
pub serial: String,
pub key: String,
#[serde(default)]
pub button: Option<u8>,
pub button_name: String,
#[serde(default)]
pub counter: Option<u16>,
pub crc_valid: bool,
pub encoder_capable: bool,
}
/// Vehicle metadata (user-provided + auto-detected)
#[derive(Serialize, Deserialize)]
pub struct FobVehicleInfo {
#[serde(default)]
pub year: Option<u32>,
pub make: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
/// Capture data within a .fob file (timing + raw data)
#[derive(Serialize, Deserialize)]
pub struct FobCapture {
pub timestamp: String,
/// Raw data value (hex string) for signal reconstruction
#[serde(default)]
pub raw_data_hex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub raw_pairs: Option<Vec<FobPair>>,
#[serde(default)]
pub raw_pair_count: usize,
}
/// A single level+duration pair in the .fob file
#[derive(Serialize, Deserialize)]
pub struct FobPair {
pub level: bool,
pub duration_us: u32,
}
/// Export a capture to .fob format with optional user metadata
pub fn export_fob(
capture: &Capture,
path: &Path,
include_raw: bool,
metadata: Option<&FobMetadata>,
) -> Result<()> {
let protocol_name = capture.protocol_name().to_string();
let make = metadata
.map(|m| m.make.clone())
.filter(|m| !m.is_empty())
.unwrap_or_else(|| App::get_make_for_protocol(&protocol_name).to_string());
let model = metadata.and_then(|m| {
if m.model.is_empty() {
None
} else {
Some(m.model.clone())
}
});
let year = metadata.and_then(|m| m.year);
let region = metadata.and_then(|m| {
if m.region.is_empty() {
None
} else {
Some(m.region.clone())
}
});
let notes = metadata.and_then(|m| {
if m.notes.is_empty() {
None
} else {
Some(m.notes.clone())
}
});
let raw_pairs = if include_raw && !capture.raw_pairs.is_empty() {
Some(
capture
.raw_pairs
.iter()
.map(|p| FobPair {
level: p.level,
duration_us: p.duration_us,
})
.collect(),
)
} else {
None
};
let fob = FobFile {
version: "2.0".to_string(),
format: "kat-fob".to_string(),
signal: FobSignalInfo {
protocol: protocol_name.clone(),
frequency: capture.frequency,
frequency_mhz: capture.frequency_mhz(),
modulation: capture.modulation().to_string(),
rf_modulation: match capture.rf_modulation() {
crate::capture::RfModulation::Unknown => None,
r => Some(r.to_string()),
},
encryption: capture.encryption_type().to_string(),
data_bits: capture.data_count_bit,
data_hex: capture.data_hex(),
serial: capture.serial_hex(),
key: capture.data_hex(),
button: capture.button,
button_name: capture.button_name().to_string(),
counter: capture.counter,
crc_valid: capture.crc_valid,
encoder_capable: capture.status == CaptureStatus::EncoderCapable,
},
vehicle: FobVehicleInfo {
year,
make,
model,
region,
notes,
},
capture: FobCapture {
timestamp: capture.timestamp.to_rfc3339(),
raw_data_hex: Some(capture.data_hex()),
raw_pair_count: capture.raw_pairs.len(),
raw_pairs,
},
};
let json = serde_json::to_string_pretty(&fob)?;
std::fs::write(path, json)?;
tracing::info!("Exported .fob v2 to {:?}", path);
Ok(())
}
/// Import a .fob file and return a Capture (supports v1 and v2 formats)
pub fn import_fob(path: &Path, next_id: u32) -> Result<Capture> {
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read {:?}", path))?;
// Try v2 format first
if let Ok(fob) = serde_json::from_str::<FobFile>(&content) {
return import_fob_v2(&fob, next_id);
}
// Fall back to v1 format
let fob: FobFileV1 =
serde_json::from_str(&content).with_context(|| format!("Failed to parse {:?}", path))?;
import_fob_v1(&fob, next_id)
}
// --- V1 compatibility types ---
/// Legacy v1 .fob file structure
#[derive(Serialize, Deserialize)]
struct FobFileV1 {
#[allow(dead_code)]
pub version: String,
#[allow(dead_code)]
pub format: String,
pub capture: FobCaptureV1,
}
/// Legacy v1 capture data
#[derive(Serialize, Deserialize)]
struct FobCaptureV1 {
pub timestamp: String,
pub frequency: u32,
pub protocol: String,
#[serde(default)]
pub year: Option<u32>,
#[allow(dead_code)]
pub make: String,
#[serde(default)]
#[allow(dead_code)]
pub model: Option<String>,
pub serial: String,
pub key: String,
#[serde(default)]
pub button: Option<u8>,
#[allow(dead_code)]
pub button_name: String,
#[serde(default)]
pub counter: Option<u16>,
#[allow(dead_code)]
pub encryption: String,
pub crc_valid: bool,
pub data_bits: usize,
#[serde(default)]
pub data_hex: Option<String>,
#[serde(default)]
pub raw_pairs: Option<Vec<FobPair>>,
}
fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result<Capture> {
let sig = &fob.signal;
let cap = &fob.capture;
// Parse serial from hex string
let serial = u32::from_str_radix(sig.serial.trim_start_matches("0x"), 16).ok();
// Parse data from hex string
let data = cap
.raw_data_hex
.as_deref()
.or(Some(sig.data_hex.as_str()))
.and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
// Parse timestamp
let timestamp = chrono::DateTime::parse_from_rfc3339(&cap.timestamp)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
// Reconstruct raw pairs if present
let raw_pairs: Vec<StoredLevelDuration> = cap
.raw_pairs
.as_ref()
.map(|pairs| {
pairs
.iter()
.map(|p| StoredLevelDuration {
level: p.level,
duration_us: p.duration_us,
})
.collect()
})
.unwrap_or_default();
let protocol = if sig.protocol == "Unknown" {
None
} else {
Some(sig.protocol.clone())
};
let status = if sig.encoder_capable && !raw_pairs.is_empty() {
CaptureStatus::EncoderCapable
} else if protocol.is_some() {
CaptureStatus::Decoded
} else {
CaptureStatus::Unknown
};
Ok(Capture {
id: next_id,
timestamp,
frequency: sig.frequency,
protocol,
serial,
button: sig.button,
counter: sig.counter,
crc_valid: sig.crc_valid,
data,
data_count_bit: sig.data_bits,
raw_pairs,
status,
received_rf: None,
})
}
fn import_fob_v1(fob: &FobFileV1, next_id: u32) -> Result<Capture> {
let cap = &fob.capture;
// Parse serial from hex string
let serial = u32::from_str_radix(cap.serial.trim_start_matches("0x"), 16).ok();
// Parse data from hex string
let data = cap
.data_hex
.as_deref()
.or(Some(cap.key.as_str()))
.and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok())
.unwrap_or(0);
// Parse timestamp
let timestamp = chrono::DateTime::parse_from_rfc3339(&cap.timestamp)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
// Reconstruct raw pairs if present
let raw_pairs: Vec<StoredLevelDuration> = cap
.raw_pairs
.as_ref()
.map(|pairs| {
pairs
.iter()
.map(|p| StoredLevelDuration {
level: p.level,
duration_us: p.duration_us,
})
.collect()
})
.unwrap_or_default();
let protocol = if cap.protocol == "Unknown" {
None
} else {
Some(cap.protocol.clone())
};
let status = if protocol.is_some() && !raw_pairs.is_empty() {
CaptureStatus::EncoderCapable
} else if protocol.is_some() {
CaptureStatus::Decoded
} else {
CaptureStatus::Unknown
};
Ok(Capture {
id: next_id,
timestamp,
frequency: cap.frequency,
protocol,
serial,
button: cap.button,
counter: cap.counter,
crc_valid: cap.crc_valid,
data,
data_count_bit: cap.data_bits,
raw_pairs,
status,
received_rf: None,
})
}
/// Scan a directory for .fob files and return their paths
pub fn scan_fob_files(dir: &Path) -> Vec<std::path::PathBuf> {
if !dir.exists() || !dir.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |e| e == "fob") {
files.push(path);
}
}
}
files.sort();
files
}