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_compensationfixes 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
readStreamwill overflow → dropped packets. For live RX, decimate as tightly as the channel allows, and/or profile the hot loop (the per-symbol FFTs). Offlinedecode_iq_filehas no such constraint. - Channel filtering:
_resampledoesn'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_nodebytes are recent; very old firmware used a different layout.
D. On-air test procedure (when the antenna arrives)¶
Receive¶
- Get the exact frequency for your region/preset/channel from the Meshtastic
app (A2). Note the SF/BW of the preset (
--presetsets both). - 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). - Decode the file offline and iterate — fast, repeatable, no real-time pressure:
- 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.
- 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.decodestage. - Only after offline decode works, try live
LoRaReceiverand 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
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.