gr-rylr998/examples/decode_capture.py
Ryan Malloy 3660f139ec Add channelizer and fix FrameSync for real SDR captures
- 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
2026-02-05 14:00:17 -07:00

211 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Decode RYLR998 LoRa frames from wideband SDR captures.
This script processes real BladeRF captures at 2 MHz sample rate,
channelizes to 125 kHz, and decodes LoRa frames.
Usage:
python decode_capture.py <capture_file.raw>
python decode_capture.py --list # List available captures
"""
import argparse
import sys
from pathlib import Path
import numpy as np
# Add parent to path for local development
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from rylr998 import (
Channelizer,
FrameSync,
PHYDecode,
sync_word_to_networkid,
)
# Capture parameters (BladeRF defaults)
INPUT_SAMPLE_RATE = 2e6 # 2 MHz capture rate
CENTER_FREQ = 915e6 # Center frequency
CHANNEL_FREQ = 915e6 # LoRa channel frequency
CHANNEL_BW = 125e3 # LoRa bandwidth
# LoRa parameters (RYLR998 defaults)
SF = 9
CR = 1
def decode_capture(
capture_path: Path,
sf: int = SF,
verbose: bool = True,
) -> list:
"""Decode LoRa frames from a wideband capture file.
Args:
capture_path: Path to .raw capture file (complex64)
sf: Spreading factor
verbose: Print progress
Returns:
List of decoded frames
"""
# Load capture
if verbose:
print(f"Loading {capture_path.name}...")
iq_raw = np.fromfile(capture_path, dtype=np.complex64)
if len(iq_raw) == 0:
print(" ERROR: Empty file")
return []
# Check for NaN/Inf
if np.any(~np.isfinite(iq_raw)):
nan_count = np.sum(~np.isfinite(iq_raw))
print(f" WARNING: {nan_count} NaN/Inf values ({100*nan_count/len(iq_raw):.1f}%)")
# Replace NaN with zeros
iq_raw = np.nan_to_num(iq_raw, nan=0.0, posinf=0.0, neginf=0.0)
duration_s = len(iq_raw) / INPUT_SAMPLE_RATE
if verbose:
print(f" Loaded {len(iq_raw):,} samples ({duration_s:.1f}s at {INPUT_SAMPLE_RATE/1e6:.1f} MHz)")
# Channelize to LoRa bandwidth
if verbose:
print(f"Channelizing to {CHANNEL_BW/1e3:.0f} kHz...")
channelizer = Channelizer(
input_sample_rate=INPUT_SAMPLE_RATE,
channel_bw=CHANNEL_BW,
center_freq=CENTER_FREQ,
channel_freq=CHANNEL_FREQ,
)
iq_ch = channelizer.channelize(iq_raw)
if verbose:
print(f" Channelized: {len(iq_ch):,} samples ({channelizer})")
# Frame synchronization
if verbose:
print(f"Searching for LoRa frames (SF{sf})...")
sync = FrameSync(sf=sf)
frames = []
# Process in chunks (1 second at a time)
chunk_size = int(CHANNEL_BW) # 1 second of channelized data
n_chunks = (len(iq_ch) + chunk_size - 1) // chunk_size
decoder = PHYDecode(sf=sf)
for i in range(n_chunks):
start = i * chunk_size
end = min((i + 1) * chunk_size, len(iq_ch))
chunk = iq_ch[start:end]
result = sync.sync_from_samples(chunk)
if result.found:
# Extract NETWORKID
nid = result.networkid
if verbose:
print(f"\n Frame detected at chunk {i} ({start/CHANNEL_BW:.1f}s):")
print(f" CFO: {result.cfo_bin:.2f} bins")
print(f" Sync word raw: {result.sync_word_raw}")
print(f" NETWORKID: {nid}")
print(f" Data symbols: {len(result.data_symbols)}")
# Decode
cfo_int = int(round(result.cfo_bin))
frame = decoder.decode(
result.data_symbols,
cfo_bin=cfo_int,
use_grlora_gray=True, # Real captures use gr-lora_sdr convention
soft_decoding=False, # Real captures need -1 offset
)
if verbose:
print(f" Header OK: {frame.header_ok}")
print(f" Payload len: {frame.payload_length}")
print(f" CRC OK: {frame.crc_ok}")
if frame.payload:
try:
text = frame.payload.decode('utf-8', errors='replace')
print(f" Payload: {repr(text)}")
except Exception:
print(f" Payload (hex): {frame.payload.hex()}")
frames.append({
'time': start / CHANNEL_BW,
'networkid': nid,
'cfo_bin': result.cfo_bin,
'frame': frame,
})
# Reset sync for next frame
sync.reset()
if verbose:
print(f"\n{'='*60}")
print(f"Total frames found: {len(frames)}")
return frames
def list_captures():
"""List available capture files."""
logs_dir = Path(__file__).parent.parent.parent / "gnuradio" / "logs"
if not logs_dir.exists():
print(f"Logs directory not found: {logs_dir}")
return
print(f"Capture files in {logs_dir}:")
print("-" * 60)
for raw_file in sorted(logs_dir.glob("*.raw")):
size_mb = raw_file.stat().st_size / 1e6
n_samples = raw_file.stat().st_size // 8 # complex64 = 8 bytes
duration_s = n_samples / INPUT_SAMPLE_RATE
print(f" {raw_file.name:<45} {size_mb:>7.1f} MB ({duration_s:.1f}s)")
def main():
parser = argparse.ArgumentParser(description="Decode LoRa frames from SDR captures")
parser.add_argument("capture", nargs="?", help="Capture file path")
parser.add_argument("--list", action="store_true", help="List available captures")
parser.add_argument("--sf", type=int, default=SF, help=f"Spreading factor (default: {SF})")
parser.add_argument("--quiet", "-q", action="store_true", help="Less verbose output")
args = parser.parse_args()
if args.list:
list_captures()
return
if not args.capture:
parser.print_help()
print("\n\nTip: Use --list to see available capture files")
return
capture_path = Path(args.capture)
if not capture_path.exists():
# Try looking in the logs directory
logs_dir = Path(__file__).parent.parent.parent / "gnuradio" / "logs"
alt_path = logs_dir / args.capture
if alt_path.exists():
capture_path = alt_path
else:
print(f"ERROR: File not found: {capture_path}")
sys.exit(1)
frames = decode_capture(capture_path, sf=args.sf, verbose=not args.quiet)
if not frames:
print("\nNo frames decoded. Try:")
print(" - Different SF (--sf 7 through --sf 12)")
print(" - Check capture frequency matches RYLR998 setting")
print(" - Ensure RYLR998 is transmitting")
if __name__ == "__main__":
main()