"""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}")