Free tool
decoder-check — run a vendor's payload decoder before a station does
decoder-check is a free checker for the JavaScript a LoRaWAN device vendor ships with a sensor. Give it the decoder and a frame or two and it runs them in a sealed sandbox, then reports what a building management system would actually have received: the frames that throw instead of returning an error, the field that is a number one uplink and a string the next, the value with its unit glued onto it, and the key a station cannot use as a point name.
- LoRaWAN
- TTN v3 / ChirpStack
- Sandboxed
- Node 18
- No dependencies
- Read-only
Install and run
There is no install. One JavaScript file, Node 18 or newer, and nothing from npm
— no package.json, no node_modules, nothing to add to a
lock file at a client who audits them. It was written and run here on Node 20,
which is the only version it has actually been executed on; the 18 floor comes from
the features it uses rather than from running it there.
# download it next to the decoder you were given
curl -O https://plantroomlabs.com/tools/decoder-check.js
# one frame, on the fPort the device actually uses
node decoder-check.js run vendor-decoder.js --payload 0FA064641103E80A2800 --port 1
# a file of frames you captured off the network server
node decoder-check.js run vendor-decoder.js --frames uplinks.txt
# also try the frames the device will send you on a bad day
node decoder-check.js run vendor-decoder.js --payload 0FA064641103E80A2800 --fuzz
# for a script, or to keep the result next to the commissioning record
node decoder-check.js run vendor-decoder.js --frames uplinks.txt --json > report.json
It finds the entry point itself: decodeUplink as The Things Network
v3 defines it, or the older Decoder and Decode that most
ChirpStack and TTN v2 files still export. --self-check runs the shipped
fixtures and the sandbox proofs and exits without touching anything of yours, which is
the cheap way to satisfy yourself that the file does what this page says.
What it checks
Ten things, each of which has cost somebody a commissioning day. They are not style rules — every one of them shows up at the station as a point that is missing, a point that is wrong, or a history you cannot use.
| Check | What it means at the station |
|---|---|
| Crash on a short frame | The decoder throws instead of returning an error, so a truncated uplink takes out the decode path rather than one value. |
| Non-deterministic output | The same bytes decode to different output twice — usually a timestamp taken from the clock inside the decoder, which makes every uplink look changed. |
| Shape drift | Fields appear and disappear between uplinks, so points exist only after the frame that carries them and a discovery run gets a different list each time. |
| Type drift | One field is a number on one uplink and a string on the next. The point was made numeric on the first sample and refuses the second. |
| Unit inside the value | "21.5 °C" instead of 21.5. It becomes a string point,
it will not trend, and it will not drive anything. Heuristic. |
| Non-finite and null numerics | Infinity, NaN or null where a number was
promised. A station cannot store them; it refuses the update or holds the last
value, which reads as a working point that stopped moving. |
| Duplicate declarations | The same function declared twice in one scope. The second wins, silently, and the scaling you read in the first is dead code. Heuristic. |
| Silent accept, silent reject | Rubbish decodes to a clean-looking object, or a valid frame returns nothing at all with no error to log. |
| Key names a station cannot use | Spaces, slashes, a leading digit. Niagara escapes them in the point name, so the point does not read back under the name the decoder produced. Heuristic. |
| fPort sensitivity | The same bytes decode differently per fPort, or the decoder ignores the port entirely — both matter when a device changes port between firmware versions. |
Three of the ten are marked heuristic on the page and in the tool's own output, because they read the source or guess at intent rather than observe behaviour. A heuristic finding is a thing to look at, not a defect established.
What a run looks like
Below is real output, on a decoder written for this page rather than a vendor's. It is a plausible temperature, humidity and pressure sensor carrying the defects this tool exists to find. Everything on the rest of this page came out of the same run.
/* ACME TH-100 temperature / humidity / pressure sensor
* Payload decoder for The Things Network v3
*/
function celsius(raw) {
return ((raw / 100) - 40);
}
function decodeUplink(input) {
var b = input.bytes;
var out = {};
out["Supply Air Temp"] = celsius(b[0] << 8 | b[1]).toFixed(2) + " °C";
out["relativeHumidity"] = (b[2] * 0.5).toFixed(1) + " %RH";
out["ahu/1/setpoint"] = b[3] / 2;
out["2ndStageEnable"] = (b[4] & 0x01) === 1;
out.barometricPressure = (b[5] << 8 | b[6]) / (b[7] - 10);
out.batteryVolts = b[8] === 0 ? "not fitted" : b[8] / 10;
out.deviceName = NAMES[b[9]].trim();
return { data: out };
}
var NAMES = ["TH-100", "TH-100L", "TH-200"];
/* left over from the 0.9 firmware, which scaled by 10 */
function celsius(raw) {
return ((raw / 10) - 40);
}
Two frames, on fPort 1, exactly as a network server would hand them over:
$ node decoder-check.js run sample-decoder.js --payload 0FA064641103E80A2800 --payload 0FA06464110BB80A0001 --port 1
# decoder-check: /tmp/decoders/sample-decoder.js
Entry point decodeUplink(input) (TTN v3, found on the global object)
Decoder 797 bytes
Frames 2 supplied
Per call fresh vm context, 2000 ms timeout, recvTime fixed at 2026-01-01T00:00:00.000Z
Sandbox no require, no process, no fs, no network, no timers; console captured, not printed
## frames
| Frame | Port | Bytes | Outcome | Points | Console |
|---|---|---|---|---|---|
| f1 | 1 | 10 | decoded | 7 | - |
| f2 | 1 | 10 | decoded | 7 | - |
Both frames decoded, seven points each, no error and nothing in the console. On a commissioning day that is where the checking usually stops. Here is what was underneath:
## findings
### 1. crash on a short frame — 2
| Frame | What | Evidence |
|---|---|---|
| f1 | throws instead of returning an error at 10 of 10 shorter lengths (bytes: 9, 8, 7, 6, 5, 4, ...) | TypeError: Cannot read properties of undefined (reading 'trim') |
| f2 | throws instead of returning an error at 10 of 10 shorter lengths (bytes: 9, 8, 7, 6, 5, 4, ...) | TypeError: Cannot read properties of undefined (reading 'trim') |
### 4. type drift — 1
| Frame | What | Evidence |
|---|---|---|
| f1, f2 | 'batteryVolts' changes type across frames: number / string | number (f1: 4); string (f2: "not fitted") |
### 5. unit inside the value [heuristic] — 4
| Frame | What | Evidence |
|---|---|---|
| f1 | 'Supply Air Temp' is a string holding a number and a unit; it should have been a float (360.00) with the unit set on the point | "360.00 °C" |
| f1 | 'relativeHumidity' is a string holding a number and a unit; it should have been a float (50.0) with the unit set on the point | "50.0 %RH" |
| f2 | 'Supply Air Temp' is a string holding a number and a unit; it should have been a float (360.00) with the unit set on the point | "360.00 °C" |
| f2 | 'relativeHumidity' is a string holding a number and a unit; it should have been a float (50.0) with the unit set on the point | "50.0 %RH" |
### 6. non-finite and null numerics — 2
| Frame | What | Evidence |
|---|---|---|
| f1 | 'barometricPressure' is Infinity; a station cannot store it and will either refuse the update or hold the previous value | Infinity |
| f2 | 'barometricPressure' is Infinity; a station cannot store it and will either refuse the update or hold the previous value | Infinity |
### 7. duplicate declarations [heuristic] — 1
| Frame | What | Evidence |
|---|---|---|
| (source) | function 'celsius' is declared twice in the same scope, at lines 4 and 26; the second wins and the first is dead code | lines 4 and 26 |
### 9. key names a station cannot use as-is [heuristic] — 3
| Frame | What | Evidence |
|---|---|---|
| f1 | key 'Supply Air Temp' contains a space; Niagara escapes it in the point name, so the point does not read back under the name the … | 'Supply Air Temp' in 'Supply Air Temp' |
| f1 | key 'ahu/1/setpoint' contains '/'; Niagara escapes it in the point name, so the point does not read back under the name the decod… | 'ahu/1/setpoint' in 'ahu/1/setpoint' |
| f1 | key '2ndStageEnable' starts with a digit; Niagara escapes it in the point name, so the point does not read back under the name th… | '2ndStageEnable' in '2ndStageEnable' |
Reading down: the decoder throws on every truncation of a frame it had just decoded
happily; batteryVolts is a number when the battery is fitted and the
string "not fitted" when it is not; two values carry their units inside
the string; a division that happens to be by zero produces Infinity on
both frames; the temperature conversion is declared twice, so the scaling you read at
the top of the file is not the one that ran; and three keys cannot survive as point
names. None of that raises an error anywhere. All of it arrives as points.
## verdict
1 crash on a short frame 2 findings
2 non-deterministic output ok
3 shape drift ok
4 type drift 1 finding
5 unit inside the value [heuristic] 4 findings
6 non-finite and null numerics 2 findings
7 duplicate declarations [heuristic] 1 finding
8 silent acceptance / silent rejection ok
9 key names a station cannot use as-is [heuristic] 3 findings
10 fPort sensitivity ok
13 findings across 2 frames, of which 8 from a heuristic check that can be wrong.
The exit code is 0 when nothing is found and 1 when something is, so this can sit in a commissioning script or a CI job and fail a build rather than a building.
What it cannot tell you
It does not know whether the numbers are right. A decoder that reads the wrong two bytes and returns a beautifully typed 21.5 °C for a sensor sitting at 4 °C passes every check here. Only the device and a reference instrument settle that, which is why the one thing worth doing before any of this is to put a known frame through and check the answer by hand.
It is not a security boundary either, and says so in its own help text.
node:vm isolates a decoder from your files and your network well enough
to make running an unfamiliar one reasonable; it is not a sandbox to run a file you
believe is hostile. Read the decoder first. They are usually under 300 lines.
And it never speaks to anything. There is no LoRaWAN in it, no MQTT client, no HTTP call to a network server. It reads the frames you give it, which means the frames have to come from somewhere — the network server's own uplink log, or mqtt-tap against the broker the integration already publishes to.
Where it came from
From running published decoders. Over the last few weeks a series of device vendors' own decoder files were executed here against their own documented example frames, and the pattern that came out of it was not that the JavaScript was bad — most of it is careful — but that it was written against a network server's console, where a string with a unit in it looks fine, rather than against a system that has to make a point out of every field and keep it for a year. Each finding went to the maintainer with a patch. That is why nothing on this page names a vendor, and why the sample decoder above is one written for the purpose.
Reading a LoRaWAN payload decoder you did not write is the reasoning behind these checks, one pattern at a time, and the LoRaWAN integration page is what happens when the answer is that the decoder is the small part. The other two free programs are bacnet-sweep and mqtt-tap, and all three are listed here.
The file you are downloading
Published here so the download is checkable rather than trusted. Both figures
are read off the file served at
/tools/decoder-check.js when this page is built, so they cannot
disagree with it.
| Property | Value |
|---|---|
| File | decoder-check.js |
| Size | 79,486 bytes |
| SHA-256 | 67f74f60399e9a27e971dd5435efc97bb06ff105a0c10bc9c064a9303239a5f0 |
| Licence | MIT — LICENSE.txt |
To check it, on Linux sha256sum decoder-check.js, on macOS
shasum -a 256 decoder-check.js, on Windows
certutil -hashfile decoder-check.js SHA256. A different digest means a
different file — not necessarily a hostile one, but not this one.
bacnet-sweep
Broadcasts a BACnet/IP Who-Is, tables the devices that answer, and dumps a named device's object list — object name, present value and units — to a table or to CSV. It can encode two BACnet services and no others: Who-Is and ReadProperty.
- BACnet/IP
- Who-Is
- CSV out
mqtt-tap
Subscribes to a broker and prints what is actually on it: the topic tree with a count, a rate, a payload-type guess and the last value per topic, plus the retained topics that stopped updating. It sends five packet types and none of them is PUBLISH.
- MQTT
- Topic tree
- Retained
Next step
Send the report and the decoder.
A decoder that needs ten lines of guarding and one that needs rewriting look the same from the outside and cost very different amounts. You will get a written scope and a fixed price, or a sentence saying which ten lines to add.