f8042c3dca
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>
357 lines
10 KiB
Python
357 lines
10 KiB
Python
#!/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
|