Skip to content

LoRa in Rafe — two separate implementations

Rafe has two independent LoRa implementations, deliberately kept apart:

Audio-band CSS Real (Semtech) LoRa
Module app/radio/loracss.py app/radio/lorasdr/
Path IC-705 SSB audio (300–3000 Hz) LimeSDR wideband IQ (UHF)
Bandwidth ~1.5 kHz (audio) 125–500 kHz
Interoperable with real LoRa? No (CSS family only) Yes (Semtech PHY)
In the codec SDK / mode picker? Yes (loracss*) No (SDR subsystem, like AIS/satdump)
Status complete, in use PHY + TX/RX validated in loopback; on-air pending

They share only the idea of chirp spread spectrum. Do not confuse them.

1. Audio-band CSS (loracss.py)

A narrowband CSS mode that fits an SSB passband — run it through the IC-705 like any other keyboard mode. Whitening + Hamming(8,4) FEC + interleaving + CRC-8; selectable SF/BW sub-modes (loracss-fast/loracss/loracss-robust). See docs/codec-sdk.md. Not Semtech-compatible.

2. Real LoRa on the LimeSDR (app/radio/lorasdr/)

The Semtech-interoperable transceiver. Real LoRa is ≥125 kHz in the UHF ISM bands, so it cannot go through the radio's SSB path — it taps the LimeSDR Mini directly.

RX:  SoapySDR IQ  ->  resample to 2*BW  ->  chirp demod  ->  symbols  ->  PHY decode  ->  payload
TX:  payload  ->  PHY encode  ->  chirp modulate  ->  resample to samp_rate  ->  SoapySDR IQ
  • phy.py — the symbol↔byte codec: Gray, diagonal interleaver, Hamming (CR 4/5–4/8), whitening, explicit header + GF(2) checksum, CRC-16, LDRO. Validated interop-correct against jkadbear/LoRaPHY's known-answer vector (a real SF12/BW125k frame decodes to payload 01..09, CRC BA2E) and round-trips every SF7–12 × CR4/5–4/8.
  • modem.py — chirp IQ modulate + demod. Frame = preamble + sync word + 2.25 down-chirp SFD + data. The sync word and preamble length are parameters (Meshtastic 0x2b/16, private 0x12/8, …). Matched-filter frame sync + dechirp/folded-FFT symbols. Full baseband loopback passes every SF/BW/CR to −3 dB.
  • sdr.py — RX: LoRaReceiver (threaded SoapySDR RX), LoRaFramer (streaming glue), decode_iq_file() (offline). TX: build_tx_iq() (payload → IQ), LoRaTransmitter (SoapySDR TX stream), send_meshtastic(). Plus a CLI covering both (--tx).

Validation status

  • ✅ PHY: interop-correct (known-answer vector + exhaustive round-trip).
  • ✅ Baseband loopback: encode→chirp→demod→decode across all SF/BW/CR, to ~−3 dB.
  • ✅ Offline SDR chain: cf32/cs16 capture → resample → decode across SF/CR.
  • ✅ TX: build_tx_iq → framer decode across all SF/BW/CR; full Meshtastic TX→RX with the real 0x2b sync word; sync-word isolation (a frame sent with one sync word won't decode under another). SoapySDR TX plumbing is prod-testable but hasn't keyed a radio — see docs/lora-open-issues.md §D/§F.
  • CFO/STO sync: dechirp-run preamble detection (CFO-immune), fractional CFO from preamble phase drift, sub-bin up/down-chirp CFO/STO split with the N/2 grid ambiguity arbitrated by sync-word peak quality. Validated to ±22% of BW carrier offset (±28 kHz at BW125k — beyond ±30 ppm crystals at 868 MHz) at arbitrary sample offsets, incl. a 100-case randomised torture run. Remaining refinement: SFO drift compensation over very long max-length SF11/SF12 frames.
  • On-air interop: definitive test is the prod LimeSDR + a real LoRa/ Meshtastic node. Not yet done.

Testing on prod

Offline, against a recorded capture (no node needed to exercise the chain):

# record with any SoapySDR tool, then:
python -m app.radio.lorasdr.sdr --file capture.cf32 --sf 7 --bw 125e3 --samp 500e3

Live off-air (needs a LoRa transmitter in range; tune close to the channel):

# EU 868.1 MHz, SF7, BW125k, CR4/5
python -m app.radio.lorasdr.sdr --freq 868.1e6 --sf 7 --bw 125e3 --samp 500e3 --cr 1
# US 915 MHz / 70cm 433.175 MHz likewise

Meshtastic uses region presets around 868/915/433 MHz with specific SF/BW/CR per preset — match --sf/--bw/--cr to the preset to decode it.

Transmit (loopback-validated, not yet keyed on a real radio — see docs/lora-open-issues.md §D/§F for the on-air plan and RF-power notes):

# raw LoRa frame
python -m app.radio.lorasdr.sdr --tx "hello" --freq 868.1e6 --sf 7 --bw 125e3 --sync 0x12
# Meshtastic text on the public LongFast channel (auto 0x2b sync + preamble 16)
python -m app.radio.lorasdr.sdr --tx "de M0SUP" --meshtastic --preset LongFast \
    --freq <derived-freq> --from-node 0xdecafbad

3. Meshtastic decoder (lorasdr/meshtastic.py)

Meshtastic is LoRa-PHY with its own thin app layer — and it's what's actually on the air in the hobbyist bands, so this decodes it on top of the PHY:

LoRa payload bytes -> 16-byte mesh header -> AES-CTR decrypt -> Data protobuf
    -> portnum + application data (text / position / node-info / ...)
  • Header: to / from / id / flags (hop limit, want-ack) / channel-hash.
  • Crypto: AES-CTR with the channel key; nonce = packetId(8 LE) + fromNode(4 LE). The public LongFast default key is built in, so public mesh traffic is readable; supply your own PSKs for private channels. AES is pure Python (no dependency) and verified against the FIPS-197 known-answer vectors.
  • App decode: Data protobuf is hand-parsed; text messages become strings, positions become lat/lon, node-info becomes names. Other ports are identified by name.
  • Wrong keys are rejected by the channel-hash hint (as the firmware does), not by guessing at garbage.

Constants (default key d4f1bb3a…6901, nonce layout, header, port numbers) transcribed from the Meshtastic firmware (CryptoEngine / Channels) + protobufs.

Turn it on in the SDR path with --meshtastic and a modem preset:

# decode public Meshtastic on the EU LongFast channel:
python -m app.radio.lorasdr.sdr --freq 869.525e6 --preset LongFast --meshtastic
# a private channel: --psk <index>  (or extend to pass a raw key)

Validated: FIPS-197 AES, text/position round-trips, and a full IQ → LoRa → Meshtastic-text decode (with carrier offset). Presets: ShortTurbo/Fast/Slow, MediumFast/Slow, LongFast (default)/Moderate/Slow. Remaining interop caveat: the AES-CTR counter-increment for payloads > 16 bytes uses the NIST standard (big-endian +1) — correct for single-block messages regardless; confirm on a real multi-block capture.

Licensing

The LoRa PHY constants (whitening table, header checksum matrix) are transcribed as protocol facts from jkadbear/LoRaPHY (MIT); the Meshtastic constants from the Meshtastic firmware (GPL-3) and protobufs. Credited in-source.