feat: preamble detection + frequency fingerprinting - iteration 3/5
Implements multi-factor scoring pipeline for improved RF device identification: - Score = Timing(30%) + Frequency(25%) + BitCount(20%) + Preamble(15%) + Stats(10%) New Components: - src/matcher/preamble_detector.py: Detects 4 pattern types (long_burst, alternating, sync_word, custom) - src/matcher/frequency_fingerprint.py: ISM band classification (315/433/868/915 MHz) for protocol filtering - Integration: Updated pattern_decoder.py with multi-factor scoring Features: - Preamble detection with 4 methods (long burst, alternating, sync word, repetition) - Frequency-based protocol filtering (reduces search space from 299 to ~20-30 candidates) - Multi-factor confidence scoring combining timing, frequency, bit count, preamble, and statistics - Sorted sync word matching (longest first to avoid substring matches) Test Coverage: - 15 new tests for preamble detection and frequency fingerprinting - Total: 56 tests passing (41 existing + 15 new) Results: - Improved matching accuracy through multi-factor scoring - Reduced protocol search space via frequency pre-filtering - Better handling of noisy signals through preamble validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user