All lab entries

Lab note

LOG-002SDR

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.

11 min read
  • SDR
  • LoRa
  • GNU Radio
  • HackRF
  • Python
  • DSP

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.

A telescopic whip with a loading coil standing on a desk lit red, and below it a HackRF One in a PortaPack case showing its idle menu, with a USB cable running out of the side
The listening end: a HackRF One in a PortaPack case, a telescopic whip, and a USB cable to the machine that does the actual work.

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:

Spectrogram of a LoRa packet showing eight identical rising chirps, then a group of falling chirps, then chirps starting at varying offsets
One E32 packet at 867.99 MHz, recorded at 2.4 Msps. Left: the preamble, every chirp identical. Middle (dotted): 2¼ down-chirps marking the frame delimiter. Right: data symbols — same ramp, different starting points.

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:

Left panel: measured peak frequency forming a sawtooth with least-squares fits, annotated 122 MHz per second, 4.11 millisecond period, 501 kHz height. Right panel: two ideal sawtooths with identical slope but different heights.
A: the preamble's peak frequency, measured from the raw IQ. Slope 122 MHz/s, period 4.11 ms, height 501 kHz → 2^SF = 2059 ≈ 2048 → SF11. B: why the slope was never going to decide it — SF9/250k has exactly the same ramp, half as tall.

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:

A web SDR screenshot: a left panel of LoRa settings with spreading factor 11, bandwidth 500k, coding rate 4/5, sync word Auto (E32), LDRO On (E32) and clock correction −15 all selected, beside a live spectrum trace and a blue waterfall tuned to 868 MHz
The receiver with the E32 preset applied — SF11, 500 kHz, CR 4/5, sync word on Auto, which is the check switched off; LDRO forced on; clock correction −15 ppm. Right: 868 MHz live, each horizontal dash in the waterfall a chirp caught inside a single row of it.

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:

codevaluecodevaluecodevaluecodevalue
a509546585512
a619656695613
a9299669105914
aa39a76a115a15
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.

A list of received LoRa packets, each row showing an arrival time, the decoded text humidity:84.4,battery:100, a byte count of 25, and the same message as a row of hex bytes
The packet list off the same receiver. One row per message: the decoded text, the same bytes in hex under it — 68 75 6d 69 64 69 74 79 3a is humidity: — and the time it landed. The green dot means the row parsed as a reading rather than as something unrecognised.
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.