Decoding LoRaWAN sensor payloads
LoRaWAN sensors send a few bytes at a time, and something has to turn those bytes back into temperatures and battery levels. Every vendor publishes a payload format and usually a sample decoder, but the samples are often long, written for one platform and hard to follow. Once you've seen the pattern it's quicker to write your own.
There are two layers to get through: the radio and LoRaWAN framing, which the gateway and network server handle, and the application payload, which is yours.
What the gateway sees
A gateway running Semtech's packet forwarder sends each received frame to the network server as JSON, in an rxpk array. Here's a made-up one in the same shape:
{
"rxpk": [
{
"tmst": 1234567,
"chan": 2,
"rfch": 1,
"freq": 868.500000,
"stat": 1,
"modu": "LORA",
"datr": "SF7BW125",
"codr": "4/5",
"rssi": -57,
"lsnr": 9.5,
"size": 21,
"data": "QHhWNBKAKgAFobLD1OX2BxgRIjNE"
}
]
}
tmst: the concentrator's internal microsecond counter when the frame finished. It wraps, and is only meaningful to that gateway.freq,chan,rfch: the centre frequency in MHz and which IF channel and RF chain of the concentrator received it.stat: CRC status. 1 is good, -1 failed, 0 no CRC.datr: spreading factor and bandwidth. SF7BW125 is the fastest common EU868 data rate; SF12BW125 the slowest and longest range.codr: the forward error correction coding rate.rssi,lsnr: signal strength in dBm and signal-to-noise ratio in dB. LoRa can still decode below the noise floor, so negative SNR is normal at range.size,data: the frame length in bytes and the frame itself, base64 encoded.
The SX1302-based forwarder adds a few more fields: rssis (the RSSI of the signal rather than the channel), foff (frequency offset in Hz), mid (which modem received it) and jver (the JSON format version).
The LoRaWAN frame
data is the whole LoRaWAN PHYPayload. For a data uplink it's laid out like this, with multi-byte fields little-endian:
MHDR(1) | DevAddr(4) | FCtrl(1) | FCnt(2) | FOpts(0-15) | FPort(1) | FRMPayload | MIC(4)
The top three bits of MHDR give the message type. The low four bits of FCtrl say how many bytes of FOpts (MAC commands) follow. Pulling the header apart in Node:
const MTYPES = ['JoinRequest', 'JoinAccept', 'UnconfirmedDataUp', 'UnconfirmedDataDown',
'ConfirmedDataUp', 'ConfirmedDataDown', 'RejoinRequest', 'Proprietary'];
function parsePhyPayload(b64) {
const b = Buffer.from(b64, 'base64');
const mtype = MTYPES[b[0] >> 5];
if (!mtype.includes('Data')) return { mtype };
const fCtrl = b[5];
const portIndex = 8 + (fCtrl & 0x0f);
const hasPort = b.length - 4 > portIndex;
return {
mtype,
devAddr: b.readUInt32LE(1).toString(16).padStart(8, '0'),
adr: Boolean(fCtrl & 0x80),
fCnt: b.readUInt16LE(6),
fPort: hasPort ? b[portIndex] : null,
frmPayload: hasPort ? b.subarray(portIndex + 1, b.length - 4).toString('hex') : '',
mic: b.subarray(b.length - 4).toString('hex'),
};
}
const rxpk = JSON.parse(process.argv[2]).rxpk[0];
console.log(parsePhyPayload(rxpk.data));
For the example frame above:
{
mtype: 'UnconfirmedDataUp',
devAddr: '12345678',
adr: true,
fCnt: 42,
fPort: 5,
frmPayload: 'a1b2c3d4e5f60718',
mic: '11223344'
}
The FRMPayload is still encrypted with the device's AppSKey at this point, which is why it looks like noise. Only the network server (or the application server, if it holds the key) can decrypt it. Header parsing like this is still useful on its own for checking which devices a gateway is hearing, at what data rate and with what counters.
The application payload
After decryption, the network server hands your decoder the plain FRMPayload bytes and the FPort. Most simple sensors use a type-length-value style format: a type byte, then a fixed number of bytes for that type, repeated. The vendor's documentation gives the table. For this example I've made one up:
| Type | Field | Size | Encoding |
|---|---|---|---|
| 0x10 | temperature | 2 bytes | signed, big-endian, 0.1 °C |
| 0x11 | humidity | 1 byte | unsigned, % |
| 0x20 | battery | 2 bytes | unsigned, big-endian, mV |
So the decrypted payload 10 00 DA 11 28 20 0E 24 is temperature 0x00DA = 218 = 21.8 °C, humidity 0x28 = 40%, battery 0x0E24 = 3620 mV.
The decoder
The Things Stack expects a decodeUplink(input) function. It gets input.bytes (an array of numbers) and input.fPort, and returns { data } on success or { errors } on failure. I keep decoders to plain ES5 so they run in any of the embedded JavaScript engines these platforms use:
var FIELDS = {
0x10: { name: 'temperature', size: 2, read: function (b, i) { return int16(b, i) / 10; } },
0x11: { name: 'humidity', size: 1, read: function (b, i) { return b[i]; } },
0x20: { name: 'battery_mv', size: 2, read: function (b, i) { return uint16(b, i); } }
};
function uint16(b, i) {
return (b[i] << 8) | b[i + 1];
}
function int16(b, i) {
var v = uint16(b, i);
return v & 0x8000 ? v - 0x10000 : v;
}
function decodeUplink(input) {
var bytes = input.bytes;
var data = {};
var i = 0;
while (i < bytes.length) {
var field = FIELDS[bytes[i]];
if (!field) {
return { errors: ['unknown type 0x' + bytes[i].toString(16) + ' at byte ' + i] };
}
if (i + 1 + field.size > bytes.length) {
return { errors: ['truncated ' + field.name + ' at byte ' + i] };
}
data[field.name] = field.read(bytes, i + 1);
i += 1 + field.size;
}
return { data: data };
}
Adding a sensor type is one line in the table. The two checks matter: an unknown type byte means you can't know how many bytes to skip, so stop rather than guess, and a payload cut short shouldn't produce a half-read number.
ChirpStack v3, which I was using, calls a function with a different signature instead, Decode(fPort, bytes, variables), returning the object directly. A wrapper keeps one decoder for both:
function Decode(fPort, bytes, variables) {
return decodeUplink({ fPort: fPort, bytes: bytes }).data;
}
To test without a network server, feed it bytes from a base64 string in Node:
var bytes = Array.from(Buffer.from('EADaESggDiQ=', 'base64'));
console.log(JSON.stringify(decodeUplink({ fPort: 5, bytes: bytes })));
{"data":{"temperature":21.8,"humidity":40,"battery_mv":3620}}
What to watch for
- Byte order. LoRaWAN headers are little-endian, but most sensor payloads are big-endian. Check the vendor's table rather than assuming.
- Signed values. Temperatures go negative; read them as two's complement or -0.1 °C becomes 6553.5.
- FPort. Many devices use different ports for readings, configuration replies and status. Switch on
input.fPortbefore decoding. - Keep the raw bytes. Store the payload alongside the decoded values so you can re-decode history if you find a bug.
Today: ChirpStack v4 uses the same decodeUplink(input) interface, so the wrapper is only needed on v3.
References: the Semtech packet forwarder protocol for the rxpk fields, and The Things Stack's uplink decoder documentation for the decodeUplink interface.