Skip to content

Note

Reading a LoRaWAN payload decoder before you trust it

The codec that turns eleven bytes into readings is somebody else's JavaScript, written for a dashboard. What it emits becomes your point types — and a dashboard forgives things a station does not.

  • LoRaWAN
  • Integration
  • Commissioning

Written September 2026

Why the decoder is the integration

A LoRaWAN device sends bytes packed to save airtime. Nothing in the network server knows what they mean; meaning arrives separately, as a JavaScript function the vendor publishes, which the network server runs to turn a frame into JSON before it is handed on over MQTT or a webhook. The route into a station is covered in bringing LoRaWAN and MQTT data into a Niagara station. This note is about the function itself, because whatever shape it emits is the shape your points inherit.

That function was written against a dashboard, and a dashboard is forgiving. It will happily render the string Error in the cell where a number was yesterday, and nobody notices until a trend is asked for. A Niagara point is not forgiving: it has one type for its whole life, its facets are set once, and a history that has been collecting a numeric point does not accept text.

Six of the eight patterns below came out of reading and running five vendors' published decoders in one evening — sensor makers and gateway makers, all files anyone can download. The last two came from three more vendors' files the same week, and they are the two I would check first now. The vendors are not named here: where a finding is an outright defect it went to the maintainer first, and none of them is worth a reader's time as gossip. Every one is worth ten minutes of checking against the decoder in front of you.

1. One key, two types

The commonest one. A valid frame gives "temperature": {"value": -30, "unit": "°C"}; a frame with the sensor's invalid marker in that byte — usually 0xFF or 0x7FFF — gives "temperature": "Error". Same key, object one minute and a bare string the next.

On the station side that is not a cosmetic difference. A numeric point fed text goes to fault or stops updating depending on how the mapping was written, and if the first frame after commissioning happened to be the invalid one, the point may have been created as a string point and will stay one. The fix belongs on our side of the wire: decode to a number plus a validity flag, map the flag to the point's status rather than its value, and let the point go stale or fault through Niagara's own mechanism instead of through a word in the value field.

2. Enumerations arrive as English sentences

Battery state comes through as "Hardware working correctly". Air quality as "Excellent". An alarm level as "High"/"Medium"/"Low"/"Critical", and occupancy as "Not active"/"Active". The ordinal that came off the wire — the 0, 1, 2, 3 the device actually sent — has been thrown away by the time the JSON exists.

A Niagara enum point wants the ordinal and a range; that is what makes it alarmable, trendable, and translatable in the graphics. Given display strings you are left matching text, which breaks the day the vendor improves their wording, and leaves your alarm extension comparing sentences. Keep the ordinal, carry the vendor's text as a facet or a descriptive slot if it is useful, and build the range once.

3. Unmapped values disappear instead of failing

Decoders map bytes to meaning with lookup helpers, and the helpers are usually written for the values that exist today: a product-type function with two cases and no default, a region function that answers "reserved" for everything it has not met, an array indexed by a byte that can be larger than the array. In JavaScript all three return undefined, and undefined does not survive JSON.stringify — the key simply is not in the output.

Downstream, a missing key is indistinguishable from a message that did not arrive. The point holds its last value and keeps its last timestamp, and the very frame that was trying to tell you the device is a model you have never seen is the one that leaves no trace. Treat a missing expected key as a fault, not as silence.

4. The history path and the live path disagree

Many devices send two kinds of message: the current reading on one port, and a batch of stored samples on another, for gap-filling after an outage. Those are two functions in the decoder, written months apart, and they do not always agree about the arithmetic.

Sign extension is where it shows. A 16-bit signed temperature assembled as bytes[0] << 24 >> 16 | bytes[1] is negative when it should be; the same bytes assembled as bytes[i] << 8 | bytes[i + 1] never are. Scale factors drift the same way: a divisor of ten in one function and a hundred in the other for the same physical quantity. The effect is that sub-zero readings are correct on the live point and wrong in the backfilled history, which is the worst arrangement available — the error is invisible on the day and permanent in the record.

