Skip to content

Real-LoRa (LimeSDR) — open issues & on-air test plan

Everything in the lorasdr stack (RX and TX) is validated at baseband / in loopback and against the reference vectors, but nothing has touched a real radio yet. This is the punch-list for the first on-air session (LimeSDR Mini + a resonant antenna + a real LoRa/Meshtastic node). The two sync-related blockers (A1/A3) are now fixed in code; A2 (derived frequency) and A4 (DC spike) still bite on the first real capture.

Related: docs/lora.md (architecture), app/radio/lorasdr/.


A. Likely blockers

A1. Sync word mismatch ✅ FIXED

The sync word is now a byte parameter on both modulate() and Demodulator (sync_word=), encoded as the two netid chirps (nibble×8). --meshtastic auto-selects Meshtastic's 0x2b on both RX and TX; the CLI --sync 0xNN overrides for raw LoRa. Cross-checked: a frame sent with one sync word does not decode under another (test_tx_sync_word_isolation).

Network Sync word Sync-word symbols (nibble×8)
Meshtastic 0x2b (16, 88)
LoRaWAN (public) 0x34 (24, 32)
Private LoRa default 0x12 (8, 16)

Still verify on-air that Meshtastic's netid chirps land exactly on (16, 88) as we assume — the encoding is transcribed, not yet confirmed against a real frame.

A2. Meshtastic frequency is derived, not fixed (blocker if you guess)

Meshtastic doesn't sit on a round number — the channel frequency is computed from region + preset + a hash of the channel name (firmware RadioInterface.cpp):

numChannels = floor((freqEnd - freqStart) / (spacing + bw))
channel_num = hash(channelName) % numChannels          # default primary uses this
freq        = freqStart + bw/2 + channel_num * bw       # (all in MHz)

Practical path: read the exact frequency off the Meshtastic app (Radio Config → LoRa shows it) or a frequency calculator for your region/preset/channel, and pass it as --freq. Guessing "868.1" will usually miss.

A3. Preamble length ✅ FIXED

preamble_len is now a parameter on modulate()/Demodulator; --meshtastic uses 16 (--preamble N overrides). Re-check the exact SFD/data offset on-air.

A4. DC spike / centre tuning

A LoRa signal tuned to the exact SDR centre frequency sits on the LimeSDR's DC offset spike and IQ-imbalance birdie. Tune the SDR a few tens of kHz off the LoRa centre and software-mix it back to baseband before the demod (or enable the LimeSDR DC/IQ correction). Otherwise the centre bin is corrupted.


B. Robustness refinements — will limit range/rate, not block first decode

  • SFO drift (sampling-freq offset): the node crystal vs the LimeSDR clock differ by ~tens of ppm; over long SF11/SF12 max-length frames the symbol grid drifts and the tail loses sync. The reference's dynamic_compensation fixes this; not ported. Short frames (most Meshtastic traffic) are fine.
  • CFO margin at BW125: validated to ±22% of BW. At BW125 that's ±27 kHz, and ±30 ppm at 868 MHz is ±26 kHz — in range but with little headroom, especially once the LimeSDR's own TCXO error is added. BW250/500 presets have more margin.
  • Multi-block AES-CTR (Meshtastic): the counter increment for payloads > 16 B uses NIST big-endian +1. Single-block messages (short text, position) are correct regardless; confirm the multi-block convention on a real long packet.
  • Real-time throughput: the demod is pure-Python; at 1 MS/s + high SF the framer may not keep up and readStream will overflow → dropped packets. For live RX, decimate as tightly as the channel allows, and/or profile the hot loop (the per-symbol FFTs). Offline decode_iq_file has no such constraint.
  • Channel filtering: _resample doesn't band-limit before decimating; on a busy band, adjacent-channel energy aliases in. Add a channel LPF for real air.
  • Gain / no AGC: fixed --gain; tune for the antenna and signal level.

