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.


What is actually inside one uplink

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.


1. Why devices cannot just send JSON

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.


2. Where ThinkLink puts decoding

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.


3. Where the script runs: sandbox rules first

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.

3.1 Injected variables

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.

3.2 What to read from msg

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

3.3 Return value: four fields, and getting them wrong loses data

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.


4. First script: hand-written Buffer version

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 }

4.1 Six decisions hidden in that code

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.


5. Second script: declarative rewrite with tklHelper

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.

5.1 frameInfo — frame validation

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.

5.2 appInfo — variable definitions

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.

5.3 The complete declarative version

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 };

5.4 Which style to use when

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

5.5 MSparser — multiple sensors in one frame

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


6. Parameter replies: port 214 and shared attributes

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.

6.1 Offset is not COV

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.


7. sub_device: turning one DTU into N devices

sub_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:

  • The parent (DTU) keeps its LoRaWAN identity and link attributes
  • Each RS-485 slave becomes an independently queryable, alarmable, chartable object
  • Upstream systems never need to know which DTU packaged the data

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.


8. After decoding: from fields to business value

A running script is step one. A deliverable thing model needs three more things.

8.1 Display fields

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

8.2 Alarms

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.

8.3 BACnet and Modbus mapping

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.


9. Debugging without console

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


10. Bringing existing scripts across

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.


11. Eight mistakes, ranked by how often they appear on site

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.


12. Getting this layer right

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:

  • It decides how long the battery lasts (tighter encoding, less airtime) — revisit the 2700 mAh cell from the earlier article: dropping from SF7 to SF12 multiplies transmit time by 24.9
  • It decides whether business systems can consume the data (field names, units, and types are fixed here)
  • It decides whether you can hand over a point list (BACnet / Modbus mapping hangs off the same field definitions)
  • It decides whether brownfield assets can be managed per device (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.


Summary

  • Compact binary in LoRaWAN is not a preference, it is a necessity: at SF7, 15 bytes takes 46.3 ms versus 199.9 ms for a 120-byte JSON payload (4.3×); CN470 DR0 caps the payload at 15 bytes
  • Decoding in ThinkLink lives in the thing model, running in a vm2 sandbox: 1000 ms timeout, no console, no async, no require, and the return value as the only output channel
  • Each return field has its place: measurements to telemetry_data, configuration to shared_attrs, child addresses to sub_device
  • tklHelper offers a declarative style: frameInfo for validation, appInfo for fields, paraInfo for parameters, with 30+ data types including the floatcdab byte order common on Modbus devices
  • Port 214 parameter replies need their own branch; offset and COV are different concepts, and negative offsets need two's complement handling
  • sub_device makes every RS-485 meter behind a DTU an independent device — the premise for brownfield retrofits
  • After decoding, configure display fields, alarm rules, and BACnet/Modbus mapping; a working script is not a completed delivery
  • Existing parser scripts can be reused through compatibility mode; what you save is mostly site verification time

The 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).