It is cheap to test. Take one frame with a negative temperature, run it through both paths, and compare. If the numbers differ, you have found it, and you have found it before a client's energy report does.

5. Timestamps formatted in the server's own time zone

Decoders that unpack a device clock often format it for display, with getFullYear, getMonth, getHours and friends. Those are local-time getters. The string they build carries no offset and no Z, so the same payload decodes to three different wall-clock strings on three servers: one reading landed as 14:13:20 under UTC, 07:13:20 on a machine in California and 19:13:20 on one in Pakistan, from identical bytes.

Once that string is in the JSON there is no way to recover which it was. A station importing it will attach its own zone to a number that already had one, and the history is out by hours in a way that only becomes obvious across a daylight-saving boundary. Insist on epoch seconds or a full ISO 8601 timestamp with an offset, and let Niagara do the zone: it has a time zone, and the decoder does not know it.

6. Epoch seconds through a signed 32-bit shift

The idiom for rebuilding a four-byte value is b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]. JavaScript's bitwise operators work on signed 32-bit integers, so any epoch past 2147483647 comes out negative: 2200000000 becomes -2094967296, which formats as a date in 1903. Today the bytes are small enough that it works, so it is never caught in testing; in January 2038 it stops working everywhere at once, and the failure looks like a clock that has gone mad rather than an operator error.

A >>> 0 on the assembled value, or building it with multiplication instead of shifts, is the whole fix. It is worth a pull request to the vendor rather than a local patch you will have to re-apply at every firmware release — two of ours this month were three lines.

7. The rejection path that never reaches you

Most of these decoders can already tell a bad frame from a good one. They return a valid boolean, or an err code, or null — and that is exactly the signal a station wants, because a frame that failed its checksum should make a point go stale or fault, not deliver a number. The pattern is that the signal exists in the contract and nothing can act on it.

Two shapes of it, both from files published this month. In the first, one vendor ships the same decoder once per platform, and the checksum function is a real CRC-16 table in one copy and return true in the other three. Zero the last two bytes of a frame and the strict copy answers valid: false; the other three report the reading as if nothing happened. Corrupt a temperature byte and leave the original checksum in place and they hand back a plausible wrong number — 28.016 °C where the sensor said 28 — still flagged valid. The documented err code for "checksum failed" is unreachable in three of the four files that document it.

In the second, the decoder is careful: it returns null for a truncated frame, deliberately, with a clear message on the console. The vendor's own command-line example then destructures that return value without testing it, so the reference consumer throws a TypeError on precisely the frames the decoder got right. Anyone who copies the example inherits a crash where the library had already made the correct decision.

This is cheap to test and nobody does it. Two more lines in the harness above: run a frame with its last two bytes zeroed, and run one with a value byte changed and the original checksum left alone. If both come back valid, the decoder has no rejection path in practice, whatever its documentation says — and the station needs to get its "this reading is not trustworthy" from somewhere else, usually a staleness alarm on the point rather than anything in the payload.

Where the check is missing but the vendor has written it elsewhere, the fix is a file move rather than new code, which makes for an easy pull request and an easier conversation.

8. The unit lives inside the value

A decoder emits "250.000 bar" where you expected 250. Not a number with a unit recorded somewhere else — a string with the unit formatted into it, one line of template literal per reading. On a dashboard it is a convenience: the label is already right in the cell. On a station it is a string point. No trend graph, no high-limit alarm, no totalisation, no rollup, and no way back, because the type is chosen once and the history behind it inherits that choice.

The worse half of the pattern is what happens across a family. Sibling files for the same range of sensors staple different units onto an identical output name. One CBOR frame, put through three published decoders for the same differential-pressure family, gave "250.000 bar", "250.000 Pa" and "0.000 Pa" — the same key, Differential Pressure, in all three, and the third one reading a different payload key for the same measurand. Only that third file scaled the transducer at all, mapping the raw count onto the sensor's range; the two that published the raw count untouched were the ones carrying the confident unit label. Two hundred and fifty bar across a filter is about 2 500 metres of water. Nobody would let that through a review if it arrived as a number, but as a formatted string it reads like a finished answer.