C. Not handled (by scope) — know these won't work

  • PKC direct messages: newer Meshtastic DMs use Curve25519 + AES-CCM, not the channel AES-CTR. Can't be decoded without the recipient's private key anyway.
  • Implicit-header LoRa: we decode explicit-header only (Meshtastic uses explicit, so fine); implicit needs a known payload length.
  • Non-default channels: the channel-hash filter and key need the channel name + PSK; only the public LongFast default is built in.
  • Telemetry / waypoint / routing / admin: identified by port name but the payloads aren't decoded (only text / position / node-info are).
  • Firmware header drift: the 16-byte header's next_hop/relay_node bytes are recent; very old firmware used a different layout.

D. On-air test procedure (when the antenna arrives)

Receive

  1. Get the exact frequency for your region/preset/channel from the Meshtastic app (A2). Note the SF/BW of the preset (--preset sets both).
  2. Record, don't stream, first: capture raw IQ with a SoapySDR tool (SoapySDRUtil/rx_sdr/GNU Radio) at that freq, a few MS/s, while a node sends periodic beacons or you press "send" on a paired node. Tune ~50 kHz off centre (A4).
  3. Decode the file offline and iterate — fast, repeatable, no real-time pressure:
    python -m app.radio.lorasdr.sdr --file cap.cf32 --preset LongFast \
        --samp <capture_rate> --meshtastic
    
  4. Oracle: keep a real Meshtastic node/app next to you showing the same packets — that's the ground truth to compare decoded text / node IDs against.
  5. Walk the chain if it's silent: preamble detected? → sync word passes? → LoRa CRC ok? → Meshtastic channel-hash matches? → text out. Add debug prints at each Demodulator/meshtastic.decode stage.
  6. Only after offline decode works, try live LoRaReceiver and watch for stream overflows (B, real-time).

Transmit

TX is implemented (build_tx_iq / LoRaTransmitter / send_meshtastic, CLI --tx). Loopback-validated for every SF/BW/CR and a full Meshtastic TX→RX, but never keyed a real radio. When testing:

# raw LoRa frame:
python -m app.radio.lorasdr.sdr --tx "hello" --freq 868.1e6 --sf 7 --bw 125e3 --sync 0x12
# a Meshtastic text to the public LongFast channel (uses 0x2b + preamble 16):
python -m app.radio.lorasdr.sdr --tx "de M0SUP" --meshtastic --preset LongFast \
    --freq <derived-freq> --from-node 0xdecafbad
- First proof: TX from the LimeSDR and confirm a real Meshtastic node shows the message — that closes the interop loop from the other side. - Sanity self-test: TX and RX on two SDRs (or record the TX and decode the file) before trusting on-air. - RF power (see §F): the LimeSDR Mini alone is ~1–10 mW at UHF — enough for a node on the same bench, but a PA is needed for real range. - Regulatory: 868/915/433 have duty-cycle + power limits (EU 868: 1% duty, 25 mW on most sub-bands). Stay legal; a dummy load / short bench link for first tests.


E. Quick-reference facts (transcribed from firmware)

  • Meshtastic sync word 0x2b; preamble 16 symbols; explicit header; CR read from header.
  • Default channel key d4f1bb3a20290759f0bcffabcf4e6901; 1-byte PSK selector (1 = default, N = default with last byte + N-1).
  • Nonce (AES-CTR) = packetId(8 B LE) + fromNode(4 B LE) + 0(4 B).
  • Presets (SF, BW): ShortTurbo 7/500, ShortFast 7/250, ShortSlow 8/250, MediumFast 9/250, MediumSlow 10/250, LongFast 11/250 (default), LongModerate 11/125, LongSlow 12/125. (Region-dependent; verify against the app.)

F. TX RF power (LimeSDR Mini)

  • Native output at UHF is roughly 0 to +10 dBm (~1–10 mW) and rolls off toward the top of the range — well below the ~+20 dBm (100 mW) a typical Meshtastic node runs. Fine for a node on the same bench/room; for real range add a small 868/915 PA (to ~100 mW–1 W) and mind the regional power limit.
  • 100 mW is itself a normal LoRa level with km-scale range — the gap is only that the LimeSDR can't produce it unaided.
  • RX needs no amplifier — LoRa is a weak-signal mode; a resonant antenna is enough. An optional LNA/preamp only helps for distant nodes.