1. Alarm systems rarely fail by staying silent

Most IoT projects ask for one thing from alarms: "make noise when something breaks." But what actually kills an alarm system is not silence — it is alerts nobody reads.

Public data on the scale of the problem:

Metric Value Source
Daily alert volume in a security operations centre ~2,992/day, with about 63% never addressed Vectra AI 2026
Same picture, different sample ~2,047/day, of which 300 (15%) go unchecked for lack of resources Binalyze, 2026 State of the SOC (Sapio survey of 400 CISOs)
Consequence of overload 91% of CISOs report information overload; 77% say it caused them to miss a warning or vulnerability Same
Industrial false-alarm rate 40%–90% of sensor alarms turn out to be false (drift, ambient noise, calibration decay) Consistent range across industrial maintenance studies
Cost of repetition Each repeated alert of the same kind reduces the odds an operator acts by roughly 30% Industrial alarm management research

A figure worth converting: in a plant monitoring 180 assets, a 35% false-positive rate consumes around 1,200 technician-hours a year chasing nothing — about US$42,000 annually.

(The first two rows come from security operations, not IoT. The mechanism is identical though: the louder the noise floor, the more easily the one alert that matters disappears into it.)

So this article is not about generating more alerts. It is about three properties:

  1. Arrives on time — the alarm fires within one reporting cycle, guaranteed and documented;
  2. Does not spam — one event produces one alarm, and it clears itself when the condition clears;
  3. Reaches the right person — urgent goes to a phone, routine goes to a list, and the two never mix.

2. Three timing budgets you have to calculate first

On LoRaWAN, the first design step is not writing a script. It is accepting a physical fact: a battery-powered end device will not wake up early just because something went wrong. That produces three timelines:

Stage Determined by Typical magnitude What you can do
Detection latency Uplink period (collection interval + change-of-value reporting) 5–60 minutes; longer if COV never trips Shorten the collection interval or tighten the COV threshold
Downlink latency Class A devices only open receive windows after their own uplink (RX1 ≈ 1 s, RX2 ≈ 2 s by LoRaWAN 1.0.4 defaults); miss them and you wait for the next uplink 1–2 s at best, a full uplink period at worst Use Class C for critical loops; otherwise write the latency into the SLA
Offline detection Platform marks a device offline after "last activity time + 2 × heartbeat period" Depends on configured heartbeat Set the heartbeat to 2–3× the uplink period

The takeaway: LoRaWAN cannot deliver second-level alarms, but it can deliver alarms that are never later than one reporting cycle. Put that sentence in the proposal before a customer discovers it during acceptance testing.

3. Three components, and all three are required

Alarming on ThinkLink is not "set a threshold on the device." It is three parts working together:

Part Responsibility Where to configure it
Trigger model Runs a script on every uplink, evaluates conditions, returns actions Model management → Trigger model → attach to device
Built-in ALARM RPC Accepts action: new / clear, creates or clears the alarm record, dispatches to notification groups Device management → device detail → RPC → add ALARM
Notification group Decides who gets it and over which channel Alarm management → Notification group

The order is fixed: attach the ALARM RPC first, then the trigger model, then (optionally) the notification group. Miss any one of them and the alarm is mute — there is no such thing as "the script exists, so it will ring."

One device can carry multiple trigger models, so multi-condition scenarios do not have to be crammed into one script.

4. The execution environment: bare function body in a vm2 sandbox

One detail trips up nearly everyone: the editor expects a bare function body. The platform wraps your code in function triggerRunner() { ... } before calling it, so you should not add another function trigger_script(...) {} wrapper. The trigger_script form appears in the documentation only to illustrate which variables are available.

The sandbox is vm2, with these limits:

Item Value
Execution timeout 1000 ms; exceeding it skips this trigger entirely
eval / WebAssembly Disabled
async / await Not supported; scripts run synchronously
require / import Not supported
setTimeout / setInterval / console Unavailable — use the delayms return field for deferred execution, and the trigger-model log for debugging
process / path / fs Unavailable
Buffer SafeBuffer, exposing only alloc, from, isBuffer, byteLength, compare, concat

Injected variables you can use directly: device, thingModelId, org_params, env, plugins, Buffer, plus the six tklHelper classes (BufferTKL, PayloadParser, MSparser, Utils, TriggerHelper, RPCHelper).

Fields of device that matter most for alarming:

