Device Automation: Threshold Control, Scheduling & Multi-Device Assets
1. Overview
ThinkLink (TKL) builds the "sense → decide → act" loop from three native capabilities: trigger models (real-time decisions), scheduled tasks + device actions (time-based scheduling), and virtual devices (assets) (cross-device aggregation). Using a "dehumidifier + humidity sensor" as the running example, this note shows how to wire the three most common automations:
| Automation | Solves | Core capability | Section |
|---|---|---|---|
| Threshold control | Auto on/off when a reading crosses a high/low limit | Trigger model (hysteresis) | §3 |
| Scheduled on/off | Auto-on and auto-off at set times | Scheduled task + device action | §4 |
| Multi-device control | One (or more) sensors drive a separate actuator | Virtual device (asset) aggregation | §5 |
They compose: "auto on/off by humidity during working hours, forced off outside the window, notify on every switch" is §3 + §4 + §5 combined — see §6.
Answer one question before you start (§2): are sensing and acting the same device? The answer decides whether you must create an "asset" — the single easiest step to get wrong, and getting it wrong makes nothing work.
2. Step one: do you need an "asset"?
The platform has two low-level hard constraints (operators cannot work around them; understanding them explains when an asset is mandatory):
- A trigger model runs only on its own device's uplink, and sees only that device's data. Mounted on the actuator, it cannot read the separate sensor's readings.
- Cross-device data linkage / relations (device_relation) only take effect on a "virtual device (asset)". Wiring a relation into a normal device does nothing.
Hence the decision gate:
| Paradigm A: build an asset | Paradigm B: no asset | |
|---|---|---|
| Criterion | Sensing and acting are two (or more) separate devices | Sensing + acting in one device (it both reports and can be controlled), or pure time scheduling |
| Typical | Humidity sensor KS52 drives dehumidifier PKO | A dehumidifier that both measures humidity and switches itself; scheduled lighting |
| Control logic lives on | A virtual device (asset) | The actuator itself |
| How it links | Asset aggregates sensor data → asset trigger decides → dispatches to the actuator by EUI | Trigger mounted on the actuator itself, controls itself via its own EUI |
Cost of choosing wrong: making a "same-device" case into an asset adds a pointless aggregation layer; making a "separate-device" case into a trigger on the actuator means it never reads the sensor and never fires. Answer this first, then proceed.
The threshold logic (§3) and scheduling logic (§4) apply to both paradigms; the only difference is where the logic is mounted — on the asset or on the device itself. §5 covers how to build the Paradigm A asset.
3. Threshold control (hysteresis dead-band)
The most basic automation: turn on when a reading crosses the high limit, turn off when it drops below the low limit. The high limit is strictly greater than the low limit; the gap between them is a dead-band that prevents chatter — it stops the actuator from flapping when the reading jitters around a single threshold.
3.1 Store the thresholds and switch in config
Control parameters all live in the device's (or asset's) server_attrs, written by a single config RPC — operators never hand-edit attributes:
{
"auto_enabled": true, // master switch; false = fully manual, trigger stands down
"hum_high": 70, // turn-on threshold
"hum_low": 55, // turn-off threshold (high > low = hysteresis dead-band)
"notify": ["Dehum Ops"] // notice group name
}3.2 The trigger model decides
The trigger model (see Trigger Model) runs on every uplink; its shape is always "gate → hysteresis → dispatch only on transition":
function trigger_script(device, thingModelId) {
const sa = device?.server_attrs || {};
if (sa.auto_enabled !== true) return null; // gate: stand down if fully manual
const td = device?.telemetry_data?.[thingModelId];
if (!td || td.humidity == null || td.power_switch == null) return null;
const power = td.power_switch; // 0/1: use the device's real report as "state memory"
const { hum_high, hum_low } = sa;
if (hum_high == null || hum_low == null) return null;
let desired = null;
if (td.humidity >= hum_high && power !== 1) desired = 1; // above high → ON
else if (td.humidity <= hum_low && power === 1) desired = 0; // below low → OFF
if (desired === null) return null; // inside dead-band / no change → do nothing
return {
delayms: 2000, abort_previous_timer: true, should_dispatch: true,
actions: [
{ method: "pko_power_action", params: { _eui: device.eui, power_switch: desired === 1 } }
]
};
}Key points:
- State memory comes from the device's real report (
power_switch), not from the trigger storing it — triggers cannot persist variables. Re-sending the same switch command is idempotent, so the brief window before the actuator confirms its new state is harmless. - Dispatch only on transition:
return nullinside the dead-band or when nothing changed — no command spam. - Gate on the first line: when
auto_enabled=falsethe whole thing stands down, so operators can flip back to fully manual in one move.
4. Scheduled on/off (scheduled task + device action)
Use the platform's native scheduled task + device action for timed on/off — do not read the system clock inside a trigger model: trigger scripts run sandboxed, time out in ~1 second, get no Date injected, and have no controllable time zone.
The new UI lets you "Add to device action" directly from a device's RPC config panel to attach a schedule to an RPC. See Scheduled Tasks and Device Action. Two records work together:
Scheduled task (cronTask) — defines when:
json{ "name": "Dehum-ON 08:00", "cron": "0 0 8 * * *", "type": "MULTI_DEVICE_RPC", "enabled": true }cronis 6 fields (with seconds), in the server's local time zone (ManThink servers run UTC+8, i.e. write Beijing time).- Give it a human-readable name (
Dehum-ON 08:00, nottask1).
Device action (multiDeviceRpc) — binds which RPC + which devices + which schedule:
json{ "name": "Dehum-scheduled-on", "rpc_id": "<switch RPC id>", "euis": ["<actuator EUI>"], "cron_task": "<cronTask id above>", "enable": true, "type": "UNLIMITED", "device_interval_ms": 3000, "params": { "rpcParams": { "power_switch": true } } }
⚠️ A scheduled RPC must carry all of its parameters itself. On scheduled execution the platform only sends
params.rpcParams(plus the device_eui) — no form, no attribute backfill. So write the full parameter set intorpcParams; any RPC that relies on "open the form, backfill the current value" will receive empty params when scheduled (details in RPC Model). A script missing a parameter should safelyreturn null, never default it to0/empty (which may wrongly clear registers).
One "on" cron (window start) plus one "off" cron (window end) makes a scheduled on/off window.
5. Multi-device control: build a virtual device (asset)
When one sensor must drive a separate actuator (Paradigm A), you must use a virtual device (asset) as the control hub — because the two hard constraints in §2 prevent the logic from living on either real device directly.
sensor(s) ──relation──┐
├──► virtual asset ──(aggregator model)──► zone humidity
actuator ──relation──┘ ──(asset trigger)──► dispatch switch to actuator by EUI
──(asset trigger)──► notify RPC (station / email)5.1 Build steps (platform side)
All done via the platform API / UI (tenant-level objects, a normal admin is enough — not PUBLIC). See Device Management:
- Create a notice group: System Management → Notice Groups, bind station + email channels.
- Create the virtual asset: device type
VIRTUAL, mounting "aggregator model + asset trigger + config RPC + notify RPC". The asset EUI follows the conventiona10+ 5-digit template/biz code + the actuator EUI's last 8 hex (thea1prefix marks it as an asset). - Create relations: each sensor → asset, and the actuator → asset, with
notify_fields:["telemetry_data"]. - Initialize config: run the config RPC once to write the actuator EUI, high/low limits, notice group, and
control_mode.
5.2 Aggregator model: roll sensor data into asset data
The asset's thing model (see Thing Model) runs whenever any bound device changes; it distinguishes "actuator" from "sensor" by EUI and rolls multiple sensors' humidity into one "zone humidity":
const sa = device?.server_attrs || {};
const pkoEui = sa.pko_eui;
const members = { ...(sa.members || {}) };
let pkoPower = sa.pko_power;
const srcTd = {};
const all = msg?.telemetry_data || {};
for (const k in all) { Object.assign(srcTd, all[k]); }
if (msg.eui === pkoEui) {
if (srcTd.power_switch !== undefined) pkoPower = srcTd.power_switch; // actuator → state memory
} else if (srcTd.humidity !== undefined) {
members[msg.eui] = { h: srcTd.humidity }; // sensor → humidity
}
const hums = Object.values(members).map(s => s.h).filter(v => v != null);
return {
telemetry_data: { zone_humidity: hums.length ? Math.max(...hums) : null },
server_attrs: { members, pko_power: pkoPower },
shared_attrs: null
};5.3 Asset trigger: decide and dispatch to the actuator
Same hysteresis as §3.2, with two differences: it reads the asset-aggregated zone_humidity, and dispatches with params._eui pointing at the actuator, plus a notification:
function trigger_script(device, thingModelId) {
const sa = device?.server_attrs || {};
if (sa.control_mode !== "auto") return null; // gate: only auto closes the loop
const td = device?.telemetry_data?.[thingModelId];
if (!td || td.zone_humidity == null) return null;
const { hum_high, hum_low, pko_eui } = sa;
const pkoPower = sa.pko_power;
if (hum_high == null || hum_low == null || !pko_eui) return null;
let desired = null;
if (td.zone_humidity >= hum_high && pkoPower !== 1) desired = 1;
else if (td.zone_humidity <= hum_low && pkoPower === 1) desired = 0;
if (desired === null) return null;
const onoff = desired ? "ON" : "OFF";
return {
delayms: 2000, abort_previous_timer: true, should_dispatch: true,
actions: [
{ method: "pko_power_action", params: { _eui: pko_eui, power_switch: desired === 1 } },
{ method: "dehum_notify", params: {
_eui: device.eui,
title: `[${device.name}] dehumidifier turned ${onoff}`,
desc: `Zone humidity ${td.zone_humidity}%RH, auto ${onoff}`,
notice_groups: sa.notify ?? []
}}
]
};
}The config RPC writes the asset's own
server_attrs(mode / thresholds / notice group / actuator EUI); it cannot directly call another device's RPC — only a trigger's_euirouting can dispatch across devices. On/off events usetype:"notify"(station + email, no alarm-center entry, no manual clearing); reserve the Alarm RPC for water-full, device fault, and the like.
6. Composing all three: scheduled window + in-window hysteresis + notify
Real projects often stack the three: auto on/off by humidity during working hours, forced off outside the window, notify on every switch. Coordinate them with one control_mode state machine:
control_mode | Who drives the actuator | Trigger |
|---|---|---|
manual | Nobody automated; device self-regulates to its own setpoint | return null (stands down) |
auto | The trigger (closed loop on the reading) | takes over |
schedule | Scheduled task + device action | return null (stands down) |
Two coordination rules when composing:
- The time window is flipped by a scheduled task setting a flag (e.g.
in_window); the trigger only reads the flag, never the system clock. The open-window cron setsin_window:1; the close-window cron setsin_window:0and turns off (one cron can carry multiple device actions). - The trigger only does: master-switch gate → forced off outside the window (safety net) → §3 hysteresis inside the window.
7. Make the config human-readable (strongly recommended)
Automation logic should be understandable at a glance by ops/customers, without reading code:
- Give each automation RPC / trigger an operator-facing remark: state "what this does + prerequisites (which config RPC to run first, which notice group to set)", not internal field paths.
- Have the config RPC also write a plain-language policy summary into
server_attrs.auto_policy, e.g."Runs 08:00–20:00; on at ≥70%RH, off at ≤55%RH; forced off outside the window". It is a human-facing mirror, not what drives the logic — the live values arehum_high/hum_lowetc.; re-sync this summary when you change the control law. - Use human-readable names for scheduled tasks / device actions (
Dehum-ON 08:00), so ops understand the schedule straight from the device-action list.
8. Pitfalls & common mistakes
| Mistake | Reality |
|---|---|
| Separate-device case mounts the trigger on the actuator | Trigger sees only its own device, can't read sensor data → must build a virtual asset (§2) |
| Wiring a sensor relation into a normal device | Cross-device relations only take effect on VIRTUAL assets; useless on a normal device |
| Reading the system clock in a trigger for scheduling | Trigger scripts time out in ~1s, get no Date, time zone uncontrollable → flip a flag via a scheduled task (§4/§6) |
| Storing last switch state in a trigger variable | Triggers can't persist; use the device's real reported state field as memory |
| Single-threshold switching | A reading jittering near the threshold flaps on/off → use a high/low hysteresis dead-band (§3) |
| A scheduled RPC relying on form backfill | Scheduling sends only params.rpcParams — no form, no backfill → write full params, return null if missing |
| Using the Alarm RPC for routine on/off | Alarms enter the alarm center and need clearing; use type:"notify" for events |
| Config RPC calling another device's RPC | An rpc_script cannot dispatch across devices; put cross-device actions in the trigger's _eui routing |