Case study · Payload decoders
Four defects in a vendor's payload decoder, fixed upstream in a day
A LoRaWAN sensor arrives with a decoder written by its manufacturer, and the station believes whatever that file returns. We read the published decoder for an air-quality and CO sensor family, found four ways it hands a building system something it cannot act on, and sent them to the manufacturer. All four are fixed in the manufacturer's own public repository, and the commits are linked at the bottom of this page.
- LoRaWAN
- Payload decoding
- Accepted upstream
- Four findings
- Public commits
- decoder-check
The systems involved
What had to talk to what
| The decoder | One JavaScript file per product family, published by the manufacturer and run as-is by whichever network server receives the frames. It is the only thing that knows what the bytes mean, and nothing downstream can check its work. |
|---|---|
| What consumes its output | The network server hands the decoded object upwards. In this trade that is a station, where each field becomes a point and the field's type decides whether that point is numeric, a string, or never updates at all. |
| What we read it with | decoder-check, the free tool on this site: it runs a vendor decoder in a sandbox with no network, no filesystem and no timers, and prints what a station would actually receive frame by frame. |
The difficulty
What made them hard to connect
Nothing in the chain validates the decoder. The network server runs the file and the station takes the object. A byte value nobody mapped returns undefined, which is not an error anywhere: the field is absent, the point never updates, and there is no log line to find. Three functions in the published file ended that way - the product type, the message type and the radio region - and each one is an input an integrator cannot control.
The same key changed type between frames. A measurement arrived as {"value":-30,"unit":"°C"} in a normal frame and as the string "Error" in a fault frame. One point cannot be both: the import makes a numeric point, and the string arrives as a fault or as nothing. A BMS needs one type per field for the lifetime of the integration.
One function was declared twice. In the version published on 17 September, decoderAir+.js declared function humidity at line 53 and again at line 175. JavaScript keeps the later one silently, so the code a reviewer reads first is not the code that runs. That is worse than a wrong conversion, because reviewing the file carefully is what produces the wrong answer.
Enumerations arrived as English display strings. Readable in a test console, and useless to a control system: the ordinal is discarded, so a station cannot compare, alarm on or trend the value without parsing prose it did not choose.
The work
What we built
A reading, not a bug report
Four findings, each with the line, the byte that triggers it, and what a station receives instead - produced by running the vendor's own file in a sandbox rather than by inference. The mail was four numbered items and a frame per item.
The manufacturer chose the fix
They took all four, said no pull request was needed, and published their own answer: a defaulted value on every enumeration, a second decoder with one numeric type per field for building-system use, and an MIT licence added to each product family folder the same day.
A rule, not only a patch
The day after, a CHANGELOG.md appeared in the repository carrying the rule we had argued for: an output field renamed or removed, or a value or type changed, is a major version, announced before publication and marked as an output change. That is the part that protects the next integration rather than this one.
Now
What it does today
The published decoder has one humidity function, a value returned for every unmapped code, a numeric-typed variant beside it for building systems, an MIT licence and a versioning rule for output changes. Our name is nowhere in the repository, which is the point: the fix sits upstream of everyone who buys the sensor, including whoever reads this page.
| Findings sent | 4 |
|---|---|
| Findings acted on | 4 |
| <code>function humidity</code> declarations in the published file | 2 before, 1 after |
| Enumerations returning nothing for an unmapped byte | 3 before, 0 after |
| Our post to the first fix commit | same day, 28 September 2026 |
| Versioning rule for output changes | none before, published the next day |
| Pull requests from us | 0 - the manufacturer wrote the code |
| Invoiced | nothing, and nothing was offered |
The stack
What it is made of
| The file | JavaScript, as the manufacturer publishes it, one per family |
|---|---|
| Read with | decoder-check: node:vm, no network, no filesystem |
| Frames read | product status, CO alarm status, real time, and the configuration frame |
| Where the fix lives | the manufacturer's public repository, MIT licensed |
Who did what
The reading is ours. The decoder is not ours, the fix is not ours and the repository is not ours: we sent four findings and the manufacturer wrote the code, chose the shape of the numeric variant and published the versioning rule. Nothing here was commissioned, paid for or sponsored in either direction, and we hold no relationship with the manufacturer beyond that exchange. They are not a client and this page does not imply one.
Check it
The evidence, not a description of it
Every link here opens the thing itself — a repository, a running demo, a note with the method in it.
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.