The previous article covered onboarding 500 devices in a single afternoon with the ThinkLink REST API. Once devices are online, the next problem arrives immediately: data is flowing, but nobody can read it.
Your console shows an uplink like this:
IQcDDG4PAADWBIsC34IG
What the business actually wants is: temperature 23.8 °C, humidity 65.1 %, battery 3.40 V, status normal, reporting interval 15 minutes. The layer sitting between those two states is the thing model decoder script — the subject of this article.
Start by unpacking that Base64 string. It comes from msg.userdata.payload in the standard ThinkLink LoRaWAN AS message — 20 Base64 characters, 15 bytes:
21 07 03 0C 6E 0F 00 00 D6 04 8B 02 DF 82 06
Mapped against the KS52 temperature/humidity sensor's private frame format:
| Byte offset | Raw | Meaning | Decoding | Result |
|---|---|---|---|---|
| 0-2 | 21 07 03 | Fixed frame tag | Direct comparison | Valid frame |
| 3-4 | 0C 6E | Sequence / reserved | Not parsed by this model | — |
| 5-6 | 0F 00 | Reporting interval | uint16 LE → 15 (minutes) | 15 min |
| 7 | 00 | Status bits | bit0 = fault, bit4 = leak | Normal, no leak |
| 8-9 | D6 04 | Temperature | uint16 LE → 1238, (1238−1000)/10 | 23.8 °C |
| 10-11 | 8B 02 | Humidity | uint16 LE → 651, ×0.1 | 65.1 % |
| 12 | DF | Battery voltage | uint8 → 223, (223×1.6)/254+2.0 | 3.40 V |
| 13-14 | 82 06 | Reserved / checksum | Not parsed by this model | — |
Fifteen bytes carry five business values. That is why LoRaWAN end devices use compact binary instead of JSON — not because engineers enjoy obscurity, but because the air interface cannot afford anything else.
Everyone new to LoRaWAN asks this: if the application layer wants JSON anyway, why not let the device send {"temperature":23.8,"humidity":65.1}?
The answer is airtime. For an SX1262 radio at BW125 kHz, CR 4/5:
| Payload | Airtime at SF7 | Relative to 15 bytes |
|---|---|---|
| 15 bytes (compact binary) | 46.3 ms | 1.0× |
| 120 bytes (JSON with field names and timestamp) | 199.9 ms | 4.3× |
Add the spreading factor and the gap becomes impossible to ignore — same 15-byte payload:
| Spreading factor | Airtime | Relative to SF7 |
|---|---|---|
| SF7 | 46.3 ms | 1.0× |
| SF10 | 329.7 ms | 7.1× |
| SF12 | 1155.1 ms | 24.9× |
At one uplink every 15 minutes (96 per day), a single device spends 4.4 seconds per day in high-power transmit at SF7 versus 111 seconds at SF12. That is the mechanism behind the battery-life article in this series: every extra byte you transmit, and every SF step you fall back, is paid for out of the battery.
There is also a hard ceiling. Under RP002-1.0.5 regional parameters, China's CN470 band allows a maximum MAC payload of 15 bytes at its lowest data rate (DR0). The frame length above is not a coincidence — it is what the regulatory limit forces under the worst link conditions.
So the division of labour in LoRaWAN is fixed: the device compresses as hard as it can; the platform restores what humans can read. The decoder script is the entire workload of that restoration step.
The module responsible for decoding in ThinkLink is the thing model. Its job: turn raw data arriving from a LoRaWAN network server (NS) — or any other protocol — into standardised telemetry (telemetry_data) and shared attributes (shared_attrs) for tables, dashboards, alarms, and protocol mapping.
A thing model has four configuration blocks, and skipping any of them causes rework:
| Block | Purpose | Common omission |
|---|---|---|
| Parser script | Turns binary frames into named fields | — |
| Display fields | Defines what the UI table/card shows | Assumed done once the script runs |
| Modbus mapping | Exposes data over Modbus TCP | Discovered missing during industrial integration |
| BACnet fields | Exposes data over BACnet/IP, generates point lists | Discovered missing at building handover |
The official documentation makes a point worth quoting: having a script does not mean the mapping is done. This is the single most common rework item in real projects — the script runs fine, then the BMS integrator asks for a point list and no BACnet field has been defined.
Creating a thing model requires both a name (for display) and an ID Name (the identifier used by code and APIs). They are independent; filling in only the name leaves problems behind.
Parser scripts execute in a vm2 sandbox, fully isolated from the Node.js main process. Every one of these limits directly determines whether your script works:
| Limit | Value | Consequence |
|---|---|---|
| Execution timeout | 1000 ms | Exceeding it aborts and skips this parse |
console |
Unavailable | No print debugging — use thing model logs |
async / await |
Unsupported | Synchronous code only |
require / import |
Unsupported | No third-party packages |
eval |
Disabled | No dynamic code execution |
| WebAssembly | Disabled | No .wasm loading |
setTimeout / setInterval |
Unavailable | No timers or delays |
process / fs / path |
Unavailable | Calling them throws ReferenceError |
Buffer is a restricted build (SafeBuffer) exposing only six static methods: Buffer.alloc, Buffer.from, Buffer.isBuffer, Buffer.byteLength, Buffer.compare, Buffer.concat. Buffer.allocUnsafe() is explicitly blocked. Instance read/write methods (readUInt8, readUInt16LE, …) work normally.
Together these rules point to one conclusion: a decoder script should be a pure, synchronous, side-effect-free byte-shuffling function. Anything that wants to call external services, query a database, or wait for a response during decoding does not belong here — that work belongs to trigger models and RPC models.
No parameters need declaring; the platform injects these:
| Variable | Description |
|---|---|
device |
Device object, frozen with vm.freeze, read-only |
msg |
Raw uplink message body |
thingModelId |
Current thing model ID |
noticeAttrs |
Flags indicating which attribute change triggered this run |
org_params |
Tenant-level environment variables (e.g. shared thresholds) |
subDevices |
Child-device cache map of the parent device |
env / plugins |
Plugin environment variables and instances (advanced) |
Buffer |
Restricted SafeBuffer |
BufferTKL / PayloadParser / MSparser / Utils / TriggerHelper / RPCHelper |
All tklHelper classes, available as sandbox globals with no import |
Most relevant device properties for decoding:
| Property | Use |
|---|---|
eui |
Device EUI, globally unique primary key |
device_type |
NORMAL / SUB_DEVICE / VIRTUAL |
data_from |
"LoRaWAN" or "OTHER" (direct MQTT) |
online / active_time |
Online state and last uplink time |
shared_attrs |
Attributes synced both ways (interval, thresholds) |
server_attrs |
Platform-side-only attributes (alarm thresholds, linked EUIs) |
mac_attrs |
LoRaWAN MAC-layer attributes (band, ADR) |
telemetry_data |
Latest telemetry snapshot per thing model, keyed by thingModelId |
Note: device is frozen with vm.freeze — assignments do not throw but do not take effect either. The return value is the only output channel. Missing properties return undefined, so use optional chaining (?.) throughout.
msgFor LoRaWAN uplinks, four fields cover almost every need:
| Data | Access |
|---|---|
| Payload (Base64) | msg.userdata.payload |
| Port | msg.userdata.port |
| RSSI | msg.gwrx[0].rssi |
| SNR | msg.gwrx[0].lsnr |
For JSON data arriving directly over MQTT, walk the JSON hierarchy as usual.
The script must return a fixed structure:
| Field | Default | Purpose |
|---|---|---|
telemetry_data |
null | Telemetry for dashboards, trends, history queries |
shared_attrs |
null | Attributes synced both ways (interval, address, thresholds) |
server_attrs |
null | Platform-side-only metadata |
sub_device |
null | Child device identifier — e.g. an RS-485 address; the platform creates the child device and assigns an EUI |
Any field may be null, but null means this uplink produces no data of that type. The most frequent mistake in practice: parameter replies get parsed into telemetry_data, so the reporting interval ends up plotted as a measurement.
sub_device is the most underrated field in LoRaWAN deployments — see section 5.
Here is a complete version with no helper library, unpacking the 15-byte frame field by field. This is the entire content of the parser script tab (a bare function body; the platform wraps and calls it):
let payload = Buffer.from(msg?.userdata?.payload, "base64"); let port = msg?.userdata?.port || null;
function parseTelemetry(payload) { // port must be 11, frame tag must be 21 07 03, length must be 15 if (port != 11) { return null } if (payload[0] != 0x21 || payload[1] != 0x07 || payload[2] != 0x03) { return null } if (payload.length != 15) { return null }
let telemetry_data = {}; telemetry_data.period_data = payload.readUInt16LE(5); telemetry_data.status = (payload[7] & 0x01) != 0 ? "fault" : "normal"; telemetry_data.leakStatus = (payload[7] & 0x10) != 0 ? true : false; telemetry_data.temperature = Number(((payload.readUInt16LE(8) - 1000) / 10).toFixed(2)); telemetry_data.humidity = Number((payload.readUInt16LE(10) / 10).toFixed(2)); let vbat = payload.readUInt8(12); telemetry_data.vbat = Number(((vbat * 1.6) / 254 + 2.0).toFixed(2)); telemetry_data.rssi = msg.gwrx[0].rssi; telemetry_data.snr = msg.gwrx[0].lsnr; return telemetry_data; }
return { sub_device: null, telemetry_data: parseTelemetry(payload), server_attrs: null, shared_attrs: null };
Feed in the Base64 string from the top and you get:
{ period_data: 15, status: "normal", leakStatus: false, temperature: 23.8, humidity: 65.1, vbat: 3.40, rssi: -92, snr: 13.2 }
Triple validation. Port, frame tag, length — drop any one and the script will eventually parse the wrong frame. Port 11 is measurement data, 214 is parameter reply, 51 is transparent transport. The same device sends entirely different things on all three; without a port check you are feeding three message types into one parser.
Endianness must come from the manual. Temperature here is readUInt16LE. Write readUInt16BE instead and 1238 becomes 56,594 — a temperature of 5559.4 °C. It throws no error; it just quietly produces nonsense, usually discovered at customer acceptance.
Offsets and coefficients come from the device protocol, not from guessing. (raw − 1000) / 10 exists because the device encodes 0 °C as 1000 to avoid negative numbers. (vbat × 1.6) / 254 + 2.0 maps an 8-bit ADC value back onto the 2.0–3.6 V range. Both come from the device manual.
Wrap toFixed in Number(). toFixed returns a string; returning it directly makes the front end treat the value as text, breaking sorting and threshold comparisons.
Carry RSSI and SNR along. They live in msg.gwrx[0], not in the payload. Putting link quality into telemetry means that when someone reports "device X is jumping around", you can check coverage first and skip a site visit.
Return null on parse failure, do not throw. Null means "no telemetry from this uplink" and the platform skips it; throwing writes an error and interrupts the flow. For occasional corrupt frames or frames on other ports, null is the correct semantic.
The hand-written version reads well, but every new device model repeats the same validation and bit-shifting. ThinkLink's tklHelper library abstracts this into three declarative blocks: frameInfo (frame validity), appInfo (variable definitions), paraInfo (parameter definitions).
The library documentation is synced to version 1.01.043 (2026-10-12). Across thing model, RPC, and trigger scripts, its six classes (BufferTKL / PayloadParser / MSparser / Utils / TriggerHelper / RPCHelper) are sandbox globals — no import needed.
| Field | Default | Description |
|---|---|---|
port |
-1 | Port number; if positive, mismatched frames are dropped; -1 disables the check |
dataLen |
-1 | Expected frame length; if positive, mismatches are dropped |
status |
-1 | Offset of the status byte; if ≥ 0, read as uint8 into tdata.status |
battery |
-1 | Offset of the battery byte; if ≥ 0, converted by (v×1.6)/254+2.0 into tdata.battery |
rssi |
false | If true, reads RSSI/SNR from msg.gwrx[0] |
tagList |
[] | Frame-tag checks, each { index, tag }; mismatch discards the frame |
subDevice |
{ index: -1, type: "uint8" } |
Position and type of the child-device address; a value of 0 discards the frame |
For our example:
let frameInfo = { port: 11, dataLen: 15, rssi: true, tagList: [ { index: 0, tag: 0x21 }, { index: 1, tag: 0x07 }, { index: 2, tag: 0x03 } ] };
The triple validation from the previous section becomes four declarative lines.
| Parameter | Description |
|---|---|
name / field_name |
Display name / program field name (the key in tdata) |
unit |
Unit string |
index |
Starting byte offset |
type |
Type string (see below) |
coefficient |
Multiplier — a number, or another field's name for a second-pass multiply |
decimal |
Decimal places, default 0 |
illegal |
Validity check such as ">0x80" or "=0xFF"; on match the field is silently dropped |
options |
Value map, e.g. { 0: "normal", 1: "fault" } |
postProcess |
JS expression string with value as input |
Supported types are broad enough that bit manipulation is rarely needed:
| Category | Type strings |
|---|---|
| Integers | int8 / uint8, int16 / uint16 (big-endian default), int16le / uint16le, int24 / uint24, int32 / uint32 (with le variants), int40–int64 series |
| Floats | floatbe / floatle (IEEE 754 single), floatcdab (byte order 2-3-0-1, common on Modbus devices), intcdab |
| Encoded numerics | bcdbe<N> / bcdle<N> (BCD), hexbe<N> / hexle<N> (hex string) |
| Bit fields | bitbe<S>-<E> / bitle<S>-<E> — extract a bit range directly |
| Booleans | bool<N> (returns 1/0), boolean<N> (returns true/false) |
| Strings | string<N> (N-byte ASCII) |
Type strings are case-insensitive. floatcdab and intcdab exist for the byte-order traps in Modbus devices — plenty of meters store 32-bit values as 2-3-0-1, which is easy to get wrong by hand and trivial to declare.
Bit-field types solve another common case: one status byte holding eight flags. Use bitle0-0 for the fault bit and bitle4-4 for the leak bit instead of counting zeros in masks like & 0x01 and & 0x10.
let frameInfo = { port: 11, dataLen: 15, rssi: true, tagList: [ { index: 0, tag: 0x21 }, { index: 1, tag: 0x07 }, { index: 2, tag: 0x03 } ] };
let appInfo = [ { name: "Status", field_name: "status", unit: "", index: 7, type: "bitle0-0" }, { name: "Leak", field_name: "leakStatus", unit: "", index: 7, type: "bitle4-4" }, { name: "Temperature", field_name: "temperature", unit: "C", index: 8, type: "uint16le", postProcess: "value = (value - 1000) * 0.1; return Number(value.toFixed(1))" }, { name: "Humidity", field_name: "humidity", unit: "%", index: 10, type: "uint16le", coefficient: 0.1, decimal: 1 }, { name: "Battery", field_name: "vbat", unit: "V", index: 12, type: "uint8", postProcess: "return Number(((value * 1.6) / 254 + 2.0).toFixed(2))" } ];
let payParser = new PayloadParser({ device, msg, frameInfo, appInfo }); let tdata = payParser.telemetry(); return { sub_device: null, telemetry_data: tdata, server_attrs: null, shared_attrs: null };
| Scenario | Recommended style | Why |
|---|---|---|
| Fixed frame structure, regular fields | Declarative tklHelper | Under 40 lines, validation built in, field changes are table edits |
| Irregular structure, dynamic field positions | Hand-written Buffer | Declarative config cannot express conditional branches |
| One frame carrying multiple sensors | MSparser subclass |
Auto-advances offsets by ID (see 5.5) |
| Cross-field computation | Hand-written or postProcess |
coefficient supports field references; complex logic still needs code |
PayloadParser.telemetry() runs in a fixed order: validate port → dataLen → tagList → subDevice, then extract status / battery / rssi and each appInfo field, applying illegal → coefficient → decimal → postProcess → options, with a second pass for coefficients that reference other fields. Knowing this order is how you debug "why didn't my options take effect" (it applies after decimal and before a skipped postProcess).
Some collectors pack several measurement points into one uplink as repeated [ID | field data...] blocks. MSparser extends PayloadParser and requires extraData:
new MSparser({ device, msg, frameInfo, extraData })
For each ID it reads, it looks up user_0x<ID>_<j> in extraData (j = 0..3, up to four fields per ID) and advances the offset automatically. frameInfo.idType sets how the ID is read, usually "uint16be".
Decoder scripts do not only handle measurements. Device configuration (intervals, thresholds, calibration offsets) travels on port 214, and those replies must be parsed too — into shared_attrs, not telemetry_data.
Parameter addresses for the KS52 (COV = change-of-value threshold, offset = calibration):
| Parameter | Address (dec / hex) | Type and unit | Applies to |
|---|---|---|---|
| Temperature COV | 144 / 0x90 | uint8 × 0.1 °C | KS52, KS53 |
| Humidity COV | 145 / 0x91 | uint8 × 0.1 %RH | KS52 only |
| Temperature offset | 146 / 0x92 | int8 × 0.1 °C | KS52, KS53 |
| Humidity offset | 147 / 0x93 | int8 × 0.1 %RH | KS52 only |
| Measurement interval | 142 / 0x8E | uint16 seconds | KS52, KS53 |
With tklHelper, parameters are declared in paraInfo using <segment>_<address> keys, where segment is one of app / cf / fw / radio / ds / status:
let paraInfo = { app_142: { name: "Measure interval", field_name: "measurePeriod", unit: "s", type: "uint16le" }, app_144: { name: "Temperature COV", field_name: "covTemperature", unit: "C", type: "uint8", coefficient: 0.1, decimal: 1 }, app_145: { name: "Humidity COV", field_name: "covHumidity", unit: "%", type: "uint8", coefficient: 0.1, decimal: 1 }, app_146: { name: "Temperature offset", field_name: "offsetTemperature", unit: "C", type: "int8", coefficient: 0.1, decimal: 1 } };
let payParser = new PayloadParser({ device, msg, frameInfo, appInfo, paraInfo }); let tdata = null, pdata = null; if (port === 214) { pdata = payParser.paras(); } else { tdata = payParser.telemetry(); } return { sub_device: null, telemetry_data: tdata, server_attrs: null, shared_attrs: pdata };
paras() only works on port 214 and returns null otherwise, so the port check is mandatory.
These two are routinely confused, and the result is "the data got worse after calibration":
| Concept | What it does | Unit |
|---|---|---|
| COV (change of value) | Decides when to transmit — send only if change exceeds the threshold | 0.1 °C / 0.1 %RH |
| Offset | Calibrates the measured value itself | 0.1 °C / 0.1 %RH |
Offset is signed 8-bit, range −128 to 127, i.e. −12.8 to +12.7 in steps of 0.1. Negative values use two's complement: −1.0 °C is written as F6. On read-back, any byte ≥ 128 must have 256 subtracted before multiplying by 0.1 — this must be handled explicitly in the decoder, or every negative calibration silently turns positive.
One more field lesson: offset is a device-side parameter. Do not also compensate on the platform side, or the error doubles.
sub_device: turning one DTU into N devicessub_device is ThinkLink's most practical design decision for brownfield retrofits.
The typical chain: a KC11 (DIN rail, RS-485, AC 85–270 V) or KC25 DTU reports over LoRaWAN, and one frame contains data from multiple RS-485 slaves — electricity meters, water meters, temperature/humidity transmitters. The DTU is one LoRaWAN device, but behind it sit 8 or 16 physical meters.
The decoder simply returns:
return { sub_device: "03", telemetry_data: tdata, server_attrs: null, shared_attrs: null };
sub_device carries the child device's bus address (an RS-485 address here). ThinkLink then creates a virtual child device in device management and assigns it a unique EUI. The result:
This is the technical premise that makes "keep the existing wired assets, replace only the last hop" actually work: LoRaWAN solves the wireless segment; device asset management still unfolds per physical meter.
frameInfo.subDevice (when index >= 0, read the address by type into tdata.addr; a value of 0 discards the frame) works together with this field to extract the slave address straight from the frame.
A running script is step one. A deliverable thing model needs three more things.
Declare which fields reach the UI. Configuration includes:
| Item | Description |
|---|---|
| Field name | Must match the key in telemetry_data exactly — case-sensitive |
| Alias | Front-end display name |
| Unit | °C, %, V, m³, … |
| Type | number / string / boolean / object — choose number for numeric fields so formatting works |
| Order | Display order in tables or cards |
| Icon | Optional, embedded SVG supported |
A mismatched field name is the number one cause of "the script has data but the page is blank".
Alarms belong to trigger models, not to the thing model. The recommended pattern handles creation and clearing in one trigger script: default action to "clear" and switch to "new" only when the condition is met. Every uplink then clears alarms whose condition has returned to normal, with no manual intervention.
tklHelper provides the TriggerHelper base class — extend it and override _alarmProcess(info) instead of hand-writing the full trigger return object. Utils.LEVEL defines four levels (low / mid / high / urgent) and Utils.ACTION defines no / new / clear.
This is the final step in building and industrial handovers.
BACnet: define BACnet fields on the thing model, with field names matching the thing model definition, plus object type (Analog Input, Binary Output, …), unit, COV increment, default value, and linked RPC. Then go to Operations → BACnet, click incremental generate, select the target thing model, and the platform produces a point list with object type, instance number, object name, description, data type, unit, and read/write permissions — ready to hand to the BMS integrator. Use YABE (Yet Another BACnet Explorer) during commissioning to confirm data is published correctly.
Modbus: the Modbus mapping on the thing model exposes telemetry fields at register addresses so a SCADA master can read them over Modbus TCP. Control in the reverse direction is handled by RPC models; RPCHelper offers buildmodbusFrame06 (FC06 write single register), buildmodbusFrame10 (FC10 write multiple), buildmodbusFrameRead (FC03/FC04 read), with CRC-16/Modbus built in.
In one sentence: the decoder decides whether data can be read out; display fields, alarms, and protocol mapping decide whether the business can use it.
consoleThe sandbox has no console.log, which is the hardest adjustment when starting out. Four techniques replace it:
The Test tab in the thing model editor. Enter a sample Base64 payload and port, see the parse result immediately. Normal tests require the device to exist; otherwise the platform returns "Device not found".
The skipDeviceCheck flag. When you only want to validate script logic without a real device (for example when configuring an NFC template), the test action supports skipDeviceCheck = true. The platform then skips the EUI lookup and injects a synthetic placeholder device (eui "empty", owned by the current tenant), so the test completes even with no matching device.
Thing model logs. Runtime output and exceptions land here, replacing console.
Version history and restore. Every save records a snapshot. Open History in the editor to inspect versions, compare against neighbours, and restore — restoring creates a new version record, so history is never lost. RPC models, thing models, trigger models, and forwarder script editors all share this. You can iterate freely; mistakes are reversible.
One more shortcut: check the public model library first. Under Marketplace plugins → Model Center there are two tabs, "Organisation models" and "Public models". Common device types usually already have a thing model — copy and adapt rather than starting from zero.
If your team already wrote parser scripts on another LoRaWAN platform, you do not need to rewrite them. ThinkLink thing models offer a ChirpStack compatibility mode — the interface is already adapted, so pasting in the original parser code works as-is.
That matters because of the scale of migration cost. Research by the Eclipse Foundation and PW Consulting puts migration cost at 35%–60% of the total migration budget. Decoder scripts are the most fiddly part of a LoRaWAN application layer, the hardest to verify automatically, and the easiest place to introduce fresh bugs during a rewrite. The more you can carry over verbatim, the less uncertainty you carry.
At an engineer cost of roughly USD 50/hour, writing and commissioning decoder scripts from scratch for a mid-size project (10 device types) typically takes 40–60 hours, or USD 2,100–3,100. Reusing existing scripts cuts that to about a third. What you save is not typing time — it is site verification time, which is the real cost driver in LoRaWAN projects.
1. Endianness reversed. 1238 reads as 56594, giving 5559.4 °C. No error, just nonsense, usually discovered at acceptance. Confirm from the manual; do not guess by trying both.
2. Forgetting two's complement. Signed fields (int8 offsets, int16 temperatures) must have 256 / 65536 subtracted when the raw value is ≥ 128 / ≥ 32768. Without this, every negative value becomes positive.
3. toFixed without Number(). Returns a string; sorting and threshold checks break silently.
4. No port check. Ports 11 (measurement), 214 (parameters), and 51 (transparent) carry completely different structures. Without branching, three message types hit one parser.
5. No length check. After a firmware update changes frame length, or when a frame from another model arrives, an unchecked parser reads out of range. SafeBuffer may return false or throw, but reading a valid-but-wrong offset is worse.
6. Parameter replies written into telemetry_data. The reporting interval gets plotted as a measurement and the trend chart is ruined. Parameters belong in shared_attrs; measurements in telemetry_data.
7. Display field name not matching script output. The top cause of "data exists, page is blank". Field names are case-sensitive.
8. Treating a working script as a finished job. Display fields, Modbus mapping, and BACnet fields are three separate configurations; a script does not imply a mapping. BACnet point lists must be generated separately.
The division of labour in LoRaWAN is clear and not reversible: the end device compresses as hard as possible, the network carries it, the platform restores it into business meaning.
The decoder looks like the least glamorous of the three layers — 15 bytes, 40 lines of JavaScript, half an hour of work. But it determines the final shape of the whole chain:
sub_device turns 16 meters behind one DTU into 16 independent objects)ManThink's product line is built around this chain: KS51 / KS52 / KS53 / KS61 sensors produce compact frames, GD61x / GDI51 / GDI52 / GD73 / GDO51 gateways carry them, KC11 / KC25 / KC21 bring existing RS-485 meters onboard, and ThinkLink handles decoding, alarming, protocol mapping, and forwarding on the platform side.
All three ThinkLink deployment forms — Cloud (free for up to 1,000 devices), TKE on-premises (1,000 devices, unlimited data processing and storage), and TKG built into the gateway (100 devices, one gateway is a complete system) — share the same thing models, RPC models, and trigger rules, and can migrate between each other.
That matters especially for decoder scripts: a thing model you write on Cloud runs unchanged on TKE or TKG. Scripts are assets, not accessories of one deployment shape.
telemetry_data, configuration to shared_attrs, child addresses to sub_deviceframeInfo for validation, appInfo for fields, paraInfo for parameters, with 30+ data types including the floatcdab byte order common on Modbus devicessub_device makes every RS-485 meter behind a DTU an independent device — the premise for brownfield retrofitsThe next article covers trigger models and alarm rules: once the data is decoded, how to make sure the right person hears about it the moment a value goes out of range.
References: ThinkLink documentation — Thing Model https://www.think-link.net/docs/zh/Model/ThingModel.html, tklHelper https://www.think-link.net/docs/zh/Model/tklHelper.html, RPC Model https://www.think-link.net/docs/zh/Model/RPCModel.html, Trigger Model https://www.think-link.net/docs/zh/Model/TriggerModel.html, KS52 sensor manual https://www.think-link.net/docs/zh/Device/LoRaWANSensor/ks52.html. Airtime figures calculated with the Semtech LoRa Calculator (SX1262, BW125 kHz, CR 4/5).