Peter Davies

Ramblings

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"
    }
  ]
}

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

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.