Patrick Lidstone
Self-hosted

LoRa PHY: the Semtech-interoperable symbol↔︎byte codec

A clean-room, DSP-free implementation of the LoRa physical-layer bit pipeline — Gray mapping, diagonal interleaving, Hamming FEC, whitening, the explicit header with its GF(2) checksum, and the LoRa CRC-16 — that decodes and re-encodes real Semtech frames bit-for-bit.

Rafe project · app/radio/lorasdr/phy.py, app/radio/lorasdr/_tables.py · validated interop-correct against jkadbear/LoRaPHY


Abstract

LoRa is a chirp-spread-spectrum (CSS) modulation whose air interface Semtech has never fully published; every open implementation is a reverse-engineering. This module is the byte↔︎symbol half of that air interface: given a stream of demodulated LoRa symbols (integers in 0 … 2^SF − 1, one per chirp) it recovers the transmitted payload bytes and verifies their CRC, and given a payload it produces the exact symbol sequence a Semtech radio would transmit. It contains no DSP — no chirps, no FFTs, no synchronisation (that is the companion chirp modem); it is purely the forward-error-correction and framing arithmetic that sits between the demodulator and the application.

The chain is a faithful, from-scratch transcription of the well-tested jkadbear/LoRaPHY reference (MIT) — the same six stages, the same constant tables, the same MATLAB-derived bit conventions — implemented in ~270 lines of pure NumPy-free Python (NumPy is used only for one np.roll). It is validated interop-correct: it decodes that project's published known-answer vector — a real SF12 / BW125 kHz frame — to the payload bytes 01 02 … 09 with CRC BA 2E, and re-encodes the header/payload/CRC region back to the identical on-air symbols. An exhaustive round-trip over every spreading factor SF7–SF12 crossed with every coding rate 4/5 … 4/8 completes without error. The value for Rafe is a Semtech-compatible LoRa stack — the basis of the Meshtastic mode — with no external radio library, driven by a LimeSDR.


1. Abstract of the module (scope and non-goals)

app/radio/lorasdr/phy.py exposes three things (app/radio/lorasdr/__init__.py):

from app.radio.lorasdr import LoRaPHY, encode, decode
  • encode(payload, sf, cr=1, crc=1, has_header=1, ldr=0) -> [symbols] — bytes to a list of LoRa symbol values 0 … 2^SF−1.
  • decode(symbols, sf, has_header=1, ldr=0, cr=1, crc=1) -> (bytes, ok) — symbols back to (payload, crc_ok); when has_header, the length/CR/CRC-present fields are read from the decoded header, so the caller's cr/crc hints are overridden.
  • class LoRaPHY(sf, bw, cr=1, crc=1, has_header=1) — a thin wrapper (phy.py) that stores the mode parameters and derives the low-data-rate flag once via ldro(sf, bw) (phy.py).

The coding-rate argument cr is the LoRa rate index 1 = 4/5, 2 = 4/6, 3 = 4/7, 4 = 4/8 — i.e. cr = number of parity bits per nibble = (4+cr)−4. The redundancy rdd = cr + 4 is the codeword width in bits.

Everything below documents the real implementation, quoting the exact constants. No DSP, no timing, no frame-sync appears here — those live in the modem.


2. Background: the six PHY layers of LoRa CSS

A LoRa frame is built by stacking six independent transforms. On transmit they run top-to-bottom; on receive, bottom-to-top. Each is a self-contained, invertible (or error-correcting) map, and each exists for a specific reason:

# Layer Purpose phy.py
1 CRC-16 whole-payload integrity, appended before coding calc_crc
2 Whitening XOR payload with a fixed PN9 sequence so long runs of identical bits (which bias the demodulator and the DC of the waveform) are broken up _whiten
3 Hamming FEC per-nibble (4,4+CR) code; single-bit correction at CR 4/7–4/8, detection at 4/5–4/6 _hamming_encode, _hamming_decode
4 Diagonal interleaver spread the bits of each codeword across CR+4 different symbols on a rotating diagonal, so a single corrupted symbol damages one bit of many codewords rather than many bits of one _diag_interleave, _diag_deinterleave
5 Gray mapping + offset map the interleaver word to the chirp index through a Gray code, so a ±1 frequency-bin error (the dominant demod error) flips only one bit; plus LoRa's constant symbol offset and the header/LDRO two-bit reduction _gray_decoding, _gray_coding
6 Explicit header a self-describing 5-nibble prefix (payload length, coding rate, CRC-present) protected by its own GF(2) checksum, always coded at CR 4/8 _gen_header, _check_header

