Skip to content

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.

CheckWhat 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.

PropertyValue
Filedecoder-check.js
Size79,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.

Also free

The other two

Same idea, different protocol. All three tools.

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.