#!/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 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()