Two properties are worth stating up front because they shape everything else:

  • The header is special. It is always transmitted at CR 4/8 and always in the reduced-ppm geometry (SF−2 codewords per block), regardless of the payload's coding rate or the LDRO setting. A receiver can therefore always decode the header knowing only SF, then learn everything else from it.
  • The first block and LDRO both drop two bits per symbol. LoRa deliberately ignores the two least-significant bits of the symbols in the header block, and of all symbols when Low Data Rate Optimization (LDRO) is on, trading throughput for immunity to the intra-symbol drift that long chirps suffer. This is the × 4 / ÷ 4 you will see in the Gray stage and the SF − 2·ldr you will see in the block geometry.

2.1 MATLAB bit conventions (why the helpers look the way they do)

The reference is MATLAB, so phy.py carries three tiny bit helpers that reproduce de2bi/bi2de exactly, and one identity that must be understood or nothing matches: MATLAB circshiftnumpy.roll for a 1-D vector. Positive shift rotates toward higher index (right/down); negative rotates left. The interleaver uses np.roll(col, 1 - x), i.e. a left rotation by x − 1.

_de2bi_lmsb(v, n)  ->  n bits, MSB first    bits[i] = (v >> (n-1-i)) & 1
_bi2de_rmsb(bits)  ->  int, bits[0] = LSB   sum(b << i)
_bi2de_lmsb(bits)  ->  int, bits[0] = MSB   sum(b << (n-1-i))

_de2bi_lmsb/_bi2de_lmsb are "left-msb" (first element most significant); _bi2de_rmsb is MATLAB's default "right-msb" (first element least significant). The interleaver and de-interleaver mix the two deliberately, and the mixture is load-bearing — swapping either breaks interop.


3. Frame structure and the bit→symbol pipeline

3.1 What a LoRa frame is, on the air

   [ preamble ][ sync word ][ SFD ]│[ explicit header ][ payload ][ CRC-16 ]
   └──── modem's job (lora-modem.md) ┘│└──────────── this module's job ──────┘

The preamble (a run of up-chirps), the two sync-word symbols, and the start-of-frame delimiter (down-chirps) are generated and detected by the chirp modem. This module owns everything from the header onward — the part that is coded bits mapped to symbols.

3.2 SF, BW, CR — the three knobs

  • SF (spreading factor), 7…12 — bits carried per chirp symbol. A symbol is an integer 0 … 2^SF − 1; the modem encodes it as a cyclic shift of the base chirp. SF sets both the block geometry and the alphabet size.
  • BW (bandwidth), e.g. 125 kHz — the chirp sweep width. It enters this module in exactly one place: the LDRO decision (§3.4), via the symbol period 2^SF / BW.
  • CR (coding rate index), 1…4 → 4/5…4/8 — parity bits per data nibble. Higher CR = stronger FEC, lower throughput. Only the payload uses CR; the header is fixed at 4/8.

3.3 The block geometry

Coded nibbles are grouped into interleaver blocks. Every block is a rectangle of ppm codewords (rows) × (CR+4) bits (columns), and the interleaver emits (CR+4) symbols from it, each ppm bits wide. Two geometries coexist in one frame:

block codewords ppm codeword width rdd symbols out carries
header / first block SF − 2 8 (CR 4/8) 8 5 header nibbles + first SF−7 payload nibbles
payload block SF − 2·LDRO CR + 4 CR + 4 payload nibbles

