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:
- Arrives on time — the alarm fires within one reporting cycle, guaranteed and documented;
- Does not spam — one event produces one alarm, and it clears itself when the condition clears;
- 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
-
Leave
should_dispatchalone. Itsfalsedefault means unchanged data does not dispatch again. Only settruewhere a forced dispatch every time is genuinely required. -
Keep
name,title,levelanddescstable. The platform suppresses an alarm that is identical in name, title, description and level. So do not concatenate the live reading intodesc— 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"). -
Default to
clear. Setactiontoclearand switch tonewonly 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 (sendnewonly on breach) and the alarm hangs forever if the device goes offline. -
Use
delaymsfor persistence checks. For noisy measurements (door contacts, water leaks, momentary excursions), do not raise on the first breach. Return adelaymswithabort_previous_timer: trueso each new uplink cancels the previous pending action — only a sustained excursion survives long enough to become an alarm. -
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. -
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 tohigh. 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
ALARMRPC 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)
- [ ]
_euiis present insideparams, not at the action's top level - [ ]
nameis unique and stable, one name per alarm type - [ ]
levelis 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 - [ ]
actiondefaults toclear, not "sendnewonly on breach" - [ ]
descdoes not embed the live reading (otherwise de-duplication fails) - [ ] The notification group has been manually enabled (it defaults to off)
- [ ] Both the
newandclearpaths 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.