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 values0 … 2^SF−1.decode(symbols, sf, has_header=1, ldr=0, cr=1, crc=1) -> (bytes, ok)— symbols back to(payload, crc_ok); whenhas_header, the length/CR/CRC-present fields are read from the decoded header, so the caller'scr/crchints 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 vialdro(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-
ppmgeometry (SF−2codewords per block), regardless of the payload's coding rate or the LDRO setting. A receiver can therefore always decode the header knowing onlySF, 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/÷ 4you will see in the Gray stage and theSF − 2·ldryou 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 circshift ≡ numpy.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:
- Build an
ncw × rddbit matrix,tmp[r][j] = (codeword_r >> j) & 1(bitj= thej-th LSB, "right-msb"). - For each output symbol
x = 1 … rdd: take columnx−1(a vector ofncwbits), rotate it left byx − 1—np.roll(col, 1 − x), i.e. MATLABcircshift(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:
- Expand each of the
rddsymbols toppmbits, MSB-first (_de2bi_lmsb). - Rotate row
xleft byx − 1(np.roll(row, 1 − x)). - Transpose (
zip(*rows)) toppm × 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 (payload010203040506070809,ok=True). - Encode (
test_encode_matches_header_payload_crc_region): re-encoding01…09reproduces the header + payload + CRC region symbol-for-symbol —sym[:16] == REF_SYMBOLS[:16]. Only the trailing block differs, because it carries transmitter-specific0xFFdon'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 thensf,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_decodecorrects only whenrdd ∈ {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
- 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
- Semtech — LoRa Modulation Basics (AN1200.22) and the SX127x / SX126x datasheets: chirp spread spectrum, spreading factor, coding rate, explicit vs implicit header, low-data-rate optimization.
- 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.
- In-repo:
app/radio/lorasdr/phy.py,app/radio/lorasdr/_tables.py,test_lorasdr.py; companion specslora-modem.md,meshtastic.md, and the depth/tone template../rvqvoice.md.