Field Meaning
device.telemetry_data[thingModelId] Latest telemetry snapshot for that thing model (the entry point for readings)
device.server_attrs Platform-side attributes; the right place for thresholds, notification groups, linked EUIs
device.shared_attrs Attributes synchronised both ways with the device
device.active_time Timestamp of the last uplink (ISO 8601)
device.heart_period Heartbeat period in seconds, used for offline detection
device.eui / device.name Device identifier and name

Note that device and the helper classes are injected frozen — assignments to them have no effect. The return value is the only output channel.

5. Four return fields, one of which is a free noise filter

A trigger script returns either null (do nothing) or an object with these fields:

Field Type Meaning
delayms Number Delay in milliseconds before executing actions
abort_previous_timer Boolean When true, cancels the previous pending delayed task and replaces it with this one
should_dispatch Boolean Defaults to false: if the data is unchanged from last time, dispatching is skipped. Set true to force it
actions Array RPC calls executed in order

Fields of each actions item:

Field Meaning
method RPC method name (alarm for alarms)
methodId Alternative to method; takes precedence when both are present
params._eui Target device EUI — inside params, not a top-level field of the action

should_dispatch defaulting to false is the first noise filter you get for free. Identical data on consecutive uplinks will not dispatch actions again. Many teams do not know this and build their own de-duplication on top of it.

6. A script you can copy

Temperature plus battery, with thresholds and notification groups externalised into server_attrs so that changing a threshold never touches the script:

// The trigger script is a bare function body; the platform wraps it in triggerRunner() let tdata = device?.telemetry_data?.[thingModelId]; if (!tdata) return null;

const LIMIT_TEMP = device.server_attrs?.alarm_temp ?? 60; const LIMIT_BAT = device.server_attrs?.alarm_battery ?? 20; const notify = device.server_attrs?.notify ?? [];

let actions = [];

// 1) Overheat: keep title and level stable so de-duplication works let hot = tdata.temperature >= LIMIT_TEMP; actions.push({ method: "alarm", params: { _eui: device.eui, name: "overheat_alarm", title: "[Overheat] " + device.name, level: "urgent", desc: "Temperature above limit, immediate action required", action: hot ? "new" : "clear", notify: notify } });

// 2) Low battery: record only, do not disturb (empty notify array) let lowBat = tdata.battery <= LIMIT_BAT; actions.push({ method: "alarm", params: { _eui: device.eui, name: "low_battery_alarm", title: "[Low battery] " + device.name, level: "mid", desc: "Battery below threshold, schedule a replacement", action: lowBat ? "new" : "clear", notify: [] } });

return { delayms: 0, abort_previous_timer: true, actions };

Parameters of the ALARM RPC:

Parameter Meaning
name Unique identifier of the alarm event; one name per alarm type
action new raises, clear clears, no does nothing
title Title shown in the UI
desc Description text
level low / mid / high / urgent
notify Array of notification group names; empty array means record but do not send

Matching server_attrs on the device:

Field Example Purpose
alarm_temp 60 Temperature threshold
alarm_battery 20 Battery threshold
notify ["ops-team"] Notification group list

If you would rather not hand-build the return object, extend TriggerHelper and override _alarmProcess(info). The constructor reads device.server_attrs.notify automatically and produces the standard return structure (where the field is named group, corresponding to notify on the ALARM RPC). If _alarmProcess returns false, or the action is no, alarm() returns null.

7. Four severity levels and a routing matrix

There are exactly four levels: low (informational), mid (warning), high (error), urgent (critical). The level itself triggers nothing — what matters is the level × notification group combination:

Situation Suggested level notify Expected response
Water leak, gas, server-room overheat urgent On-call group (WeCom webhook) Immediately
Out of range but tolerable (temperature drift) high Ops team email Same shift
Low battery, degrading signal quality mid Empty array, list only Scheduled work
Parameter changed, firmware version mismatch low Empty array Next inspection

Agree on this table with the customer during kickoff. Alarm severity is not a technical question, it is an ownership question — every level needs a named owner, otherwise urgent ends up as ignored as everything else.