The harness frame for this is a loop rather than a case: run every file in a family against one frame and diff the key names and the unit strings against each other. Disagreement inside one vendor's own directory is the cheapest possible evidence, and it is static — no device, no network server, no gateway.

Vendor-side the fix is one line per reading: emit { value: 250, unit: "Pa" } and let the consumer format. Station-side, when the vendor will not change it, the parse belongs in exactly one place rather than spread across the point tree — one conversion per key that asserts the unit it expected and drives the point to fault when that label changes, instead of quietly continuing with a number that is now off by a factor of a hundred thousand. A point whose value silently changed units is indistinguishable, in a year-old trend, from a plant that changed behaviour.

Testing one in ten minutes, with nothing installed

A vendor decoder is a plain file with a decodeUplink function in it. Node can run it without a package, a network server, or the device on a desk. The file usually has no module.exports, so the trick is to evaluate it in a context and then reach in for the function:

const vm = require('node:vm');
const src = require('fs').readFileSync('decoder.js', 'utf8');
const ctx = vm.createContext({ console });
new vm.Script(src + '\n;globalThis.__d = decodeUplink;').runInContext(ctx);

const hex = h => Array.from({ length: h.length / 2 },
                            (_, i) => parseInt(h.substr(i * 2, 2), 16));
const run = (h, fPort) =>
  console.log(h, JSON.stringify(ctx.__d({ bytes: hex(h), fPort })));

run('FE0C022BFE0C016AAE5B00', 2);   // nominal, one value below zero
run('FFFFFFFFFFFFFFFFFFFFFF', 2);   // every invalid marker at once
run('FE0C022BFE0C016AAE5B00', 3);   // the history path, same bytes

Three frames answer most of this note. A nominal frame with one negative value catches sign extension and scale. An all-0xFF frame catches the type changes and the silent undefined. Running the same bytes through the history port catches the two paths disagreeing. Run the third one twice with TZ=UTC and then TZ=America/Los_Angeles in front of the command, and you have tested the timestamps as well.

The newline in front of the appended statement is not decoration. Several of these files end in } // end of decoder, and without it the appended code lands inside that comment: the script runs, nothing is exported, and the harness reports that your decoder is not a function. Wrapping the source in eval('(function(){' + src + '})()') instead fails on the same files for the same reason, and on a bare function declaration at the end as well. The node:vm form above works on every decoder we have tried.

Where this lands in a station

There are two honest options and one that looks easier than it is. The first is to normalise upstream, in the network server or a small broker-side service: the decoder stays the vendor's, and one place fixes types, ordinals, missing keys and time. The second is a decoder written in Java inside the module, which is what we do on a JACE when the payload matters — it is a couple of hundred lines per device family, it is testable in a JUnit run rather than on a roof, and it emits Niagara types directly with the facets and enum ranges already right.

The one that looks easier is running the vendor's JavaScript inside the station. Niagara 4 is Java 8 throughout, so there is a script engine in the JVM and it can be made to work. It has a shelf life: Nashorn was deprecated in Java 11 and removed in Java 15, so that approach does not travel to the JDKs a Niagara 5 station will be built on. Committing an estate's decoding to an engine that will not exist on the platform you are migrating towards is a decision to make deliberately, if at all.

If you have a decoder and a sample frame and want to know which of the six this one does, send both — that read is free and the answer is usually a paragraph. The tools page has what we publish under MIT, LoRaWAN integration is the rest of this boundary as a piece of work — decoder, network server, downlinks, points — and Niagara module development covers the Java side when a decoder has to live in the station. Where the payload comes off a broker rather than a LoRaWAN server, reading an unfamiliar MQTT broker is the companion to this one.

Related

Where this comes up in the work

More notes

Other things worth writing down

Next step

Tell us the version, the hardware, and what it has to do.

You will get a written scope and a fixed price against it. If the honest answer is that you do not need us, you will get that instead.