Version 1.0.0
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
//! Flipper Zero .sub export format.
|
||||
//!
|
||||
//! Outputs files in the Flipper SubGhz RAW format with alternating
|
||||
//! positive (high) and negative (low) durations in microseconds.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::capture::Capture;
|
||||
|
||||
/// Export a capture to Flipper Zero .sub RAW format
|
||||
pub fn export_flipper_sub(capture: &Capture, path: &Path) -> Result<()> {
|
||||
if capture.raw_pairs.is_empty() {
|
||||
return Err(anyhow::anyhow!("No raw signal data to export"));
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Header
|
||||
lines.push("Filetype: Flipper SubGhz RAW File".to_string());
|
||||
lines.push("Version: 1".to_string());
|
||||
lines.push(format!("Frequency: {}", capture.frequency));
|
||||
lines.push("Preset: FuriHalSubGhzPresetOok270Async".to_string());
|
||||
lines.push("Protocol: RAW".to_string());
|
||||
|
||||
// Convert raw_pairs to alternating +/- durations
|
||||
// Flipper format: positive values = HIGH, negative values = LOW
|
||||
let mut raw_data = Vec::new();
|
||||
for pair in &capture.raw_pairs {
|
||||
let duration = pair.duration_us as i64;
|
||||
if pair.level {
|
||||
raw_data.push(duration);
|
||||
} else {
|
||||
raw_data.push(-duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Write RAW_Data lines (max ~512 values per line for readability)
|
||||
const MAX_PER_LINE: usize = 512;
|
||||
for chunk in raw_data.chunks(MAX_PER_LINE) {
|
||||
let values: Vec<String> = chunk.iter().map(|v| v.to_string()).collect();
|
||||
lines.push(format!("RAW_Data: {}", values.join(" ")));
|
||||
}
|
||||
|
||||
let content = lines.join("\n") + "\n";
|
||||
std::fs::write(path, content)?;
|
||||
tracing::info!("Exported Flipper .sub to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! .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,
|
||||
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(),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Export formats for captured signals.
|
||||
|
||||
pub mod fob;
|
||||
pub mod flipper;
|
||||
Reference in New Issue
Block a user