8. Six ways to cut the noise

  1. Leave should_dispatch alone. Its false default means unchanged data does not dispatch again. Only set true where a forced dispatch every time is genuinely required.

  2. Keep name, title, level and desc stable. The platform suppresses an alarm that is identical in name, title, description and level. So do not concatenate the live reading into desc — 61.2 and 61.5 are two different alarms, de-duplication breaks, and you wake up to forty entries. If you need to show a value, show a band or a category ("above upper limit").

  3. Default to clear. Set action to clear and switch to new only when the condition holds. Every uplink then doubles as a recovery check, and alarms disappear on their own once things are normal. Do the opposite (send new only on breach) and the alarm hangs forever if the device goes offline.

  4. Use delayms for persistence checks. For noisy measurements (door contacts, water leaks, momentary excursions), do not raise on the first breach. Return a delayms with abort_previous_timer: true so each new uplink cancels the previous pending action — only a sustained excursion survives long enough to become an alarm.

  5. Put thresholds and groups in server_attrs. Changing a threshold then requires no script edit, no new version, no re-attachment. Five hundred devices can share one trigger model and each keep its own thresholds — that is what makes this scale.

  6. Require two signals for escalation. Temperature high while humidity moves too is a different level of confidence from temperature spiking alone. Single-parameter breach stays at mid; two parameters together escalate to high. This removes a meaningful share of false positives.

9. Notification groups: what they actually support

Notification groups live under Alarm management → Notification group. Id Name, name, notification method and contacts are mandatory; tags and notes are optional.

Item Actual behaviour
Built-in methods Email (default) and WeCom webhook
Contacts Multiple selection
Enable switch Off by default — you must turn it on
DingTalk / Slack / Telegram Not in the built-in form; they require a custom RPC

This deserves emphasis: describing DingTalk, Slack and Telegram as built-in channels is inaccurate. They are reachable through a custom RPC, which is real but not a checkbox. Write it up at face value, or acceptance testing will not match the proposal.

One practical habit: store group names as an array in device.server_attrs (for example notify: ["ops-team"]) and pass it straight through to notify. Severity routing then becomes an attribute change rather than a script change — the same trigger model across 500 devices can route each device to its own group.

10. Three recipes you can apply directly

Scenario Condition Level Notify Cleared by
KS52 water leak water_leak == 1 urgent On-call WeCom group Next uplink with normal reading
Low battery battery <= 20 mid Empty array (list only) After battery replacement
Device offline active_time older than 2 × heart_period high Ops team email Next uplink

One caveat on offline detection: the platform exposes three different online notions (the activity-time basis used in lists and statistics, the server_attrs.online detector, and the MQTT data.online notification). They are not interchangeable. When you implement offline alarms in a trigger script, state explicitly that you are using active_time and heart_period, and document it.

11. Pre-launch checklist

  • [ ] The ALARM RPC is attached to the target device (not just the trigger model)
  • [ ] The trigger model is attached on the device's Trigger tab (not merely created)
  • [ ] _eui is present inside params, not at the action's top level
  • [ ] name is unique and stable, one name per alarm type
  • [ ] level is one of the four legal values — a missing level means no alarm
  • [ ] Thresholds and groups live in server_attrs, and the script has defaults when they are absent
  • [ ] action defaults to clear, not "send new only on breach"
  • [ ] desc does not embed the live reading (otherwise de-duplication fails)
  • [ ] The notification group has been manually enabled (it defaults to off)
  • [ ] Both the new and clear paths have been exercised in the editor's test tab

12. Eight mistakes seen repeatedly

Symptom Cause
Script never runs The trigger model was created but never attached to the device
Script runs, no alarm appears The ALARM RPC is not attached to the device
Alarm spam Live values concatenated into desc, bypassing de-duplication
Alarms that never clear Only new is ever sent; once a device goes offline the alarm hangs
Threshold change has no effect Threshold hard-coded in the script while you edited server_attrs
ReferenceError in the script Used console, setTimeout or process, none of which the sandbox injects
Script appears to hang Used async/await; the sandbox is synchronous
"Mark all read" treated as recovery That is a read state; alarm clearing and notification delivery are two separate things

Closing

A usable alarm system is not defined by its script. It is defined by three judgement calls: what counts as a real anomaly, who must know, and when to stay quiet. ThinkLink hands all three to scripts and attribute configuration — flexible enough that one model can serve 500 devices with different thresholds, at the cost of requiring someone to think the rules through before go-live.

Of the six noise-reduction techniques above, the two most often skipped are number 2 (keep desc stable) and number 3 (default to clear). Skip either and the system looks fine for the first week — then nobody opens the alarm list again.

Next in the series (issue 23): keeping data inside the plant — deploying ThinkLink Edge (TKE) on premises, from hardware selection and Docker Compose to two-way migration of thing models with the cloud.