Initial commit: Phase 1 & Phase 2 infrastructure complete
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
RF signal file parsers for .sub, .fff, and other formats
|
||||
"""
|
||||
|
||||
from .sub_parser import SubFileParser, parse_sub_file
|
||||
from .metadata import SignalMetadata
|
||||
|
||||
__all__ = ['SubFileParser', 'parse_sub_file', 'SignalMetadata']
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Signal metadata data structures
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalMetadata:
|
||||
"""
|
||||
Extracted metadata from RF signal capture file
|
||||
"""
|
||||
# File information
|
||||
file_type: str # 'Flipper SubGhz Key File' or 'Flipper SubGhz RAW File'
|
||||
version: int
|
||||
file_format: str # 'KEY', 'RAW', 'BinRAW'
|
||||
|
||||
# RF characteristics
|
||||
frequency: int # in Hz
|
||||
preset: Optional[str] = None
|
||||
modulation: Optional[str] = None # Extracted from preset
|
||||
|
||||
# Protocol information (for decoded files)
|
||||
protocol: Optional[str] = None
|
||||
bit_length: Optional[int] = None
|
||||
key_data: Optional[bytes] = None
|
||||
timing_element: Optional[int] = None # TE in microseconds
|
||||
|
||||
# RAW signal data
|
||||
raw_data: Optional[List[int]] = None # Timing array
|
||||
|
||||
# BinRAW data
|
||||
bin_raw_bit: Optional[int] = None
|
||||
bin_raw_te: Optional[int] = None
|
||||
bin_raw_data: Optional[bytes] = None
|
||||
|
||||
# Custom preset data
|
||||
custom_preset_module: Optional[str] = None
|
||||
custom_preset_data: Optional[bytes] = None
|
||||
|
||||
# Computed fields
|
||||
duration_ms: Optional[float] = None
|
||||
pulse_count: Optional[int] = None
|
||||
avg_pulse_width: Optional[float] = None
|
||||
|
||||
# Metadata
|
||||
parsed_at: datetime = field(default_factory=datetime.utcnow)
|
||||
parse_errors: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
'file_type': self.file_type,
|
||||
'version': self.version,
|
||||
'file_format': self.file_format,
|
||||
'frequency': self.frequency,
|
||||
'preset': self.preset,
|
||||
'modulation': self.modulation,
|
||||
'protocol': self.protocol,
|
||||
'bit_length': self.bit_length,
|
||||
'key_data': self.key_data.hex() if self.key_data else None,
|
||||
'timing_element': self.timing_element,
|
||||
'raw_data_length': len(self.raw_data) if self.raw_data else 0,
|
||||
'duration_ms': self.duration_ms,
|
||||
'pulse_count': self.pulse_count,
|
||||
'avg_pulse_width': self.avg_pulse_width,
|
||||
'parsed_at': self.parsed_at.isoformat(),
|
||||
'parse_errors': self.parse_errors
|
||||
}
|
||||
|
||||
@property
|
||||
def is_decoded(self) -> bool:
|
||||
"""Check if signal has been decoded to a protocol"""
|
||||
return self.protocol is not None and self.protocol != 'RAW'
|
||||
|
||||
@property
|
||||
def has_raw_data(self) -> bool:
|
||||
"""Check if file contains raw timing data"""
|
||||
return self.raw_data is not None and len(self.raw_data) > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresetInfo:
|
||||
"""Modulation preset information"""
|
||||
name: str
|
||||
modulation: str # 'OOK' or '2FSK'
|
||||
bandwidth_khz: float
|
||||
deviation_khz: Optional[float] = None # For FSK
|
||||
|
||||
@staticmethod
|
||||
def from_preset_name(name: str) -> 'PresetInfo':
|
||||
"""Parse preset information from name"""
|
||||
presets = {
|
||||
'FuriHalSubGhzPresetOok270Async': PresetInfo('OOK270', 'OOK', 270.0),
|
||||
'FuriHalSubGhzPresetOok650Async': PresetInfo('OOK650', 'OOK', 650.0),
|
||||
'FuriHalSubGhzPreset2FSKDev238Async': PresetInfo('FM238', '2FSK', 270.0, 2.38),
|
||||
'FuriHalSubGhzPreset2FSKDev476Async': PresetInfo('FM476', '2FSK', 270.0, 47.6),
|
||||
'FuriHalSubGhzPresetCustom': PresetInfo('Custom', 'Custom', 0.0),
|
||||
}
|
||||
return presets.get(name, PresetInfo('Unknown', 'Unknown', 0.0))
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Flipper Zero .sub file parser
|
||||
|
||||
Parses both KEY and RAW format .sub files and extracts signal metadata
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
from .metadata import SignalMetadata, PresetInfo
|
||||
|
||||
|
||||
class SubFileParser:
|
||||
"""Parser for Flipper Zero .sub files"""
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
"""Reset parser state"""
|
||||
self.fields = {}
|
||||
self.errors = []
|
||||
|
||||
def parse(self, file_path: str) -> SignalMetadata:
|
||||
"""
|
||||
Parse a .sub file and extract metadata
|
||||
|
||||
Args:
|
||||
file_path: Path to .sub file
|
||||
|
||||
Returns:
|
||||
SignalMetadata object
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist
|
||||
ValueError: If file format is invalid
|
||||
"""
|
||||
self.reset()
|
||||
path = Path(file_path)
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# Read file
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to read file: {e}")
|
||||
|
||||
# Parse fields
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
self.fields[key.strip()] = value.strip()
|
||||
|
||||
# Validate required fields
|
||||
if 'Filetype' not in self.fields:
|
||||
raise ValueError("Missing required field: Filetype")
|
||||
|
||||
if 'Frequency' not in self.fields:
|
||||
raise ValueError("Missing required field: Frequency")
|
||||
|
||||
# Extract metadata
|
||||
return self._extract_metadata()
|
||||
|
||||
def _extract_metadata(self) -> SignalMetadata:
|
||||
"""Extract metadata from parsed fields"""
|
||||
|
||||
# Basic fields
|
||||
file_type = self.fields.get('Filetype', '')
|
||||
version = int(self.fields.get('Version', 1))
|
||||
frequency = int(self.fields.get('Frequency', 0))
|
||||
preset = self.fields.get('Preset')
|
||||
|
||||
# Determine file format
|
||||
protocol = self.fields.get('Protocol', 'Unknown')
|
||||
if protocol == 'RAW':
|
||||
file_format = 'RAW'
|
||||
elif protocol == 'BinRAW':
|
||||
file_format = 'BinRAW'
|
||||
else:
|
||||
file_format = 'KEY'
|
||||
|
||||
# Extract modulation from preset
|
||||
modulation = None
|
||||
if preset:
|
||||
preset_info = PresetInfo.from_preset_name(preset)
|
||||
modulation = preset_info.modulation
|
||||
|
||||
# Initialize metadata
|
||||
metadata = SignalMetadata(
|
||||
file_type=file_type,
|
||||
version=version,
|
||||
file_format=file_format,
|
||||
frequency=frequency,
|
||||
preset=preset,
|
||||
modulation=modulation,
|
||||
protocol=protocol if protocol not in ['RAW', 'BinRAW'] else None
|
||||
)
|
||||
|
||||
# Extract format-specific fields
|
||||
if file_format == 'KEY':
|
||||
self._extract_key_fields(metadata)
|
||||
elif file_format == 'RAW':
|
||||
self._extract_raw_fields(metadata)
|
||||
elif file_format == 'BinRAW':
|
||||
self._extract_binraw_fields(metadata)
|
||||
|
||||
# Extract custom preset if present
|
||||
if 'Custom_preset_module' in self.fields:
|
||||
metadata.custom_preset_module = self.fields['Custom_preset_module']
|
||||
if 'Custom_preset_data' in self.fields:
|
||||
metadata.custom_preset_data = bytes.fromhex(
|
||||
self.fields['Custom_preset_data'].replace(' ', '')
|
||||
)
|
||||
|
||||
# Add any parsing errors
|
||||
metadata.parse_errors = self.errors
|
||||
|
||||
return metadata
|
||||
|
||||
def _extract_key_fields(self, metadata: SignalMetadata):
|
||||
"""Extract fields from KEY format file"""
|
||||
try:
|
||||
if 'Bit' in self.fields:
|
||||
metadata.bit_length = int(self.fields['Bit'])
|
||||
|
||||
if 'Key' in self.fields:
|
||||
# Parse hex key data
|
||||
key_hex = self.fields['Key'].replace(' ', '')
|
||||
metadata.key_data = bytes.fromhex(key_hex)
|
||||
|
||||
if 'TE' in self.fields:
|
||||
metadata.timing_element = int(self.fields['TE'])
|
||||
|
||||
except ValueError as e:
|
||||
self.errors.append(f"Error parsing KEY fields: {e}")
|
||||
logger.warning(f"Parse error: {e}")
|
||||
|
||||
def _extract_raw_fields(self, metadata: SignalMetadata):
|
||||
"""Extract fields from RAW format file"""
|
||||
try:
|
||||
if 'RAW_Data' in self.fields:
|
||||
# Parse timing array
|
||||
raw_str = self.fields['RAW_Data']
|
||||
timings = [int(x) for x in raw_str.split()]
|
||||
metadata.raw_data = timings
|
||||
|
||||
# Calculate statistics
|
||||
metadata.pulse_count = len(timings)
|
||||
metadata.duration_ms = sum(abs(t) for t in timings) / 1000.0
|
||||
|
||||
# Average pulse width (positive values only)
|
||||
positive_pulses = [t for t in timings if t > 0]
|
||||
if positive_pulses:
|
||||
metadata.avg_pulse_width = sum(positive_pulses) / len(positive_pulses)
|
||||
|
||||
except ValueError as e:
|
||||
self.errors.append(f"Error parsing RAW fields: {e}")
|
||||
logger.warning(f"Parse error: {e}")
|
||||
|
||||
def _extract_binraw_fields(self, metadata: SignalMetadata):
|
||||
"""Extract fields from BinRAW format file"""
|
||||
try:
|
||||
if 'Bit' in self.fields:
|
||||
metadata.bit_length = int(self.fields['Bit'])
|
||||
|
||||
if 'TE' in self.fields:
|
||||
metadata.bin_raw_te = int(self.fields['TE'])
|
||||
|
||||
if 'Bit_RAW' in self.fields:
|
||||
metadata.bin_raw_bit = int(self.fields['Bit_RAW'])
|
||||
|
||||
if 'Data_RAW' in self.fields:
|
||||
data_hex = self.fields['Data_RAW'].replace(' ', '')
|
||||
metadata.bin_raw_data = bytes.fromhex(data_hex)
|
||||
|
||||
# Calculate duration
|
||||
if metadata.bit_length and metadata.bin_raw_te:
|
||||
metadata.duration_ms = (metadata.bit_length * metadata.bin_raw_te) / 1000.0
|
||||
|
||||
except ValueError as e:
|
||||
self.errors.append(f"Error parsing BinRAW fields: {e}")
|
||||
logger.warning(f"Parse error: {e}")
|
||||
|
||||
|
||||
def parse_sub_file(file_path: str) -> SignalMetadata:
|
||||
"""
|
||||
Convenience function to parse a .sub file
|
||||
|
||||
Args:
|
||||
file_path: Path to .sub file
|
||||
|
||||
Returns:
|
||||
SignalMetadata object
|
||||
"""
|
||||
parser = SubFileParser()
|
||||
return parser.parse(file_path)
|
||||
|
||||
|
||||
def extract_protocol_features(metadata: SignalMetadata) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract features for protocol matching
|
||||
|
||||
Args:
|
||||
metadata: Parsed signal metadata
|
||||
|
||||
Returns:
|
||||
Dictionary of matchable features
|
||||
"""
|
||||
features = {
|
||||
'frequency': metadata.frequency,
|
||||
'modulation': metadata.modulation,
|
||||
'protocol': metadata.protocol,
|
||||
}
|
||||
|
||||
if metadata.is_decoded:
|
||||
# For decoded signals
|
||||
features.update({
|
||||
'bit_length': metadata.bit_length,
|
||||
'timing_element': metadata.timing_element,
|
||||
'key_pattern': metadata.key_data[:4] if metadata.key_data else None # First 4 bytes
|
||||
})
|
||||
|
||||
elif metadata.has_raw_data:
|
||||
# For RAW signals, extract timing characteristics
|
||||
features.update({
|
||||
'pulse_count': metadata.pulse_count,
|
||||
'avg_pulse_width': metadata.avg_pulse_width,
|
||||
'duration_ms': metadata.duration_ms,
|
||||
'timing_pattern': _extract_timing_pattern(metadata.raw_data)
|
||||
})
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def _extract_timing_pattern(raw_data: List[int], max_samples: int = 20) -> List[int]:
|
||||
"""
|
||||
Extract representative timing pattern from RAW data
|
||||
|
||||
Args:
|
||||
raw_data: Array of timing values
|
||||
max_samples: Maximum number of samples to return
|
||||
|
||||
Returns:
|
||||
Representative timing pattern
|
||||
"""
|
||||
if not raw_data:
|
||||
return []
|
||||
|
||||
# Take first few pulses as pattern
|
||||
pattern = raw_data[:max_samples]
|
||||
|
||||
# Normalize to absolute values
|
||||
return [abs(t) for t in pattern]
|
||||
|
||||
|
||||
def validate_sub_file(file_path: str) -> tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a .sub file without full parsing
|
||||
|
||||
Args:
|
||||
file_path: Path to .sub file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for required header
|
||||
if 'Filetype:' not in content:
|
||||
errors.append("Missing Filetype field")
|
||||
|
||||
if 'Frequency:' not in content:
|
||||
errors.append("Missing Frequency field")
|
||||
|
||||
if 'Version:' not in content:
|
||||
errors.append("Missing Version field")
|
||||
|
||||
# Check file type
|
||||
if 'Flipper SubGhz' not in content:
|
||||
errors.append("Not a valid Flipper SubGhz file")
|
||||
|
||||
# Validate frequency value
|
||||
freq_match = re.search(r'Frequency:\s*(\d+)', content)
|
||||
if freq_match:
|
||||
freq = int(freq_match.group(1))
|
||||
if freq < 300_000_000 or freq > 928_000_000:
|
||||
errors.append(f"Frequency out of range: {freq} Hz")
|
||||
|
||||
except FileNotFoundError:
|
||||
errors.append("File not found")
|
||||
except Exception as e:
|
||||
errors.append(f"Validation error: {e}")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
Reference in New Issue
Block a user