#!/usr/bin/env python3 """ Frequency Fingerprinting for RF Device Identification Uses center frequency and frequency bands as identification features. Narrows candidate protocols by frequency before detailed timing analysis. Key features: - Frequency band classification (315/433/868/915 MHz) - Protocol filtering by frequency tolerance - Frequency-based scoring - ISM band detection """ import numpy as np from dataclasses import dataclass from typing import List, Dict, Optional, Tuple from collections import defaultdict # ISM (Industrial, Scientific, Medical) band definitions ISM_BANDS = { '315MHz': (314_000_000, 316_000_000), # North America '433MHz': (433_050_000, 434_790_000), # Europe (primary ISM) '868MHz': (868_000_000, 868_600_000), # Europe SRD band '915MHz': (902_000_000, 928_000_000), # North America ISM } @dataclass class FrequencyFingerprint: """ Frequency characteristics of a signal Attributes: center_freq: Center frequency in Hz bandwidth: Estimated bandwidth in Hz (optional) ism_band: Detected ISM band name ('315MHz', '433MHz', etc.) frequency_offset: Offset from standard band center confidence: Detection confidence (0.0-1.0) """ center_freq: int bandwidth: Optional[int] ism_band: Optional[str] frequency_offset: int confidence: float @dataclass class FrequencyMatch: """ Match result comparing signal frequency to protocol Attributes: protocol_name: Name of matched protocol frequency_error: Absolute error in Hz frequency_error_pct: Error as percentage within_tolerance: Whether within protocol's tolerance score: Match score (0.0-1.0) details: Additional match details """ protocol_name: str frequency_error: int frequency_error_pct: float within_tolerance: bool score: float details: Dict class FrequencyFingerprinter: """ Frequency-based protocol filtering and scoring Workflow: 1. Analyze signal frequency 2. Determine ISM band 3. Filter protocols by frequency band 4. Score remaining protocols by frequency match """ def __init__(self): """Initialize fingerprinter""" self.ism_bands = ISM_BANDS def analyze(self, frequency: int) -> FrequencyFingerprint: """ Analyze frequency characteristics Args: frequency: Signal center frequency in Hz Returns: FrequencyFingerprint with detected characteristics """ # Detect ISM band ism_band = self._detect_ism_band(frequency) # Calculate offset from band center offset = 0 if ism_band: band_low, band_high = self.ism_bands[ism_band] band_center = (band_low + band_high) // 2 offset = frequency - band_center # Confidence based on band detection confidence = 0.9 if ism_band else 0.6 return FrequencyFingerprint( center_freq=frequency, bandwidth=None, # Not calculated from single .sub file ism_band=ism_band, frequency_offset=offset, confidence=confidence ) def filter_protocols_by_frequency( self, protocols: List, signal_frequency: int, tolerance_hz: int = 200_000 # ±200 kHz default ) -> List: """ Filter protocols by frequency match Args: protocols: List of ProtocolSignature objects signal_frequency: Signal frequency in Hz tolerance_hz: Frequency tolerance in Hz Returns: Filtered list of protocols within frequency range """ filtered = [] for protocol in protocols: freq_error = abs(protocol.frequency - signal_frequency) # Use protocol's own tolerance if available, otherwise use default protocol_tolerance = getattr(protocol, 'frequency_tolerance', tolerance_hz) if freq_error <= protocol_tolerance: filtered.append(protocol) return filtered def score_frequency_match( self, signal_frequency: int, protocol_frequency: int, protocol_tolerance: int = 100_000 ) -> FrequencyMatch: """ Score frequency match between signal and protocol Args: signal_frequency: Signal frequency in Hz protocol_frequency: Protocol expected frequency in Hz protocol_tolerance: Protocol's frequency tolerance in Hz Returns: FrequencyMatch with score """ freq_error = abs(signal_frequency - protocol_frequency) freq_error_pct = freq_error / protocol_frequency if protocol_frequency > 0 else 1.0 within_tolerance = freq_error <= protocol_tolerance # Score calculation: # - Exact match = 1.0 # - Within tolerance = 0.8-1.0 (linear falloff) # - Outside tolerance = 0.0-0.5 (steep falloff) if freq_error == 0: score = 1.0 elif within_tolerance: # Linear falloff within tolerance normalized = freq_error / protocol_tolerance score = 1.0 - (normalized * 0.2) # 1.0 → 0.8 else: # Steep falloff outside tolerance excess = freq_error - protocol_tolerance # Penalty: 50% score at 2x tolerance, 0% at 4x tolerance if excess < protocol_tolerance: score = 0.5 * (1.0 - excess / protocol_tolerance) else: score = 0.0 return FrequencyMatch( protocol_name="", # Set by caller frequency_error=freq_error, frequency_error_pct=freq_error_pct, within_tolerance=within_tolerance, score=score, details={ 'signal_freq_mhz': f"{signal_frequency / 1_000_000:.3f}", 'protocol_freq_mhz': f"{protocol_frequency / 1_000_000:.3f}", 'error_khz': f"{freq_error / 1_000:.1f}", 'tolerance_khz': f"{protocol_tolerance / 1_000:.1f}" } ) def group_protocols_by_band( self, protocols: List ) -> Dict[str, List]: """ Group protocols by ISM frequency band Args: protocols: List of ProtocolSignature objects Returns: Dict mapping band name to protocols """ grouped = defaultdict(list) for protocol in protocols: band = self._detect_ism_band(protocol.frequency) if band: grouped[band].append(protocol) else: grouped['other'].append(protocol) return dict(grouped) def get_band_statistics( self, protocols: List ) -> Dict[str, int]: """ Get protocol count statistics by band Args: protocols: List of ProtocolSignature objects Returns: Dict mapping band name to protocol count """ grouped = self.group_protocols_by_band(protocols) return {band: len(protos) for band, protos in grouped.items()} # === Helper Methods === def _detect_ism_band(self, frequency: int) -> Optional[str]: """ Detect which ISM band a frequency belongs to Args: frequency: Frequency in Hz Returns: Band name ('315MHz', '433MHz', etc.) or None """ for band_name, (low, high) in self.ism_bands.items(): if low <= frequency <= high: return band_name return None def _get_band_center(self, band_name: str) -> int: """Get center frequency of an ISM band""" if band_name not in self.ism_bands: return 0 low, high = self.ism_bands[band_name] return (low + high) // 2 # === Integration with Protocol Database === class FrequencyFilterStrategy: """ Matching strategy that pre-filters by frequency Reduces search space before expensive timing analysis """ def __init__(self, protocol_db): """ Initialize strategy Args: protocol_db: Protocol database instance """ self.protocol_db = protocol_db self.fingerprinter = FrequencyFingerprinter() def filter_candidates( self, signal_frequency: int, max_candidates: int = 50 ) -> List: """ Get candidate protocols filtered by frequency Args: signal_frequency: Signal frequency in Hz max_candidates: Maximum protocols to return Returns: List of candidate protocols """ # Get all protocols all_protocols = self.protocol_db.get_all() # Filter by frequency (±200 kHz default tolerance) candidates = self.fingerprinter.filter_protocols_by_frequency( all_protocols, signal_frequency, tolerance_hz=200_000 ) # If too many, narrow further if len(candidates) > max_candidates: # Sort by frequency error, keep closest candidates.sort( key=lambda p: abs(p.frequency - signal_frequency) ) candidates = candidates[:max_candidates] return candidates def get_frequency_score( self, signal_frequency: int, protocol_frequency: int, protocol_tolerance: int ) -> float: """ Get frequency match score Args: signal_frequency: Signal frequency in Hz protocol_frequency: Protocol frequency in Hz protocol_tolerance: Protocol tolerance in Hz Returns: Score between 0.0 and 1.0 """ match = self.fingerprinter.score_frequency_match( signal_frequency, protocol_frequency, protocol_tolerance ) return match.score # Singleton instance _fingerprinter: Optional[FrequencyFingerprinter] = None def get_frequency_fingerprinter() -> FrequencyFingerprinter: """Get singleton frequency fingerprinter instance""" global _fingerprinter if _fingerprinter is None: _fingerprinter = FrequencyFingerprinter() return _fingerprinter