So the first eight symbols of the coded frame are always the header block, and every subsequent group of CR+4 symbols is one payload block. This is precisely how decode slices the symbol stream: g[:8] is the header block, then g[8:8+rdd], g[8+rdd:8+2·rdd], … are payload blocks (phy.py).

3.4 Symbols carry SF bits, or SF−2 with LDRO

Each interleaved symbol is ppm bits wide, then the Gray stage applies an offset. For the header block and, when LDRO is on, every block, the offset is (num × 4 + 1) mod 2^SF (phy.py) — the × 4 forces the low two bits of the on-air symbol to a fixed pattern, so those two bins carry no information: the symbol conveys SF − 2 bits. Otherwise the offset is (num + 1) mod 2^SF (phy.py) and the symbol conveys the full SF bits.

LDRO is engaged when the symbol period exceeds 16 ms (phy.py):

def ldro(sf, bw):
    return 1 if (2 ** sf) / bw > 16e-3 else 0

At BW = 125 kHz this makes LDRO on for SF11 and SF12 (2^11/125e3 = 16.38 ms, 2^12/125e3 = 32.77 ms) and off for SF7–SF10 — verified against the running code. The reference SF12/BW125 vector (§7) is therefore an LDRO=1 frame; getting LDRO right is required to decode it.

3.5 The pipeline, end to end

TX:  payload ─▶ +CRC-16 ─▶ whiten(payload only) ─▶ nibble-split ─▶ prepend header
        ─▶ Hamming(4,4+CR) [first SF−2 forced 4/8] ─▶ diagonal interleave (per block)
        ─▶ Gray-map + offset ─▶ symbols
RX:  symbols ─▶ un-offset + Gray⁻¹ ─▶ diagonal de-interleave ─▶ Hamming-correct
        ─▶ nibble-join ─▶ [parse+check header] ─▶ de-whiten(payload only) ─▶ CRC verify

4. Encode, step by step

encode(payload, sf, cr, crc, has_header, ldr) (phy.py). Trace with the reference mode SF=12, CR=4, CRC=1, header=1, LDRO=1, payload 01…09.

(1) Append CRC. data = payload + calc_crc(payload) — two CRC bytes (§6.4) appended to the plaintext payload. For the example, data = 01…09 BA 2E (11 bytes).

(2) Size the frame. sym_num = _calc_sym_num(...) (formula in §6.6) gives the total coded-symbol count — 24 here. Then the total nibble budget:

nibble_num = sf - 2 + (sym_num - 8) // (cr + 4) * (sf - 2 * ldr)     # :205

= 10 + (24−8)//8 × 10 = 10 + 2×10 = 30 nibbles = 15 bytes.

(3) Pad to the nibble budget. data_w = data + [255] * ceil((nibble_num − 2·len(data)) / 2) — append 0xFF bytes until data_w holds nibble_num nibbles. Here data is 11 bytes → pad with 4 × 0xFF → 15 bytes.

(4) Whiten the payload — and only the payload.

data_w[:plen] = _whiten(data_w[:plen])                               # :207

Whitening covers data_w[:plen], i.e. the payload bytes alone. The CRC bytes (already computed over the plaintext payload) and the 0xFF padding are left in the clear. This asymmetry is essential and must be reproduced exactly: the receiver de-whitens only byts[:plen] and compares the untouched CRC bytes.

(5) Split into nibbles, low nibble first. For i = 1 … nibble_num:

idx = (i + 1) // 2 - 1
nibble = data_w[idx] & 0xF   if i odd   else   data_w[idx] >> 4

Each byte contributes its low nibble first, then its high nibble — a little-endian nibble order.

(6) Prepend the header. hdr = _gen_header(plen, cr, crc) (§6.3) — 5 header nibbles — is prepended: _hamming_encode(hdr + nibbles, …). With no header, hdr is empty.

(7) Hamming-encode every nibble. _hamming_encode(hdr+nibbles, sf, cr). Crucially, the first SF−2 codewords are forced to CR 4/8 irrespective of cr:

cr_now = 4 if i < sf - 2 else cr          # :80  — the whole header block is 4/8

