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:
- the model writes
decodeUplink()from the payload format and three requirements; - a test in Node feeds it bytes you chose, with values you worked out by hand;
- 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;
- The sign. The comment says “signed”, the code does not.
FF 9Cis 65436 as an unsigned number, and -100 as a signed one. A 16-bit value is negative when it is 32768 or more, and then you subtract 65536:if (raw > 32767) raw -= 65536;. - The name. The request asks for
low_battery; the decoder returnslowBattery. JavaScript does not mind, and neither does the network. A dashboard that readslow_batterygetsundefinedand shows nothing: a flat battery that nobody hears about.
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
FF 9C, frost: -100, so -10.0 °C. The first negative value.FF FF, just below zero: -1, so -0.1 °C. It catches a quieter mistake:raw - 65535in place ofraw - 65536. We tried it: it turns -10.0 into -9.9, and -0.1 into 0.06, case opened: two flags at once. A decoder that compares the whole byte,flags === 2, misses a set flag when another one is set too.- Too short, too long, another port: a decoder must refuse what it cannot read, not decode garbage.
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
- The example in a datasheet is never enough. It is usually a normal day: positive, no flags. Write the edge cases yourself.
- Field names are a contract. A wrong name breaks nothing loudly: the field is just empty. Test the names as well as the values.
- A comment is not a check. “signed 16-bit” was written right above the bug.
- Keep the test next to the decoder. When the sensor gets a new firmware and a sixth byte, change
payload.txt, ask again, and run the same test.
Exercise
- A second round for Coder: send it its own decoder and the FAIL lines, and ask for a fix. Run the test again.
- Add a case for the warmest value the format can carry,
7F FF. What should it be, and is it a temperature a sensor can really measure? - The FN-7 gets a sixth byte: a counter of uplinks, from 0 to 255. Change
payload.txtand the request, add a case to the test, and ask both models again. - Write
encodeDownlink()for a message that sets the interval between readings in minutes, from 1 to 1440, in two bytes. Then test that decoding what you encoded gives back the same number.
