Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

Decode a LoRaWAN payload

A field sensor sends five bytes, and a model writes the JavaScript decoder for The Things Network. A Node test feeds it known bytes, a frost reading and a set flag included, and finds the sign bug that the example in the datasheet never shows.

Intermediate · 35 min

The idea

A LoRaWAN sensor sends a few bytes, a few times an hour: radio time is precious, so there is no JSON and no text. The network server turns the bytes back into values with a small JavaScript function, decodeUplink(), which you write. The Things Network calls it a payload formatter.

A wrong decoder gives no error. The bytes arrive, the function runs, and the dashboard shows -10 °C as 6543.6 °C, or an empty field because a name is spelled differently. So:

  1. the model writes decodeUplink() from the payload format and three requirements;
  2. a test in Node feeds it bytes you chose, with values you worked out by hand;
  3. the test says which values are wrong, before the decoder goes anywhere near the network.

What you need

Node.js 22 or newer, the openai package and a key, as in Your first API call in JavaScript. No sensor and no account on The Things Network: everything runs on your PC.

The payload format

The FN-7 is an invented field node, the kind you put in a garden or on a roof. Save its format as payload.txt:

FN-7 field node: uplink payload, fPort 2, always 5 bytes

Byte  Field        Type                          Meaning
0     battery      unsigned 8-bit                battery level in %, 0-100
1-2   temperature  signed 16-bit, byte 1 first   temperature in °C x 10
3     humidity     unsigned 8-bit                relative humidity in % x 2
4     flags        8 bits                        bit 0: the button was pressed
                                                 bit 1: tamper, the case was opened
                                                 bit 2: low battery
                                                 bits 3-7: reserved, always 0

Example: 5A 00 E7 7B 00 is battery 90 %, 23.1 °C, 61.5 %, no flags.

The request

Save it as request.txt:

Write a payload decoder for The Things Network, in JavaScript: a function decodeUplink(input), where input.bytes is an array of numbers 0-255 and input.fPort is the port. It must:
1. return { data: { battery, temperature, humidity, button, tamper, low_battery } }, with numbers for the first three and true or false for the flags;
2. return { errors: ["..."] }, with no data, when fPort is not 2 or the payload is not 5 bytes long;
3. follow the payload format below exactly.
Only the code: plain JavaScript, no imports, no exports.

1. The decoder

Save it as write_decoder.mjs, next to the two text files:

import { readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.ai.itsincom.org/v1",
  apiKey: process.env.API_KEY,
});

const model = process.argv[2] ?? "qwen3-coder-30b";
const request = readFileSync("request.txt", "utf8") + "\n" + readFileSync("payload.txt", "utf8");

const answer = await client.chat.completions.create({
  model,
  temperature: 0,
  messages: [{ role: "user", content: request }],
});
if (answer.choices[0].finish_reason !== "stop") {
  throw new Error("The answer was cut off: run it again.");
}
const text = answer.choices[0].message.content;

// The model puts the code between ``` lines, sometimes with words around it: keep the code.
const code = text.match(/```[a-z]*\n([\s\S]*?)```/);
writeFileSync("decoder.js", code ? code[1] : text.trim() + "\n");
console.log(`decoder.js from ${model}: ${readFileSync("decoder.js", "utf8").split("\n").length - 1} lines`);
node write_decoder.mjs
decoder.js from qwen3-coder-30b: 37 lines

2. The test

The Things Network runs decodeUplink() on its own: no import, no export. The test does the same with node:vm, which runs the file as a plain script and hands back the function. Save it as test_decoder.mjs:

import { readFileSync } from "node:fs";
import vm from "node:vm";

// The Things Network runs a decoder on its own, with no modules and no exports: so do we.
const file = process.argv[2] ?? "decoder.js";
const decodeUplink = vm.runInNewContext(readFileSync(file, "utf8") + "\n;decodeUplink");

// Known bytes, and the values worked out by hand from the payload format.
const good = [
  ["the example", "5A 00 E7 7B 00", { battery: 90, temperature: 23.1, humidity: 61.5 }, []],
  ["frost", "40 FF 9C B4 00", { battery: 64, temperature: -10, humidity: 90 }, []],
  ["just below zero", "64 FF FF 00 01", { battery: 100, temperature: -0.1, humidity: 0 }, ["button"]],
  ["case opened", "0F 00 05 50 06", { battery: 15, temperature: 0.5, humidity: 40 }, ["tamper", "low_battery"]],
];
const bad = [
  ["too short", 2, "5A 00"],
  ["too long", 2, "5A 00 E7 7B 00 00"],
  ["another port", 1, "5A 00 E7 7B 00"],
];
const FLAGS = ["button", "tamper", "low_battery"];
let failed = 0;

function report(ok, what, detail) {
  if (!ok) failed++;
  console.log(`${ok ? "ok  " : "FAIL"}  ${what}: ${detail}`);
}