so the header block (5 header nibbles + first SF−7 payload nibbles) is always the strongest code. Codeword bit layouts are in §6.5.

(8) Interleave, block by block. The first SF−2 codewords form the header block at rdd=8; the rest form payload blocks at rdd=CR+4, ppm=SF−2·LDRO:

sym_i = _diag_interleave(codewords[:sf - 2], 8)           # :214  header block → 8 symbols
ppm = sf - 2 * ldr; i = sf - 2
while i + ppm <= len(codewords):                          # :217
    sym_i += _diag_interleave(codewords[i:i + ppm], cr + 4)
    i += ppm

(Any codewords past the last whole block — the tail of the 0xFF padding — are simply not transmitted; see §7 for why the reference encode matches only the first 16 symbols.)

(9) Gray-map to chirp symbols. return _gray_decoding(sym_i, sf, ldr) (§6.7) — applies the Gray map and the ×4+1 / +1 offset, yielding the final symbol list 0 … 2^SF−1.


5. Decode, step by step

decode(symbols, sf, has_header, ldr, cr, crc) (phy.py) — the exact inverse.

(1) Un-offset and Gray-invert. g = _gray_coding(symbols, sf, ldr) (§6.7): subtract the offset (÷4 for header/LDRO symbols, −1 otherwise) and apply the binary→Gray map to recover the interleaver-domain words.

(2) Header block. nibbles = _hamming_decode(_diag_deinterleave(g[:8], sf-2), 8) — de-interleave the first 8 symbols into SF−2 codewords at ppm = SF−2, then Hamming-decode at rdd=8.

(3) Parse and verify the header, if has_header:

if not _check_header(nibbles): return b"", False   # GF(2) checksum failed
plen = nibbles[0] * 16 + nibbles[1]                 # payload length
crc  = nibbles[2] & 1                               # CRC-present flag
cr   = nibbles[2] >> 1                              # coding-rate index (payload)
payload_nibbles = nibbles[5:]                        # header is 5 nibbles

The header overrides the caller's cr/crc. Without a header, all SF−2 decoded nibbles are payload and plen is inferred later.

(4) Payload blocks: with rdd = cr+4, ppm = sf−2·ldr, consume the remaining symbols block by block:

i = 8
while i + rdd <= len(g):
    payload_nibbles += _hamming_decode(_diag_deinterleave(g[i:i+rdd], ppm), rdd)
    i += rdd

(5) Re-assemble bytes, low nibble first, capped at 255 bytes:

byts[k] = (payload_nibbles[2k] & 0xF) | ((payload_nibbles[2k+1] & 0xF) << 4)

(6) De-whiten the payload only: payload = _whiten(byts[:plen]) (whitening is its own inverse).

(7) Verify CRC: if crc and enough bytes are present, ok = (calc_crc(payload) == byts[plen:plen+2]). Return (bytes(payload), ok).


6. Constants and tables (the reproducible core)

Everything a re-implementer needs that cannot be derived — quoted verbatim.

6.1 The whitening PN9 sequence — self-contained LFSR recipe

WHITEN is a 255-byte PN9 sequence, byte-indexed: _whiten XORs data[i] ^ WHITEN[i], so it is trivially self-inverse. Only the payload (data_w[:plen]) is whitened; the CRC bytes and the 0xFF padding are left in the clear (§4 step 4).

The sequence is not a magic blob — it is the run of a standard 8-bit Fibonacci LFSR, and the whole 255-byte table can be regenerated from this recipe (verified against the running module to reproduce all 255 WHITEN bytes exactly):

  • width 8 bits; seed 0xFF;
  • feedback bit = XOR of state bits 7, 5, 4, 3 (b7 ^ b5 ^ b4 ^ b3);
  • step: emit the current state byte first, then shift left and feed the feedback bit into bit 0 — state = ((state << 1) | fb) & 0xFF.
def whiten_seq(length=255, s=0xFF):
    out = []
    for _ in range(length):
        out.append(s)                                    # emit BEFORE shifting
        fb = ((s >> 7) ^ (s >> 5) ^ (s >> 4) ^ (s >> 3)) & 1   # taps 7,5,4,3
        s = ((s << 1) | fb) & 0xFF                        # shift left, feed bit 0
    return out

