Skip to content

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:

AutomationSolvesCore capabilitySection
Threshold controlAuto on/off when a reading crosses a high/low limitTrigger model (hysteresis)§3
Scheduled on/offAuto-on and auto-off at set timesScheduled task + device action§4
Multi-device controlOne (or more) sensors drive a separate actuatorVirtual 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):

  1. 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.
  2. 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 assetParadigm B: no asset
CriterionSensing and acting are two (or more) separate devicesSensing + acting in one device (it both reports and can be controlled), or pure time scheduling
TypicalHumidity sensor KS52 drives dehumidifier PKOA dehumidifier that both measures humidity and switches itself; scheduled lighting
Control logic lives onA virtual device (asset)The actuator itself
How it linksAsset aggregates sensor data → asset trigger decides → dispatches to the actuator by EUITrigger 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:

json
{
  "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":

javascript
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 null inside the dead-band or when nothing changed — no command spam.
  • Gate on the first line: when auto_enabled=false the 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:

  1. Scheduled task (cronTask) — defines when:

    json
    { "name": "Dehum-ON 08:00", "cron": "0 0 8 * * *", "type": "MULTI_DEVICE_RPC", "enabled": true }
    • cron is 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, not task1).
  2. 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 into rpcParams; 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 safely return null, never default it to 0/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:

  1. Create a notice group: System Management → Notice Groups, bind station + email channels.
  2. Create the virtual asset: device type VIRTUAL, mounting "aggregator model + asset trigger + config RPC + notify RPC". The asset EUI follows the convention a10 + 5-digit template/biz code + the actuator EUI's last 8 hex (the a1 prefix marks it as an asset).
  3. Create relations: each sensor → asset, and the actuator → asset, with notify_fields:["telemetry_data"].
  4. 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":

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

javascript
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 _eui routing can dispatch across devices. On/off events use type:"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_modeWho drives the actuatorTrigger
manualNobody automated; device self-regulates to its own setpointreturn null (stands down)
autoThe trigger (closed loop on the reading)takes over
scheduleScheduled task + device actionreturn null (stands down)

Two coordination rules when composing:

  1. 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 sets in_window:1; the close-window cron sets in_window:0 and turns off (one cron can carry multiple device actions).
  2. The trigger only does: master-switch gate → forced off outside the window (safety net) → §3 hysteresis inside the window.

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 are hum_high/hum_low etc.; 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

MistakeReality
Separate-device case mounts the trigger on the actuatorTrigger sees only its own device, can't read sensor data → must build a virtual asset (§2)
Wiring a sensor relation into a normal deviceCross-device relations only take effect on VIRTUAL assets; useless on a normal device
Reading the system clock in a trigger for schedulingTrigger 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 variableTriggers can't persist; use the device's real reported state field as memory
Single-threshold switchingA reading jittering near the threshold flaps on/off → use a high/low hysteresis dead-band (§3)
A scheduled RPC relying on form backfillScheduling sends only params.rpcParams — no form, no backfill → write full params, return null if missing
Using the Alarm RPC for routine on/offAlarms enter the alarm center and need clearing; use type:"notify" for events
Config RPC calling another device's RPCAn rpc_script cannot dispatch across devices; put cross-device actions in the trigger's _eui routing