function decode(fPort, hex) {
  const bytes = hex.split(" ").map((b) => parseInt(b, 16));
  try {
    return decodeUplink({ bytes, fPort });
  } catch (e) {
    return { thrown: `${e.name}: ${e.message}` };
  }
}

for (const [name, hex, numbers, set] of good) {
  const result = decode(2, hex);
  const data = result.data ?? {};
  const wrong = [];
  for (const [key, value] of Object.entries(numbers)) {
    if (typeof data[key] !== "number" || Math.abs(data[key] - value) > 0.001) wrong.push(`${key} ${data[key]}, not ${value}`);
  }
  for (const key of FLAGS) {
    if (data[key] !== set.includes(key)) wrong.push(`${key} ${data[key]}, not ${set.includes(key)}`);
  }
  report(wrong.length === 0, `${name.padEnd(15)} ${hex}`, result.thrown ?? (wrong.join("; ") || JSON.stringify(data)));
}

for (const [name, fPort, hex] of bad) {
  const result = decode(fPort, hex);
  const ok = Array.isArray(result.errors) && result.errors.length > 0 && result.data === undefined;
  report(ok, `${name.padEnd(15)} port ${fPort}, ${hex}`, result.thrown ?? JSON.stringify(result));
}

console.log(`${failed} failed`);
process.exitCode = failed ? 1 : 0;
node test_decoder.mjs

What you should see

With the decoder of qwen3-coder-30b, in five of our six runs:

FAIL  the example     5A 00 E7 7B 00: low_battery undefined, not false
FAIL  frost           40 FF 9C B4 00: temperature 6543.6, not -10; low_battery undefined, not false
FAIL  just below zero 64 FF FF 00 01: temperature 6553.5, not -0.1; low_battery undefined, not false
FAIL  case opened     0F 00 05 50 06: low_battery undefined, not true
ok    too short       port 2, 5A 00: {"errors":["Invalid fPort or payload length"]}
ok    too long        port 2, 5A 00 E7 7B 00 00: {"errors":["Invalid fPort or payload length"]}
ok    another port    port 1, 5A 00 E7 7B 00: {"errors":["Invalid fPort or payload length"]}
4 failed

Two bugs, in 37 lines. Here they are, in decoder.js:

  // Extract temperature (signed 16-bit, big-endian)
  const tempBytes = [bytes[1], bytes[2]];
  const temperature = (tempBytes[0] << 8) | tempBytes[1];
  // Convert from fixed-point to decimal (x10)
  const temperatureDecimal = temperature / 10.0;

Look at the first line of the test. With the example from the payload format, the temperature is right: 23.1 °C is positive, and positive values need no sign. A decoder tested only on the example looks correct.

The other model

node write_decoder.mjs qwen3.8-27b
node test_decoder.mjs
ok    the example     5A 00 E7 7B 00: {"battery":90,"temperature":23.1,"humidity":61.5,"button":false,"tamper":false,"low_battery":false}
ok    frost           40 FF 9C B4 00: {"battery":64,"temperature":-10,"humidity":90,"button":false,"tamper":false,"low_battery":false}
ok    just below zero 64 FF FF 00 01: {"battery":100,"temperature":-0.1,"humidity":0,"button":true,"tamper":false,"low_battery":false}
ok    case opened     0F 00 05 50 06: {"battery":15,"temperature":0.5,"humidity":40,"button":false,"tamper":true,"low_battery":true}
ok    too short       port 2, 5A 00: {"errors":["payload must be 5 bytes"]}
ok    too long        port 2, 5A 00 E7 7B 00 00: {"errors":["payload must be 5 bytes"]}
ok    another port    port 1, 5A 00 E7 7B 00: {"errors":["fPort must be 2"]}
0 failed

What we measured, with the same request:

Model Runs Passed all 7 Time for one decoder
qwen3-coder-30b 6 1 about 3 s
qwen3.8-27b 5 5 about 10 s

Four of the five failing Coder decoders were the same file, byte for byte: temperature=0 makes a model repeat itself, mistakes included. Qwen3.8 reasons before it answers. It is slower, and here it handled the sign every time, with if (raw & 0x8000) raw -= 0x10000; or the same thing written another way.

Why these bytes

The expected values are worked out by hand from payload.txt, never copied from what a decoder prints.

On The Things Network

In the console of The Things Stack, open your application or your device, then Payload formatters → Uplink, choose the custom JavaScript formatter and paste decoder.js. The console also has a box to test a payload: try the frost one, 40 FF 9C B4 00, there as well. We tested the decoders in Node only, not on the network.

Messages from the network carry the bytes in Base64, in uplink_message.frm_payload. To try a real one in your test:

[...Buffer.from("WgDnewA=", "base64")]   // [90, 0, 231, 123, 0]: the example

Good to know

Exercise

All examples