Decoding my own LoRa node the hard way
An ESP32 sends humidity over an EBYTE LoRa module. Reading it back off the air with a HackRF took two days longer than it should have — because one correct measurement answered a question I hadn't asked.
There is an ESP32 on my bench with an EBYTE E32-900T20D bolted to it, sending a humidity reading every second or so. I know what it says, because I wrote the firmware. The question I actually wanted to answer was different: could I read it off the air with a HackRF One, treating the transmitter as a black box?
Not "can I receive LoRa" — gr-lora_sdr exists and works. The interesting part
is that the E32 datasheet tells you almost nothing useful. It advertises an
"air data rate" in kbps and a channel number. It does not tell you the spreading
factor, the bandwidth, the sync word, or what the module does to your bytes on
the way out. All of that had to come off the air.
It took considerably longer than I expected, and most of the time went into one wrong assumption that looked right for two days.

The case is all the PortaPack contributes here — nothing on that screen decodes anything. The samples leave over the USB cable, and every step below happens in GNU Radio at the other end of it.
The node sits a metre away on the same desk, which matters more than it sounds. At that range the problem is never a weak signal, so every mistake in this entry was made with signal to spare.
What LoRa actually looks like
LoRa is chirp spread spectrum. A symbol is a linear frequency sweep across the channel — start somewhere in the band, ramp upward at a fixed rate, wrap around when you hit the top, and stop when you have swept the full bandwidth once. The data is not in the sweep. The data is in where the sweep starts.
Record it and the structure is visible with no decoding at all:

The preamble is a run of identical up-chirps, which is what a receiver uses to find the packet and lock timing. Then the sweep direction inverts for two and a quarter symbols — the frame delimiter. Everything after that is payload, and you can see the symbols starting at different heights. That staircase is the data.
The wrong turn
To demodulate you need two numbers: the bandwidth BW and the spreading factor
SF, which together fix the symbol time as Tsym = 2^SF / BW.
The obvious move is to measure the chirp slope. I did, got 122 MHz/s, matched it against the plausible configurations, and landed on SF9 with 250 kHz bandwidth. Everything downstream half-worked in the way that keeps you going: the de-chirp correlation showed a real peak, the packet detector fired. Nothing decoded, but it never looked like the modulation was wrong.
It was wrong. The slope of a LoRa chirp is BW / Tsym, which expands to
BW² / 2^SF. Double the bandwidth and raise the spreading factor by two and the
slope is identical:
- SF9, BW 250 kHz → 250 kHz / 2.048 ms = 122 MHz/s
- SF11, BW 500 kHz → 500 kHz / 4.096 ms = 122 MHz/s
Slope alone cannot separate them. It never could.
So I stopped fitting slopes and tracked the peak frequency frame by frame across the preamble, which turns the chirp into a sawtooth you can read directly:

Height 501 kHz, period 4.11 ms, so 2^SF = 500 kHz × 4.096 ms = 2048 and
SF = 11. Two independent checks agreed:
- De-chirp sharpness. Resampled to twice the bandwidth and correlated against each hypothesis, the peak-to-average ratio was 231 for SF11/500k and 115 for SF9/250k — the lower figure being a half-band artefact of throwing away signal the 250 kHz assumption never asked for.
- Bit rate.
SF × BW / 2^SF= 11 × 500000 / 2048 = 2686 bps, which is the E32's "2.4 kbps" air-rate setting. SF9/250k would have implied 4.4 kbps — a rate the module offers as "4.8k" and which I had not configured.
Some external research I was handed said SF9 with 125 kHz. That was extrapolation from a datasheet anchor with the bandwidth held fixed, and a direct slope measurement rules it out immediately: SF9/BW125 sweeps at 30.5 MHz/s, a quarter of what the radio was doing.
Three things that all had to be right at once
With SF11/BW500k in hand the demodulator locked, read the header, and produced garbage. The header itself was clean and worth quoting, because it settled a second wrong belief — that EBYTE wrapped the payload in something proprietary:
Header checksum VALID!
Payload length: 32
Coding rate: 1 (4/5)
CRC presence: 1
Standard explicit-header LoRa. No custom framing at the PHY at all. Three separate things were corrupting the payload, and any one of them left it unreadable, which is exactly why it took so long — fixing two of three looks identical to fixing none.
Low Data Rate Optimisation had to be on. LDRO is normally enabled when the symbol time exceeds 16 ms; here it is 4 ms, so every "auto" setting turned it off. The E32 transmits with it on regardless. With the receiver disagreeing, the whole payload is systematically scrambled — not noisy, scrambled, which reads like a decryption problem rather than a configuration one.
The sync word had to be skipped. This one cost me the most hours for the
least insight. LoRa's default sync word is 0x12, and frame_sync will reject
any preamble that does not carry it. The E32 uses its own network ID, which does
not land on 0x12 or 0x34. The symptom is brutal: a textbook-clean capture,
correlation peak of 248, and zero packet locks — the block silently declining
every packet. Setting the sync word to [0] disables the check and it locks
immediately.
Clock offset had to be corrected. The HackRF and the E32 do not share a reference, and a ~15 ppm difference is nothing at the start of a packet and fatal 256 ms into one. The tell was that errors clustered at the end of every payload while the first bytes came through fine. A fractional resampler ahead of the demodulator fixes it:
# ppm is negative here: the capture runs slightly fast relative to the sender.
grfilter.mmse_resampler_cc(0, 1 / (1 + ppm * 1e-6))
Anywhere between −10 and −20 ppm gave 9 packets out of 9 with zero bit errors. Left uncorrected, the tail of every packet was noise.
None of the three is the kind of thing you find once and keep in your head, so all three became switches in the browser front end I ended up putting on the flowgraph — behind one preset that sets them the way the E32 wants them:

