GNU Radio Out-of-Tree module providing: - Complete TX chain: PHYEncode → FrameGen → CSSMod - Complete RX chain: CSSDemod → FrameSync → PHYDecode - NETWORKID extraction/encoding (0-255 range) - All SF (7-12) and CR (4/5-4/8) combinations - Loopback tested with 24/24 configurations passing Key features: - Fractional SFD (2.25 downchirp) handling - Gray encode/decode with proper inverse operations - gr-lora_sdr compatible decode modes - GRC block definitions and example flowgraphs - Comprehensive documentation Discovered RYLR998 sync word mapping: sync_bin_1 = (NETWORKID >> 4) * 8 sync_bin_2 = (NETWORKID & 0x0F) * 8
451 lines
16 KiB
Python
451 lines
16 KiB
Python
"""LoRa PHY layer decoder: Gray decode → deinterleave → Hamming FEC → dewhiten.
|
|
|
|
Complete receive chain for decoding LoRa payload bytes from CSS-demodulated
|
|
symbol bins. Implements the exact gr-lora_sdr compatible signal chain.
|
|
|
|
RX chain order:
|
|
CSS demod (peak bins) → Correction → Gray map → Deinterleave
|
|
→ Hamming FEC → Dewhiten → CRC check
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class LoRaFrame:
|
|
"""Decoded LoRa frame with header fields and payload."""
|
|
payload: bytes
|
|
payload_length: int # from header
|
|
coding_rate: int # 1-4 (CR 4/5 through 4/8)
|
|
has_crc: bool
|
|
crc_ok: Optional[bool] # None if no CRC
|
|
header_ok: bool
|
|
sf: int
|
|
n_symbols: int
|
|
errors_corrected: int # FEC corrections
|
|
|
|
|
|
# Whitening/dewhitening sequence (from gr-lora_sdr tables.h)
|
|
DEWHITEN_SEQ = bytes([
|
|
0xFF, 0xFE, 0xFC, 0xF8, 0xF0, 0xE1, 0xC2, 0x85,
|
|
0x0B, 0x17, 0x2F, 0x5E, 0xBC, 0x78, 0xF1, 0xE3,
|
|
0xC6, 0x8D, 0x1A, 0x34, 0x68, 0xD0, 0xA0, 0x40,
|
|
0x80, 0x01, 0x02, 0x04, 0x08, 0x11, 0x23, 0x47,
|
|
0x8E, 0x1C, 0x38, 0x71, 0xE2, 0xC4, 0x89, 0x12,
|
|
0x25, 0x4B, 0x97, 0x2E, 0x5C, 0xB8, 0x70, 0xE0,
|
|
0xC0, 0x81, 0x03, 0x06, 0x0C, 0x19, 0x32, 0x64,
|
|
0xC9, 0x92, 0x24, 0x49, 0x93, 0x26, 0x4D, 0x9B,
|
|
0x37, 0x6E, 0xDC, 0xB9, 0x72, 0xE4, 0xC8, 0x90,
|
|
0x20, 0x41, 0x82, 0x05, 0x0A, 0x15, 0x2B, 0x56,
|
|
0xAD, 0x5B, 0xB6, 0x6D, 0xDA, 0xB5, 0x6B, 0xD6,
|
|
0xAC, 0x59, 0xB2, 0x65, 0xCB, 0x96, 0x2C, 0x58,
|
|
0xB0, 0x61, 0xC3, 0x87, 0x0F, 0x1F, 0x3E, 0x7D,
|
|
0xFA, 0xF4, 0xE8, 0xD1, 0xA2, 0x44, 0x88, 0x10,
|
|
0x21, 0x43, 0x86, 0x0D, 0x1B, 0x36, 0x6C, 0xD8,
|
|
0xB1, 0x63, 0xC7, 0x8F, 0x1E, 0x3C, 0x79, 0xF3,
|
|
0xE7, 0xCE, 0x9C, 0x39, 0x73, 0xE6, 0xCC, 0x98,
|
|
0x31, 0x62, 0xC5, 0x8B, 0x16, 0x2D, 0x5A, 0xB4,
|
|
0x69, 0xD2, 0xA4, 0x48, 0x91, 0x22, 0x45, 0x8A,
|
|
0x14, 0x29, 0x52, 0xA5, 0x4A, 0x95, 0x2A, 0x54,
|
|
0xA9, 0x53, 0xA7, 0x4E, 0x9D, 0x3B, 0x77, 0xEE,
|
|
0xDD, 0xBB, 0x76, 0xEC, 0xD9, 0xB3, 0x67, 0xCF,
|
|
0x9E, 0x3D, 0x7B, 0xF7, 0xEF, 0xDF, 0xBE, 0x7C,
|
|
0xF9, 0xF2, 0xE5, 0xCA, 0x94, 0x28, 0x51, 0xA3,
|
|
0x46, 0x8C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07,
|
|
0x0E, 0x1D, 0x3A, 0x75, 0xEB, 0xD7, 0xAE, 0x5D,
|
|
0xBA, 0x74, 0xE9, 0xD3, 0xA6, 0x4C, 0x99, 0x33,
|
|
0x66, 0xCD, 0x9A, 0x35, 0x6A, 0xD4, 0xA8, 0x50,
|
|
0xA1, 0x42, 0x84, 0x09, 0x13, 0x27, 0x4F, 0x9F,
|
|
0x3F, 0x7F, 0xFF, 0xFE, 0xFC, 0xF8, 0xF0, 0xE1,
|
|
0xC2, 0x85, 0x0B, 0x17, 0x2F, 0x5E, 0xBC, 0x78,
|
|
0xF1, 0xE3, 0xC6, 0x8D, 0x1A, 0x34, 0x68, 0xD0,
|
|
])
|
|
|
|
|
|
class PHYDecode:
|
|
"""LoRa PHY layer decoder block.
|
|
|
|
Decodes demodulated symbol bins into payload bytes using the
|
|
gr-lora_sdr compatible signal chain.
|
|
"""
|
|
|
|
def __init__(self, sf: int = 9, cr: int = 1, has_crc: bool = True,
|
|
ldro: bool = False, implicit_header: bool = False,
|
|
payload_len: int = 0):
|
|
"""Initialize PHY decoder.
|
|
|
|
Args:
|
|
sf: Spreading factor (7-12)
|
|
cr: Coding rate 1-4 (CR 4/5 through CR 4/8), used for implicit mode
|
|
has_crc: CRC enabled (used for implicit mode)
|
|
ldro: Low Data Rate Optimization
|
|
implicit_header: Use implicit header mode (no header transmitted)
|
|
payload_len: Expected payload length for implicit mode
|
|
"""
|
|
if not 7 <= sf <= 12:
|
|
raise ValueError(f"SF must be 7-12, got {sf}")
|
|
if not 1 <= cr <= 4:
|
|
raise ValueError(f"CR must be 1-4, got {cr}")
|
|
|
|
self.sf = sf
|
|
self.N = 1 << sf
|
|
self.cr = cr
|
|
self.has_crc = has_crc
|
|
self.ldro = ldro
|
|
self.implicit_header = implicit_header
|
|
self.payload_len = payload_len
|
|
|
|
def gray_map(self, symbols: list[int], sf_bits: int) -> list[int]:
|
|
"""gr-lora_sdr Gray mapping: x ^ (x >> 1).
|
|
|
|
Used for decoding real captures where CSS modulation produces
|
|
Gray-domain output.
|
|
"""
|
|
n = 1 << sf_bits
|
|
return [(s ^ (s >> 1)) % n for s in symbols]
|
|
|
|
def gray_decode(self, symbols: list[int], sf_bits: int) -> list[int]:
|
|
"""Iterative Gray decode (Gray → binary).
|
|
|
|
Used for loopback testing with our own TX encoder which does
|
|
Gray encode. This is the proper inverse of Gray encode.
|
|
"""
|
|
n = 1 << sf_bits
|
|
result = []
|
|
for g in symbols:
|
|
b = g
|
|
mask = g >> 1
|
|
while mask:
|
|
b ^= mask
|
|
mask >>= 1
|
|
result.append(b % n)
|
|
return result
|
|
|
|
def deinterleave(self, symbols: list[int], sf: int, cr: int,
|
|
reduced_rate: bool) -> list[int]:
|
|
"""Deinterleave symbols into codewords.
|
|
|
|
LoRa interleaves bits diagonally across groups of symbols.
|
|
"""
|
|
cw_len = cr + 4 # codeword length in bits
|
|
sf_app = sf - 2 if reduced_rate else sf
|
|
|
|
codewords = []
|
|
|
|
for grp_start in range(0, len(symbols), cw_len):
|
|
grp = symbols[grp_start:grp_start + cw_len]
|
|
if len(grp) < cw_len:
|
|
break
|
|
|
|
# Build interleaved bit matrix
|
|
inter = []
|
|
for sym in grp:
|
|
row = []
|
|
for bit_pos in range(sf_app - 1, -1, -1):
|
|
row.append((sym >> bit_pos) & 1)
|
|
inter.append(row)
|
|
|
|
# Deinterleave: diagonal rotation
|
|
deinter = [[0] * cw_len for _ in range(sf_app)]
|
|
for i in range(cw_len):
|
|
for j in range(sf_app):
|
|
row_out = (i - j - 1) % sf_app
|
|
deinter[row_out][i] = inter[i][j]
|
|
|
|
# Read out codewords
|
|
for row in deinter:
|
|
cw = 0
|
|
for bit in row:
|
|
cw = (cw << 1) | bit
|
|
codewords.append(cw)
|
|
|
|
return codewords
|
|
|
|
def hamming_decode(self, codewords: list[int], cr: int) -> tuple[list[int], int]:
|
|
"""Decode Hamming-coded codewords to 4-bit nibbles."""
|
|
nibbles = []
|
|
errors = 0
|
|
|
|
for cw in codewords:
|
|
cw_len = cr + 4
|
|
|
|
b7 = (cw >> (cw_len - 1)) & 1
|
|
b6 = (cw >> (cw_len - 2)) & 1
|
|
b5 = (cw >> (cw_len - 3)) & 1
|
|
b4 = (cw >> (cw_len - 4)) & 1
|
|
|
|
if cr == 4:
|
|
# Extended Hamming(8,4)
|
|
b3 = (cw >> 3) & 1
|
|
b2 = (cw >> 2) & 1
|
|
b1 = (cw >> 1) & 1
|
|
b0 = cw & 1
|
|
|
|
s0 = b7 ^ b6 ^ b5 ^ b3
|
|
s1 = b6 ^ b5 ^ b4 ^ b2
|
|
s2 = b7 ^ b6 ^ b4 ^ b1
|
|
syndrome = (s2 << 2) | (s1 << 1) | s0
|
|
|
|
p_check = b7 ^ b6 ^ b5 ^ b4 ^ b3 ^ b2 ^ b1 ^ b0
|
|
|
|
if syndrome != 0:
|
|
if p_check:
|
|
bit_flip = {5: cw_len - 1, 7: cw_len - 2,
|
|
3: cw_len - 3, 6: cw_len - 4,
|
|
1: 3, 2: 2, 4: 1}
|
|
flip_pos = bit_flip.get(syndrome)
|
|
if flip_pos is not None:
|
|
cw ^= (1 << flip_pos)
|
|
errors += 1
|
|
|
|
nibble = (cw >> 4) & 0xF
|
|
|
|
elif cr == 3:
|
|
# Hamming(7,4)
|
|
b3 = (cw >> 2) & 1
|
|
b2 = (cw >> 1) & 1
|
|
b1 = cw & 1
|
|
|
|
s0 = b7 ^ b6 ^ b5 ^ b3
|
|
s1 = b6 ^ b5 ^ b4 ^ b2
|
|
s2 = b7 ^ b6 ^ b4 ^ b1
|
|
syndrome = (s2 << 2) | (s1 << 1) | s0
|
|
|
|
if syndrome != 0:
|
|
bit_flip = {5: cw_len - 1, 7: cw_len - 2,
|
|
3: cw_len - 3, 6: cw_len - 4,
|
|
1: 2, 2: 1, 4: 0}
|
|
flip_pos = bit_flip.get(syndrome)
|
|
if flip_pos is not None:
|
|
cw ^= (1 << flip_pos)
|
|
errors += 1
|
|
|
|
nibble = (cw >> 3) & 0xF
|
|
|
|
elif cr == 2:
|
|
# Partial parity (6,4)
|
|
b3 = (cw >> 1) & 1
|
|
b2 = cw & 1
|
|
|
|
s0 = b7 ^ b6 ^ b5 ^ b3
|
|
s1 = b6 ^ b5 ^ b4 ^ b2
|
|
|
|
if s0 or s1:
|
|
errors += 1
|
|
|
|
nibble = (cw >> 2) & 0xF
|
|
|
|
else:
|
|
# CR 4/5: even parity
|
|
b3 = cw & 1
|
|
parity = b7 ^ b6 ^ b5 ^ b4 ^ b3
|
|
if parity:
|
|
errors += 1
|
|
nibble = (cw >> 1) & 0xF
|
|
|
|
nibbles.append(nibble)
|
|
|
|
return nibbles, errors
|
|
|
|
def dewhiten(self, nibbles: list[int], offset: int = 0) -> list[int]:
|
|
"""Remove whitening from payload nibbles."""
|
|
result = []
|
|
for i in range(0, len(nibbles) - 1, 2):
|
|
seq_byte = DEWHITEN_SEQ[(offset + i // 2) % len(DEWHITEN_SEQ)]
|
|
low = nibbles[i] ^ (seq_byte & 0x0F)
|
|
high = nibbles[i + 1] ^ ((seq_byte >> 4) & 0x0F)
|
|
result.append(low)
|
|
result.append(high)
|
|
|
|
if len(nibbles) % 2 == 1:
|
|
idx = len(nibbles) - 1
|
|
seq_byte = DEWHITEN_SEQ[(offset + idx // 2) % len(DEWHITEN_SEQ)]
|
|
result.append(nibbles[idx] ^ (seq_byte & 0x0F))
|
|
|
|
return result
|
|
|
|
def crc16(self, data: bytes) -> int:
|
|
"""Standard CRC-16/CCITT."""
|
|
crc = 0x0000
|
|
for byte in data:
|
|
crc ^= byte << 8
|
|
for _ in range(8):
|
|
if crc & 0x8000:
|
|
crc = (crc << 1) ^ 0x1021
|
|
else:
|
|
crc <<= 1
|
|
crc &= 0xFFFF
|
|
return crc
|
|
|
|
def _xor_bits(self, val: int, mask: int) -> int:
|
|
"""XOR bits of val selected by mask."""
|
|
x = val & mask
|
|
result = 0
|
|
while x:
|
|
result ^= x & 1
|
|
x >>= 1
|
|
return result
|
|
|
|
def parse_header(self, nibbles: list[int]) -> tuple[int, int, bool, bool]:
|
|
"""Parse explicit header from decoded nibbles."""
|
|
if len(nibbles) < 5:
|
|
return 0, 1, False, False
|
|
|
|
payload_len = (nibbles[0] << 4) | nibbles[1]
|
|
cr = (nibbles[2] >> 1) & 0x07
|
|
has_crc = bool(nibbles[2] & 1)
|
|
|
|
cr = max(1, min(4, cr))
|
|
|
|
# Header checksum
|
|
received_check = ((nibbles[3] & 1) << 4) | nibbles[4]
|
|
d = (nibbles[0] << 8) | (nibbles[1] << 4) | nibbles[2]
|
|
c4 = self._xor_bits(d, 0xF00)
|
|
c3 = self._xor_bits(d, 0x8E1)
|
|
c2 = self._xor_bits(d, 0x49A)
|
|
c1 = self._xor_bits(d, 0x257)
|
|
c0 = self._xor_bits(d, 0x12F)
|
|
computed_check = (c4 << 4) | (c3 << 3) | (c2 << 2) | (c1 << 1) | c0
|
|
|
|
header_ok = (received_check == computed_check)
|
|
|
|
return payload_len, cr, has_crc, header_ok
|
|
|
|
def decode(self, symbols: list[int], cfo_bin: int = 0,
|
|
use_grlora_gray: bool = True,
|
|
soft_decoding: bool = False) -> LoRaFrame:
|
|
"""Decode a complete LoRa frame from CSS-demodulated symbols.
|
|
|
|
Args:
|
|
symbols: Raw FFT peak bin values from CSS demodulation
|
|
cfo_bin: Integer CFO in bins (preamble peak, for correction)
|
|
use_grlora_gray: If True, use gr-lora_sdr Gray mapping (x ^ x>>1).
|
|
If False, use iterative Gray decode (for loopback testing
|
|
with our own TX encoder).
|
|
soft_decoding: If False (default), apply -1 offset for gr-lora_sdr
|
|
compatibility. If True, don't apply the -1 offset (use for
|
|
loopback testing with our own TX encoder).
|
|
|
|
Returns:
|
|
LoRaFrame with decoded payload and status
|
|
"""
|
|
n_symbols = len(symbols)
|
|
N = self.N
|
|
sf = self.sf
|
|
n_app = 1 << (sf - 2)
|
|
|
|
header_cr = 4
|
|
header_cw_len = header_cr + 4 # 8 symbols
|
|
|
|
if n_symbols < header_cw_len:
|
|
return LoRaFrame(
|
|
payload=b"", payload_length=0, coding_rate=1,
|
|
has_crc=False, crc_ok=None, header_ok=False,
|
|
sf=sf, n_symbols=n_symbols, errors_corrected=0,
|
|
)
|
|
|
|
# Apply correction
|
|
# gr-lora_sdr compatibility: (bin - cfo - 1) % N
|
|
# Loopback with our TX: (bin - cfo) % N (no -1 offset)
|
|
offset = 0 if soft_decoding else 1
|
|
corrected = [(b - cfo_bin - offset) % N for b in symbols]
|
|
|
|
header_bins = corrected[:header_cw_len]
|
|
payload_bins = corrected[header_cw_len:]
|
|
|
|
# Select Gray mapping function
|
|
gray_fn = self.gray_map if use_grlora_gray else self.gray_decode
|
|
|
|
# Header: reduced rate, divide by 4
|
|
header_reduced = [b // 4 for b in header_bins]
|
|
header_gray = gray_fn(header_reduced, sf - 2)
|
|
header_cw = self.deinterleave(header_gray, sf, header_cr, reduced_rate=True)
|
|
header_nibbles, header_errors = self.hamming_decode(header_cw, header_cr)
|
|
|
|
payload_len, cr, has_crc, header_ok = self.parse_header(header_nibbles)
|
|
total_errors = header_errors
|
|
|
|
# Extra header nibbles (payload from header block)
|
|
n_extra = max(0, (sf - 2) - 5)
|
|
extra_payload_nibs = list(header_nibbles[5:5 + n_extra])
|
|
|
|
# Payload symbols
|
|
reduced_rate_payload = self.ldro
|
|
|
|
if len(payload_bins) > 0:
|
|
if reduced_rate_payload:
|
|
payload_reduced = [b // 4 for b in payload_bins]
|
|
payload_gray = gray_fn(payload_reduced, sf - 2)
|
|
else:
|
|
payload_gray = gray_fn(payload_bins, sf)
|
|
|
|
payload_cw = self.deinterleave(payload_gray, sf, cr,
|
|
reduced_rate=reduced_rate_payload)
|
|
payload_nibbles, payload_errors = self.hamming_decode(payload_cw, cr)
|
|
total_errors += payload_errors
|
|
else:
|
|
payload_nibbles = []
|
|
|
|
# Combine and dewhiten
|
|
all_payload_nibs = extra_payload_nibs + list(payload_nibbles)
|
|
if all_payload_nibs:
|
|
all_payload_nibs = self.dewhiten(all_payload_nibs, offset=0)
|
|
|
|
# Pack nibbles into bytes
|
|
def pack_nibbles(nibs):
|
|
result = bytearray()
|
|
for i in range(0, len(nibs) - 1, 2):
|
|
result.append(nibs[i] | (nibs[i + 1] << 4))
|
|
return result
|
|
|
|
all_bytes = pack_nibbles(all_payload_nibs)
|
|
payload_bytes = bytearray(all_bytes[:payload_len])
|
|
|
|
# CRC check
|
|
crc_ok = None
|
|
if has_crc and payload_len > 0 and len(all_bytes) >= payload_len + 2:
|
|
crc_received = all_bytes[payload_len] | (all_bytes[payload_len + 1] << 8)
|
|
crc_computed = self.crc16(bytes(payload_bytes))
|
|
crc_ok = (crc_received == crc_computed)
|
|
|
|
return LoRaFrame(
|
|
payload=bytes(payload_bytes),
|
|
payload_length=payload_len,
|
|
coding_rate=cr,
|
|
has_crc=has_crc,
|
|
crc_ok=crc_ok,
|
|
header_ok=header_ok,
|
|
sf=sf,
|
|
n_symbols=n_symbols,
|
|
errors_corrected=total_errors,
|
|
)
|
|
|
|
|
|
def phy_decode(sf: int = 9, cr: int = 1, has_crc: bool = True,
|
|
ldro: bool = False, implicit_header: bool = False,
|
|
payload_len: int = 0) -> PHYDecode:
|
|
"""Factory function for GNU Radio compatibility."""
|
|
return PHYDecode(sf=sf, cr=cr, has_crc=has_crc, ldro=ldro,
|
|
implicit_header=implicit_header, payload_len=payload_len)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("PHY Decode Test")
|
|
print("=" * 50)
|
|
|
|
# Test with known-good bins from real capture
|
|
raw_bins = [84, 368, 136, 452, 340, 156, 0, 504]
|
|
cfo_bin = 231
|
|
sf = 9
|
|
|
|
decoder = PHYDecode(sf=sf)
|
|
frame = decoder.decode(raw_bins, cfo_bin=cfo_bin)
|
|
|
|
print(f"\nTest decode (SF{sf}, CFO={cfo_bin}):")
|
|
print(f" header_ok: {frame.header_ok}")
|
|
print(f" payload_len: {frame.payload_length}")
|
|
print(f" CR: 4/{frame.coding_rate + 4}")
|
|
print(f" has_crc: {frame.has_crc}")
|
|
print(f" errors: {frame.errors_corrected}")
|