- Add Channelizer class for wideband capture processing (2 MHz → 125 kHz) - FIR low-pass filter with scipy.firwin (or fallback windowed-sinc) - Proper decimation for anti-aliasing - Fix FrameSync preamble detection to accept any CFO - Real captures have significant carrier frequency offset - Preamble bins appear at arbitrary values, not just near 0 - Now accepts any strong signal as first preamble, validates consistency - Add decode_capture.py example script for processing raw BladeRF captures - PHYDecode verified to match existing lora_phy decoder output
405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""LoRa frame synchronization block.
|
|
|
|
Detects preamble, extracts sync word (NETWORKID), locates SFD,
|
|
and outputs aligned data symbols.
|
|
|
|
Frame structure:
|
|
[Preamble: N upchirps at bin 0]
|
|
[Sync Word: 2 upchirps encoding NETWORKID nibbles]
|
|
[SFD: 2.25 downchirps]
|
|
[Data: encoded payload symbols]
|
|
"""
|
|
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
from dataclasses import dataclass
|
|
from enum import Enum, auto
|
|
from typing import Optional, Callable
|
|
|
|
from .networkid import networkid_from_symbols, sync_word_to_networkid
|
|
|
|
|
|
class FrameSyncState(Enum):
|
|
"""State machine states for frame synchronization."""
|
|
SEARCH = auto() # Searching for preamble
|
|
PREAMBLE = auto() # Tracking preamble chirps
|
|
SYNC_WORD = auto() # Capturing sync word symbols
|
|
SFD = auto() # Detecting SFD downchirps
|
|
DATA = auto() # Outputting data symbols
|
|
|
|
|
|
@dataclass
|
|
class SyncResult:
|
|
"""Result from frame synchronization."""
|
|
found: bool # Frame detected
|
|
networkid: int # Extracted NETWORKID
|
|
cfo_bin: float # Carrier frequency offset (bins)
|
|
data_symbols: list[int] # Aligned data symbol bins
|
|
preamble_count: int # Number of preamble symbols detected
|
|
sync_word_raw: tuple[int, int] # Raw sync word symbol values
|
|
|
|
|
|
@dataclass
|
|
class FrameSyncConfig:
|
|
"""Configuration for frame synchronizer."""
|
|
sf: int = 9 # Spreading factor
|
|
sample_rate: float = 250e3 # Input sample rate
|
|
bw: float = 125e3 # LoRa bandwidth
|
|
preamble_min: int = 4 # Minimum preamble symbols to detect
|
|
expected_networkid: Optional[int] = None # Filter by NETWORKID (None = any)
|
|
sfd_threshold: float = 0.5 # SFD detection threshold
|
|
|
|
|
|
class FrameSync:
|
|
"""Frame synchronization for LoRa signals.
|
|
|
|
Performs preamble detection, sync word extraction, SFD detection,
|
|
and symbol alignment for the receiver chain.
|
|
"""
|
|
|
|
def __init__(self, sf: int = 9, sample_rate: float = 250e3,
|
|
bw: float = 125e3, preamble_min: int = 4,
|
|
expected_networkid: Optional[int] = None):
|
|
"""Initialize frame synchronizer.
|
|
|
|
Args:
|
|
sf: Spreading factor (7-12)
|
|
sample_rate: Input sample rate in Hz
|
|
bw: LoRa signal bandwidth in Hz
|
|
preamble_min: Minimum preamble symbols to consider valid
|
|
expected_networkid: Only accept frames with this NETWORKID (None = all)
|
|
"""
|
|
self.config = FrameSyncConfig(
|
|
sf=sf, sample_rate=sample_rate, bw=bw,
|
|
preamble_min=preamble_min, expected_networkid=expected_networkid
|
|
)
|
|
self.sf = sf
|
|
self.N = 1 << sf
|
|
self.sps = int(self.N * sample_rate / bw)
|
|
|
|
# Generate reference chirps
|
|
n = np.arange(self.sps)
|
|
phase_up = 2 * np.pi * (n * n / (2 * self.sps))
|
|
self._upchirp = np.exp(1j * phase_up).astype(np.complex64)
|
|
self._downchirp = np.conj(self._upchirp)
|
|
|
|
# State
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
"""Reset synchronizer state."""
|
|
self._state = FrameSyncState.SEARCH
|
|
self._preamble_bins = []
|
|
self._preamble_count = 0
|
|
self._sync_bins = []
|
|
self._data_bins = []
|
|
self._cfo_estimate = 0.0
|
|
self._sfd_count = 0 # Count SFD downchirps (need 2 full ones)
|
|
|
|
def _dechirp_and_peak(self, samples: NDArray[np.complex64],
|
|
use_downchirp: bool = False) -> tuple[int, float]:
|
|
"""Dechirp samples and find FFT peak.
|
|
|
|
Args:
|
|
samples: One symbol of IQ samples
|
|
use_downchirp: If True, detect downchirp (for SFD)
|
|
|
|
Returns:
|
|
Tuple of (peak_bin, peak_magnitude)
|
|
"""
|
|
if use_downchirp:
|
|
# For downchirp detection, multiply by upchirp
|
|
dechirped = samples[:self.sps] * self._upchirp
|
|
else:
|
|
# For upchirp detection, multiply by downchirp
|
|
dechirped = samples[:self.sps] * self._downchirp
|
|
|
|
spectrum = np.abs(np.fft.fft(dechirped, n=self.N))
|
|
peak_bin = int(np.argmax(spectrum))
|
|
peak_mag = spectrum[peak_bin] / np.mean(spectrum)
|
|
|
|
return peak_bin, peak_mag
|
|
|
|
def _is_preamble_chirp(self, peak_bin: int, peak_mag: float) -> bool:
|
|
"""Check if a chirp looks like part of the preamble.
|
|
|
|
Preamble chirps should have:
|
|
- Strong FFT peak (high SNR)
|
|
- Bin consistent with previous preamble chirps (if any)
|
|
|
|
Real SDR captures can have significant CFO (carrier frequency offset),
|
|
so the preamble bin can appear anywhere in 0..N-1, not just near 0.
|
|
The key insight is that preamble chirps have the SAME bin value
|
|
(modulo small noise) for many consecutive symbols.
|
|
"""
|
|
if peak_mag < 3.0: # Minimum SNR threshold
|
|
return False
|
|
|
|
# If we have a CFO estimate, check against it
|
|
if self._preamble_count > 0:
|
|
expected_bin = int(round(self._cfo_estimate)) % self.N
|
|
# Tight tolerance: must be within 3 bins of expected
|
|
# This distinguishes preamble (bin ~0+CFO) from sync word (bin >= 8+CFO)
|
|
tolerance = 3
|
|
distance = min(abs(peak_bin - expected_bin),
|
|
self.N - abs(peak_bin - expected_bin))
|
|
return distance <= tolerance
|
|
else:
|
|
# First preamble chirp - accept ANY strong signal
|
|
# Real captures have arbitrary CFO, so preamble can appear at any bin
|
|
# We'll validate by checking if subsequent chirps have the same bin
|
|
return True
|
|
|
|
def _is_downchirp(self, samples: NDArray[np.complex64]) -> tuple[bool, float]:
|
|
"""Detect if samples contain a downchirp (SFD).
|
|
|
|
Returns:
|
|
Tuple of (is_downchirp, peak_magnitude)
|
|
"""
|
|
# Dechirp with upchirp (inverse of normal)
|
|
dechirped = samples[:self.sps] * self._upchirp
|
|
spectrum = np.abs(np.fft.fft(dechirped, n=self.N))
|
|
peak_mag = np.max(spectrum) / np.mean(spectrum)
|
|
|
|
# Downchirp produces strong peak when dechirped with upchirp
|
|
return peak_mag > 5.0, peak_mag
|
|
|
|
def _estimate_cfo(self) -> float:
|
|
"""Estimate CFO from preamble bin measurements."""
|
|
if not self._preamble_bins:
|
|
return 0.0
|
|
|
|
# Average the preamble bin values
|
|
bins = np.array(self._preamble_bins)
|
|
|
|
# Handle wraparound (bins near N-1 and 0 are close)
|
|
# Convert to complex unit vectors and average
|
|
angles = 2 * np.pi * bins / self.N
|
|
avg_angle = np.angle(np.mean(np.exp(1j * angles)))
|
|
return avg_angle * self.N / (2 * np.pi)
|
|
|
|
def process_symbol(self, samples: NDArray[np.complex64]) -> Optional[SyncResult]:
|
|
"""Process one symbol's worth of samples.
|
|
|
|
This is a streaming interface - call repeatedly with each symbol.
|
|
|
|
Args:
|
|
samples: Complex IQ samples (length >= sps)
|
|
|
|
Returns:
|
|
SyncResult when a complete frame is detected, None otherwise
|
|
"""
|
|
if len(samples) < self.sps:
|
|
return None
|
|
|
|
peak_bin, peak_mag = self._dechirp_and_peak(samples)
|
|
|
|
if self._state == FrameSyncState.SEARCH:
|
|
# Looking for preamble start
|
|
if self._is_preamble_chirp(peak_bin, peak_mag):
|
|
self._preamble_bins.append(peak_bin)
|
|
self._preamble_count = 1
|
|
self._cfo_estimate = peak_bin
|
|
self._state = FrameSyncState.PREAMBLE
|
|
|
|
elif self._state == FrameSyncState.PREAMBLE:
|
|
# Tracking preamble
|
|
if self._is_preamble_chirp(peak_bin, peak_mag):
|
|
self._preamble_bins.append(peak_bin)
|
|
self._preamble_count += 1
|
|
self._cfo_estimate = self._estimate_cfo()
|
|
else:
|
|
# Preamble ended - check if we have enough
|
|
if self._preamble_count >= self.config.preamble_min:
|
|
# This symbol is first sync word
|
|
self._sync_bins = [peak_bin]
|
|
self._state = FrameSyncState.SYNC_WORD
|
|
else:
|
|
# False alarm, reset
|
|
self.reset()
|
|
|
|
elif self._state == FrameSyncState.SYNC_WORD:
|
|
# Capturing sync word (2 symbols)
|
|
self._sync_bins.append(peak_bin)
|
|
if len(self._sync_bins) >= 2:
|
|
self._state = FrameSyncState.SFD
|
|
|
|
elif self._state == FrameSyncState.SFD:
|
|
# Detecting SFD downchirps (2 full + 0.25 fractional)
|
|
is_dc, _ = self._is_downchirp(samples)
|
|
if is_dc:
|
|
self._sfd_count += 1
|
|
# After 2 full downchirps, transition to DATA
|
|
# The 0.25 fractional downchirp is handled in sync_from_samples
|
|
if self._sfd_count >= 2:
|
|
self._state = FrameSyncState.DATA
|
|
else:
|
|
# Not a downchirp after expecting SFD - could be data already
|
|
# (This handles cases where SFD detection fails)
|
|
self._data_bins.append(peak_bin)
|
|
self._state = FrameSyncState.DATA
|
|
|
|
elif self._state == FrameSyncState.DATA:
|
|
self._data_bins.append(peak_bin)
|
|
|
|
return None
|
|
|
|
def sync_from_samples(self, samples: NDArray[np.complex64],
|
|
max_data_symbols: int = 100) -> SyncResult:
|
|
"""Synchronize and extract frame from a block of samples.
|
|
|
|
The key challenge is the fractional SFD: the LoRa SFD is 2.25 downchirps,
|
|
meaning data symbols start at a 0.25 symbol offset after the last full
|
|
downchirp. This method uses a two-phase approach:
|
|
|
|
Phase 1: State machine to detect preamble, sync word, and SFD
|
|
Phase 2: Extract data from the correct fractional offset
|
|
|
|
Args:
|
|
samples: Complex IQ samples containing a LoRa frame
|
|
max_data_symbols: Maximum data symbols to extract
|
|
|
|
Returns:
|
|
SyncResult with extracted frame data
|
|
"""
|
|
self.reset()
|
|
|
|
n_symbols = len(samples) // self.sps
|
|
sfd_end_symbol = None # Track when we find the SFD
|
|
|
|
# Phase 1: Find frame structure using state machine
|
|
for i in range(n_symbols):
|
|
symbol_samples = samples[i * self.sps:(i + 1) * self.sps]
|
|
|
|
prev_state = self._state
|
|
self.process_symbol(symbol_samples)
|
|
|
|
# Record when we transition to DATA state
|
|
if prev_state == FrameSyncState.SFD and self._state == FrameSyncState.DATA:
|
|
sfd_end_symbol = i
|
|
break
|
|
|
|
# Phase 2: Extract data symbols at correct fractional offset
|
|
# SFD is 2.25 downchirps, so add 0.25 symbol offset after SFD detection
|
|
if sfd_end_symbol is not None:
|
|
# Clear any bins captured during state machine (they're misaligned)
|
|
self._data_bins = []
|
|
|
|
# Data starts after 2.25 SFD downchirps from the start of SFD
|
|
# When we transition to DATA at symbol index i, that's the 2nd full downchirp
|
|
# We still need to skip: that symbol (1) + fractional part (0.25) = 1.25
|
|
# So data_start = (sfd_end_symbol + 1.25) * sps
|
|
data_start_sample = int((sfd_end_symbol + 1.25) * self.sps)
|
|
|
|
# Extract data symbols from the correct offset
|
|
for i in range(max_data_symbols):
|
|
start = data_start_sample + i * self.sps
|
|
end = start + self.sps
|
|
if end > len(samples):
|
|
break
|
|
symbol_samples = samples[start:end]
|
|
peak_bin, peak_mag = self._dechirp_and_peak(symbol_samples)
|
|
self._data_bins.append(peak_bin)
|
|
|
|
# Build result
|
|
found = len(self._data_bins) > 0 and self._preamble_count >= self.config.preamble_min
|
|
|
|
# Extract NETWORKID from sync word
|
|
sync_raw = (0, 0)
|
|
networkid = 0
|
|
if len(self._sync_bins) >= 2:
|
|
# Sync word bins are relative to preamble bin
|
|
cfo_int = int(round(self._cfo_estimate))
|
|
sync_raw = (self._sync_bins[0], self._sync_bins[1])
|
|
# Convert to deltas from preamble
|
|
d1 = (self._sync_bins[0] - cfo_int) % self.N
|
|
d2 = (self._sync_bins[1] - cfo_int) % self.N
|
|
networkid = sync_word_to_networkid((d1, d2))
|
|
|
|
# Filter by expected NETWORKID if configured
|
|
if found and self.config.expected_networkid is not None:
|
|
if networkid != self.config.expected_networkid:
|
|
found = False
|
|
|
|
return SyncResult(
|
|
found=found,
|
|
networkid=networkid,
|
|
cfo_bin=self._cfo_estimate,
|
|
data_symbols=list(self._data_bins),
|
|
preamble_count=self._preamble_count,
|
|
sync_word_raw=sync_raw,
|
|
)
|
|
|
|
|
|
def frame_sync(sf: int = 9, sample_rate: float = 250e3,
|
|
expected_networkid: Optional[int] = None) -> FrameSync:
|
|
"""Factory function for GNU Radio compatibility."""
|
|
return FrameSync(sf=sf, sample_rate=sample_rate,
|
|
expected_networkid=expected_networkid)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Frame Sync Test")
|
|
print("=" * 50)
|
|
|
|
# Generate a test frame
|
|
sf = 9
|
|
N = 1 << sf
|
|
fs = 125e3
|
|
|
|
# Simple chirp generation
|
|
n = np.arange(N)
|
|
|
|
def upchirp(f_start: int) -> np.ndarray:
|
|
phase = 2 * np.pi * ((f_start * n / N) + (n * n / (2 * N)))
|
|
return np.exp(1j * phase).astype(np.complex64)
|
|
|
|
def downchirp() -> np.ndarray:
|
|
return np.conj(upchirp(0))
|
|
|
|
# Build test frame
|
|
# Preamble (8 upchirps at bin 0)
|
|
preamble = np.tile(upchirp(0), 8)
|
|
|
|
# Sync word for NETWORKID=18 (0x12): nibbles 1, 2 → bins 32, 64
|
|
sync_nibble_hi = 1
|
|
sync_nibble_lo = 2
|
|
sync_bin_1 = sync_nibble_hi * (N // 16) # 32
|
|
sync_bin_2 = sync_nibble_lo * (N // 16) # 64
|
|
sync_word = np.concatenate([upchirp(sync_bin_1), upchirp(sync_bin_2)])
|
|
|
|
# SFD (2.25 downchirps)
|
|
dc = downchirp()
|
|
sfd = np.concatenate([dc, dc, dc[:N // 4]])
|
|
|
|
# Data symbols
|
|
data_bins = [100, 200, 300, 400, 500]
|
|
data = np.concatenate([upchirp(b) for b in data_bins])
|
|
|
|
# Complete frame
|
|
frame = np.concatenate([preamble, sync_word, sfd, data])
|
|
|
|
print(f"\nTest frame (SF{sf}):")
|
|
print(f" Preamble: 8 symbols")
|
|
print(f" Sync word: bins [{sync_bin_1}, {sync_bin_2}] → NETWORKID=18")
|
|
print(f" SFD: 2.25 downchirps")
|
|
print(f" Data: {len(data_bins)} symbols, bins {data_bins}")
|
|
print(f" Total: {len(frame)} samples")
|
|
|
|
# Test synchronizer
|
|
sync = FrameSync(sf=sf, sample_rate=fs)
|
|
result = sync.sync_from_samples(frame)
|
|
|
|
print(f"\nSync result:")
|
|
print(f" found: {result.found}")
|
|
print(f" networkid: {result.networkid}")
|
|
print(f" cfo_bin: {result.cfo_bin:.2f}")
|
|
print(f" preamble_count: {result.preamble_count}")
|
|
print(f" sync_word_raw: {result.sync_word_raw}")
|
|
print(f" data_symbols: {result.data_symbols}")
|
|
|
|
if result.found and result.networkid == 18:
|
|
print("\n✓ Frame sync OK")
|
|
else:
|
|
print("\n✗ Frame sync FAILED")
|