I also spent a while convinced the signal had to be moved off DC to dodge the HackRF's centre spike. It didn't. Once the three fixes above were in, the packet decodes perfectly with the channel sitting right on DC — LoRa spreads across 500 kHz and simply does not care about a notch a few kHz wide. That detour came from debugging with LDRO and the clock offset still broken and blaming the nearest visible artefact.
The layer EBYTE adds on top
Clean CRCs, and the 32-byte payload still wasn't text. This part is EBYTE's own doing, sitting above standard LoRa:
payload = [ 8-byte header ][ 2 bytes per input byte ][ 4-byte footer ]
Every byte you hand the module goes out as two. My first guess was a repeating
XOR mask — I derived e4 eb from a constant BBBB test string, and it was
wrong, because a constant input cannot distinguish a mask from a code table.
The fix was to stop guessing and generate known data. I flashed a calibration
sketch that transmits every value from 0x00 to 0xFF as a run of n × 0x11
constants, recorded a full sweep, and read the mapping straight off. Each input
byte splits into two nibbles; the even position carries the high nibble XORed
with 0x33, the odd position carries the low nibble, and both go through one
16-entry code table:
| code | value | code | value | code | value | code | value |
|---|---|---|---|---|---|---|---|
a5 | 0 | 95 | 4 | 65 | 8 | 55 | 12 |
a6 | 1 | 96 | 5 | 66 | 9 | 56 | 13 |
a9 | 2 | 99 | 6 | 69 | 10 | 59 | 14 |
aa | 3 | 9a | 7 | 6a | 11 | 5a | 15 |
hi = INVG[even ^ 0x33]
lo = INVG[odd]
byte = (hi << 4) | lo
A code byte outside the table means a corrupted frame, which turns the table into a free integrity check on top of the CRC — bad frames get dropped rather than printed as noise.
One last practical snag: crc_verif publishes an empty PDU when the CRC
fails, so a failing packet is indistinguishable from no packet. To show packets
either way I read the de-whitened byte stream directly and used the
frame_info tags — emitted at every frame start with offset, payload length,
coding rate and CRC flag — to cut it into frames.
Where it ended up
$ humidity:41.2
$ humidity:41.3
$ humidity:40.9
Which is exactly what the ESP32 was printing to its serial port all along, now
arriving through 868 MHz, a HackRF, and a receive chain of
frame_sync → fft_demod → gray_mapping → deinterleaver → hamming_dec → header_decoder → dewhitening → crc_verif, with the EBYTE nibble decode bolted
on the end.

- Modulation
- SF11 / 500k
- Chirp slope
- 122 MHz/s
- Clock offset
- −15 ppm
- Clean packets
- 9 / 9
The lesson I'd keep is the first one. I had a measurement — 122 MHz/s — that was completely correct and that I read as an answer when it was only a constraint. Two configurations satisfied it, and I never checked whether anything else did, because the number had come out clean and matching a number feels like knowing. The fix wasn't a better measurement. It was measuring a second, independent quantity and letting the two intersect.
The rest of it — LDRO, sync word, clock drift — is the ordinary tax on talking to hardware that was never designed to be listened in on. Worth paying once, and worth writing down so I don't pay it again.