Spot-check of the first and last bytes it produces (identical to the shipped table):

WHITEN[0..15]    = ff fe fc f8 f0 e1 c2 85 0b 17 2f 5e bc 78 f1 e3
                 = 255 254 252 248 240 225 194 133 11 23 47 94 188 120 241 227  (decimal)
WHITEN[247..254] = 84 09 13 27 4f 9f 3f 7f
                 = 132   9  19  39  79 159  63 127                              (decimal)

The first byte is 0xff and the last (WHITEN[254]) is 0x7f. A re-implementer may either run the LFSR above or copy the 255-entry table from app/radio/lorasdr/_tables.py; both are validated to agree, so the recipe alone is sufficient to reproduce the protocol constant.

6.2 The header checksum matrix (GF(2), 5×12)

The header's 5 checksum bits are a linear function over GF(2) of the 12 data bits (three data nibbles × 4). HEADER_CHECKSUM_MATRIX (_tables.py) is quoted in full:

HEADER_CHECKSUM_MATRIX = [
    [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
    [1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
    [0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0],
    [0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1],
    [0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1],
]

Row r gives checksum bit r: chk[r] = (Σ_c M[r][c] & bits[c]) & 1 (phy.py) — a GF(2) matrix–vector product. The 12-bit input bits is the three data nibbles expanded MSB-first (_de2bi_lmsb(nib, 4) per nibble).

6.3 Header format

_gen_header(plen, cr, crc) (phy.py) builds 5 nibbles:

h[0] = plen >> 4          # payload length, high nibble
h[1] = plen & 0xF         # payload length, low nibble
h[2] = (cr << 1) | crc    # bits 3..1 = CR index, bit 0 = CRC-present
h[3] = chk[0]             # checksum bit 0 (only bit 0 of the nibble is used)
h[4] = chk[1..4] packed:  chk[1]<<3 | chk[2]<<2 | chk[3]<<1 | chk[4]

_check_header recomputes chk from nibbles[:3] and compares it to [nibbles[3] & 1] + _de2bi_lmsb(nibbles[4], 4); mismatch ⇒ decode returns (b"", False).

6.4 CRC-16

The core is a textbook CRC-16-CCITT: polynomial 0x1021 (x¹⁶+x¹²+x⁵+1), init 0x0000, MSB-first, no final XOR (phy.py):

def _crc16(data):
    crc = 0
    for by in data:
        crc ^= (by << 8)
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
    return crc

LoRa's quirk (calc_crc): the CRC is taken over data[:-2] (all but the last two payload bytes), then its low and high bytes are XORed with the last two payload bytes, and short payloads are special-cased:

def calc_crc(data):
    if len(data) == 0: return [0, 0]
    if len(data) == 1: return [data[0], 0]
    if len(data) == 2: return [data[1], data[0]]
    c = _crc16(data[:-2])
    return [(c & 0xFF) ^ data[-1], (c >> 8) ^ data[-2]]     # low^last, high^2nd-last

The two returned bytes are appended (unwhitened) after the payload. For the reference payload 01…09 this yields [0xBA, 0x2E] — matched by the test (test_lorasdr.py).

6.5 Hamming (4, 4+CR) codeword layout

_hamming_encode (phy.py) computes five parity bits from a nibble's bits (1-indexed, LSB = 1) using _bit_reduce_xor:

p1 = b1^b3^b4      p2 = b1^b2^b4      p3 = b1^b2^b3
p4 = b1^b2^b3^b4   (overall parity)   p5 = b2^b3^b4

The codeword places the data nibble in bits 0–3 and stacks parity above, per rate:

CR index rate width codeword (MSB → LSB) phy.py
1 4/5 5 p4 · nib phy.py
2 4/6 6 p5 · p3 · nib phy.py
3 4/7 7 p2 · p5 · p3 · nib phy.py
4 4/8 8 p1 · p2 · p5 · p3 · nib phy.py

Decode (_hamming_decode) only corrects when there is enough redundancy — rdd ∈ {7, 8} (CR 4/7, 4/8). It forms a 3-bit syndrome and flips the indicated bit via a fixed lookup:

p2 = b7^b4^b2^b1;  p3 = b5^b3^b2^b1;  p5 = b6^b4^b3^b2
parity = (p2 << 2) | (p3 << 1) | p5
cw ^= _PARITY_FIX.get(parity, 0)                        # :105

with the parity-fix table:

_PARITY_FIX = {3: 4, 5: 8, 6: 1, 7: 2}

i.e. syndrome 3 → flip bit 0b100, 5 → 0b1000, 6 → 0b1, 7 → 0b10; any other syndrome (0, 1, 2, 4) is treated as no correction. At CR 4/5 and 4/6 the code only detects; _hamming_decode simply returns cw & 0xF without correction.

6.6 Symbol-count and nibble-budget formulas

def _calc_sym_num(plen, sf, cr, crc, has_header, ldr):                 # :187
    return 8 + max((4 + cr) * -(-(2 * plen - sf + 7 + 4 * crc
                                  - 5 * (1 - has_header)) // (sf - 2 * ldr)), 0)

nibble_num = sf - 2 + (sym_num - 8) // (cr + 4) * (sf - 2 * ldr)        # :205

-(-a // b) is ceiling division. The leading 8 is the header block's eight symbols; the rest is (CR+4) symbols per payload block. has_header removes 5 "virtual" header nibbles from the count when absent.

6.7 Gray mapping and the symbol offset

Encode side, _gray_decoding (phy.py): convert each interleaver word from Gray to binary (the descending-mask XOR cascade), then apply the offset —

mask = num >> 1
while mask: num ^= mask; mask >>= 1        # Gray → binary
out = (num * 4 + 1) % 2**sf   if (i < 8 or ldr)   else   (num + 1) % 2**sf

Decode side, _gray_coding: undo the offset, then binary → Gray —

v = v // 4  if (i < 8 or ldr)  else  (v - 1) % 2**sf
s = v & 0xFFFF
out = s ^ (s >> 1)                          # binary → Gray

The i < 8 clause applies the two-bit reduction to the first eight symbols (the header block); ldr extends it to all symbols. The +1 / −1 is LoRa's constant modulation offset. Note the deliberate asymmetry: encode does ×4 + 1, decode does integer ÷4 (discarding the +1 remainder) — correct because the two low bits carry no information in that regime.

6.8 The diagonal interleaver rotation rule

_diag_interleave(codewords, rdd) (phy.py) — ncw codewords in, rdd symbols out:

  1. Build an ncw × rdd bit matrix, tmp[r][j] = (codeword_r >> j) & 1 (bit j = the j-th LSB, "right-msb").
  2. For each output symbol x = 1 … rdd: take column x−1 (a vector of ncw bits), rotate it left by x − 1np.roll(col, 1 − x), i.e. MATLAB circshift(col, 1 − x) — and read the rotated vector as an integer with _bi2de_rmsb (first element = LSB).

The per-column rotation by a different amount is the "diagonal": bit j of codeword r lands in symbol j+1 at a position shifted by j, spreading each codeword's bits across all rdd symbols on a diagonal.

_diag_deinterleave(symbols, ppm) inverts it:

  1. Expand each of the rdd symbols to ppm bits, MSB-first (_de2bi_lmsb).
  2. Rotate row x left by x − 1 (np.roll(row, 1 − x)).
  3. Transpose (zip(*rows)) to ppm × rdd, read each row _bi2de_rmsb, and reverse the resulting codeword list (flipud).

The mixed left-msb/right-msb reads and the final reversal are all required for interop — they are not free conventions.


7. Interoperability and validation

The module is validated against jkadbear/LoRaPHY's published known-answer vector and by an exhaustive self-consistent round trip (test_lorasdr.py).

Known-answer vector (test_lorasdr.py) — a real SF12 / BW125 kHz frame (hence LDRO=1, CR 4/8, CRC on), 24 symbols:

REF_SYMBOLS = [2541, 1153, 673, 2397, 1189, 3509, 41, 3089, 3237, 3917, 2729,
               2765, 1417, 2833, 1389, 801, 3197, 345, 961, 745, 3101, 297, 1893, 469]
REF_PAYLOAD = 01 02 03 04 05 06 07 08 09          REF_CRC = [0xBA, 0x2E]
  • Decode (test_decode_reference_vector): LoRaPHY(sf=12, bw=125e3, cr=4, crc=1).decode(REF_SYMBOLS) returns (01…09, True) — verified live against the running module (payload 010203040506070809, ok=True).
  • Encode (test_encode_matches_header_payload_crc_region): re-encoding 01…09 reproduces the header + payload + CRC region symbol-for-symbolsym[:16] == REF_SYMBOLS[:16]. Only the trailing block differs, because it carries transmitter-specific 0xFF don't-care padding (§4 step 3), which a real transmitter fills arbitrarily. calc_crc(01…09) == [0xBA, 0x2E] is checked directly.

Exhaustive round trip (test_roundtrip_all_sf_cr): for every SF ∈ {7,8,9,10,11,12} × CR ∈ {1,2,3,4} (i.e. 4/5–4/8), random payloads of 1–40 bytes are encoded and decoded; all recover byte-exact with ok=True. Because the header carries cr/crc/plen, the decoder is given only sf and reads the rest.

Downstream, the same PHY feeds the chirp modem and SDR path: test_lorasdr.py further drives full IQ loopback across SF×CR, CFO/STO robustness, an offline .cf32 SDR file, and sync-word isolation — but those exercise the modem, not this module. The PHY's own correctness is the two known-answer tests plus the SF×CR round trip above.


8. Limitations

  • No DSP, sync, or timing. This module maps symbols↔︎bytes only. Preamble detection, sync-word matching, CFO/STO estimation, and chirp (de)modulation are the companion modem; a symbol stream must already be aligned and offset-corrected before decode.
  • Explicit header assumed by default. Implicit-header mode is supported (has_header=0) but then sf, cr, crc, and payload length must be supplied out-of-band and cannot be checked; the length is inferred from the byte count (phy.py).
  • Payload capped at 255 bytes (min(255, …)) — the LoRa length field is one byte.
  • Hamming corrects one bit per codeword, only at CR 4/7–4/8. CR 4/5 and 4/6 provide detection but no correction here (_hamming_decode corrects only when rdd ∈ {7, 8}). The interleaver's diagonal spread means this per-codeword correction still repairs realistic burst patterns, but there is no soft-decision decoding — symbols arrive already hard-sliced by the modem.
  • CRC is 16-bit with LoRa's XOR quirk; it detects errors, it does not correct them. A header-checksum failure aborts the whole decode.
  • LDRO is derived from 2^SF/BW > 16 ms (§3.4); a link whose two endpoints disagree on LDRO (e.g. a non-standard threshold) will not interoperate. The 16 ms rule matches Semtech's recommendation and the reference.

9. References

  1. jkadbear/LoRaPHY (MIT) — the reference MATLAB implementation this module is a clean-room transcription of; source of the whitening table, header checksum matrix, Hamming/interleaver conventions, and the SF12/BW125 known-answer vector. https://github.com/jkadbear/LoRaPHY
  2. SemtechLoRa Modulation Basics (AN1200.22) and the SX127x / SX126x datasheets: chirp spread spectrum, spreading factor, coding rate, explicit vs implicit header, low-data-rate optimization.
  3. Robyns, Quitin, et al., "A Multi-Channel Software Decoder for the LoRa Modulation Scheme" (2018) — an independent reverse-engineering corroborating the whitening/interleaving/Hamming structure.
  4. In-repo: app/radio/lorasdr/phy.py, app/radio/lorasdr/_tables.py, test_lorasdr.py; companion specs lora-modem.md, meshtastic.md, and the depth/tone template ../rvqvoice.md.