""" 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) # Reconstruct a signed pulse train (µs) from the demodulated bit # stream so BinRAW captures flow through the same RAW identification # path as RAW_Data files. Data_RAW is the signal sampled at TE-µs # steps: a run of N identical bits is one pulse of N*TE µs (+high on # 1s, -low on 0s). This populates raw_data, which makes the # has_raw_data property true. n_bits = metadata.bin_raw_bit or metadata.bit_length if metadata.bin_raw_data and metadata.bin_raw_te and n_bits: pulses = self._binraw_to_pulses( metadata.bin_raw_data, n_bits, metadata.bin_raw_te) if pulses: metadata.raw_data = pulses metadata.pulse_count = len(pulses) # 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}") @staticmethod def _binraw_to_pulses(data: bytes, n_bits: int, te: int) -> List[int]: """Expand a BinRAW bit stream into a signed timing array (µs). Bits are read MSB-first, truncated to ``n_bits``, then run-length encoded: each run of ``k`` equal bits becomes ``k*te`` µs, signed positive for 1s (pulse) and negative for 0s (gap). """ bits = [] for byte in data: for i in range(7, -1, -1): bits.append((byte >> i) & 1) bits = bits[:n_bits] if not bits: return [] pulses: List[int] = [] current, run = bits[0], 1 for b in bits[1:]: if b == current: run += 1 else: pulses.append(run * te * (1 if current else -1)) current, run = b, 1 pulses.append(run * te * (1 if current else -1)) return pulses 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)