Skip to content

1. RPC Model

ThinkLink (hereinafter referred to as TKL) RPC Model provides the ability to remotely control and configure parameters for LoRaWAN devices. By defining standardized Remote Procedure Calls, users can issue instructions to devices, set working parameters, or trigger specific actions, enabling intelligent operation and maintenance management.

1.1. New RPC

In the TKL platform, create a new RPC command by following these steps:

  1. Navigate to Model Management → RPC Model → Add.
  2. Configure basic information and script logic.

1.2. Parameter Information

FieldDescription
Field IdentifierThe variable name of the parameter in the script — i.e., the key in the params object. For example: period represents the reporting interval value and will be read from user input in the script.
MethodThe function name used when invoking the RPC via MQTT or other interfaces.
AliasThe display name shown in the user interface, improving readability. For example: "Set Reporting Interval" helps users understand the purpose of the parameter.
InheritWhether this RPC can be inherited by sub-devices:
true: Sub-devices under this device can use this RPC.
false: Sub-devices cannot use this RPC.

✅ Multiple parameter fields can be added to support complex control requirements.

1.3. RPC Scripting

TKL supports writing custom encoding scripts in JavaScript. These scripts convert user-provided inputs into data formats compliant with the target device's communication protocol, then send them via the downlink.

Example script:

javascript
let classMode = (device && device.shared_attrs && device.shared_attrs.class_mode) || "ClassA";
let sleepMs = classMode === "ClassA" ? 200 : 10000;
let isClassA = classMode === "ClassA";

function getDevicesInfo() {
    let buffer = Buffer.alloc(4);
    buffer[0] = 0x8F;
    buffer[1] = 2;
    buffer[2] = 100;
    buffer[3] = 96;
    return buffer.toString("base64");
}

function processSubAddr(subAddr, modelHex) {
    let addrBuffer;
    let laddrBuffer = Buffer.alloc(7);
    let substr = subAddr.replaceAll(" ", "");
  
    if (substr === "nc" || substr === "") { 
        return null;
    }
    if (modelHex.length != 10) {  
        return null;
    }
  
    let subnum = parseInt(substr, 10);
    if (subnum === 0) {
        for (let i = 0; i < 7; i++) { 
            laddrBuffer[i] = 0; 
        }
    } else {
        addrBuffer = Buffer.from(substr, 'hex');
        if (addrBuffer.length !== 7) { 
            return null;
        }
        for (let i = 0; i < 7; i++) { 
            laddrBuffer[i] = addrBuffer[6 - i]; 
        }
    }
  
    let hexStr = laddrBuffer.toString('hex') + modelHex;
    const buffer = Buffer.from(hexStr, 'hex');
    return buffer;
}

function encode(params) {
    let buffer = Buffer.alloc(98);
    buffer[0] = 0xCF;
    buffer[1] = 76;
    buffer[2] = 100;
    buffer[3] = 96;

    let dataSize = 0;
    let serverAttrs = {};

    for (let i = 0; i < 6; i++) {
        const subAddr = params['sub_addr' + (i + 1)];
        if (!subAddr || subAddr === "nc") {
            buffer.writeUint32LE(0xFFFF, i*2+4); // Set as 10 years
            continue;
        }

        const modelHex = params['model' + (i + 1)];
        if (modelHex === "0000000000") {
            buffer.writeUint32LE(0xFFFF, i*2+4);
            continue;
        }

        let period = params['period' + (i + 1)];
        let payload = processSubAddr(subAddr, modelHex);

        serverAttrs['sub_' + subAddr.replaceAll(" ", "")] = {
            addr: subAddr.replaceAll(" ", ""),
            model: modelHex,
            period: period,
        };

        serverAttrs['model' + (i + 1)] = modelHex;
        serverAttrs['period' + (i + 1)] = period;

        if (payload === null) {
            buffer.writeUint32LE(0xFFFF, i*2+4);
            continue;
        }

        period = (period) & 0x7FFF;
        period |= 0x4000;
        buffer.writeUint32LE(period, i*2+4);
        payload.copy(buffer, 26 + i*12, 0, 12);
        dataSize += 12;
    }

    if (dataSize === 0) { 
        return null; 
    }

    buffer[1] = 24 + dataSize;
    buffer[3] = 22 + dataSize;

    let retBuffer = Buffer.alloc(26 + dataSize);
    buffer.copy(retBuffer, 0, 0, 26 + dataSize);

    return {
        sAttrs: Object.keys(serverAttrs).length < 1 ? null : serverAttrs,
        payload: retBuffer.toString("base64")
    };
}

let rdata = encode(params);
if (rdata === null) { 
    return null; 
}

return [
    {
        sleepTimeMs: 100,
        type: "modifyAttrs",
        dnMsg: {
            server_attrs: rdata.sAttrs,
        }
    },
    {
        sleepTimeMs: 0,
        dnMsg: {
            "version": "3.0",
            "type": "data",
            "if": "loraWAN",
            "moteeui": device.eui,
            "token": new Date().getTime(),
            "userdata": {
                "confirmed": isClassA,
                "fpend": false,
                "port": 214,
                "TxUTCtime": "",
                "payload": rdata.payload,
                "dnWaitms": 3000,
                "type": "data",
                "intervalms": 0
            }
        }
    },
    {
        sleepTimeMs: sleepMs,
        dnMsg: {
            "version": "3.0",
            "type": "data",
            "if": "loraWAN",
            "moteeui": device.eui,
            "token": new Date().getTime() + 1,
            "userdata": {
                "confirmed": true,
                "fpend": false,
                "port": 214,
                "TxUTCtime": "",
                "payload": getDevicesInfo(),
                "dnWaitms": 3000,
                "type": "data",
                "intervalms": 0
            }
        }
    }
];

1.3.1. Sandbox Environment & Constraints

RPC scripts run inside a vm2 sandbox as an async function, fully isolated from the Node.js host process.

Runtime limits

ConstraintValueDetails
Execution timeout1000 msThe script is killed after 1 second; the RPC call fails.
eval❌ DisabledDynamic code execution via eval is blocked.
WebAssembly❌ DisabledLoading or executing .wasm modules is blocked.
async / await✅ SupportedRPC scripts are async; await axios(...) and similar calls work.
require / import❌ Not supportedNo external modules can be imported.
process, path, fs, etc.❌ Not availableThese Node.js globals are not injected; referencing them throws ReferenceError.
setTimeout / setInterval❌ Not availableTimer globals are absent. To introduce delays between multiple downlink steps, use the sleepTimeMs field in each returned action object.
console❌ Not availableUse the injected logger object (info / warn / error) for debugging.
Buffer✅ Available (restricted)A restricted SafeBuffer is injected. Available static methods: alloc, from, isBuffer, byteLength, compare, concat. allocUnsafe and allocUnsafeSlow are blocked. Instance read/write methods (readUInt8, writeUInt16LE, etc.) work normally.

All available sandbox globals

NameSourceNotes
devicePlatform-injected, read-onlyTarget device object (vm.freeze).
paramsPlatform-injected, read-onlyUser-supplied parameter object (vm.freeze).
alarmsPlatform-injected, read-onlyCurrent alarm state map for the device (vm.freeze).
loggerPlatform-injectedRPC log object; usage mirrors console (info / warn / error).
axiosPlatform-injected, read-onlyPre-configured Axios instance (30 s timeout) for calling external HTTP APIs (vm.freeze).
org_paramsPlatform-injected, read-onlyOrganization-level environment variables (vm.freeze).
envPlatform-injected, read-onlyEnvironment variables declared by mounted plugins (advanced).
pluginsPlatform-injected, read-onlyPlugin instances mounted on this RPC (advanced).
SafePromisePlatform-injected, read-onlyTimeout-aware Promise base class; class Promise extends SafePromise {} is pre-injected in the script wrapper — no manual use needed.
BufferTKL / PayloadParser / MSparser / Utils / TriggerHelper / RPCHelpertklHelper, read-onlyPlatform utility library (vm.freeze). See tklHelper.

Note: device, params, alarms, and all tklHelper classes are injected via vm.freeze. Assigning to their properties inside the script will not throw but will have no effect. To modify device attributes, return a type: "modifyAttrs" action in the result array.

1.3.2. Input Parameters

  1. device

The target device object injected by the platform (vm.freeze, read-only). Full property reference:

PropertyTypeDescription
euistringDevice EUI (16-char hex, globally unique).
namestringDevice name.
device_typestringDevice type: "NORMAL" / "SUB_DEVICE" / "VIRTUAL".
data_fromstringData source: "LoRaWAN" or "OTHER".
parentstring | nullParent device EUI (sub-device only). Set target: device.parent for sub-device downlinks.
onlinebooleanWhether the device is currently online.
active_timestringTimestamp of the last uplink (ISO 8601).
shared_attrsobjectAttributes synchronized bidirectionally with the device.
server_attrsobjectPlatform-side-only attributes (device cannot read).
mac_attrsobjectLoRaWAN MAC-layer attributes (band, ADR, etc.).
telemetry_dataobjectLatest telemetry snapshot per Thing Model, keyed by thingModelId. Example: device.telemetry_data["45616600866361349"].TP.
thing_modelstring[]Mounted Thing Model ID array.
rpcstring[]Mounted RPC model ID array.
tagsstring[]Device tag array.
tenant_codestringTenant identifier.
heart_periodnumberHeartbeat detection period (seconds).

📌 When calling an RPC via MQTT or HTTP, you must explicitly provide the device EUI using the field name _eui.

  1. params

An object containing all user-input parameters provided through the UI or API.

AttributeDescription
Attr NameUsed in JS scripts as params.xxx to retrieve input values.
IndexDetermines the display order of parameters in the UI.
AliasFriendly name shown in the UI for better understanding.
TypeSupported types: number, string, boolean, object, variant. variant is RPC-input only (not offered in the thing-model / trigger field editors); see §1.3.2.2 Variant Type.
Default ValueValue used if no input is given.
UnitPhysical unit appended during display (e.g., s, min, ).
OptionsDropdown list options in key-value pair format.
Hidden ConditionControls whether the parameter is shown in the "Execute RPC" input dialog. See §1.3.2.1 Parameter Hidden Condition.

⚠️ Every field in the Execute dialog is required — every input must be "submittable without typing"

In the "Execute RPC" dialog the operator cannot submit unless every data item is filled, so when designing the template every field must be submittable without manual input, via one of two routes:

  1. Configure default_from: ① "fixed" + dftValue (a constant default); or ② "shared_attrs" / "server_attrs" + attr_field_name (backfill the device's current value — and the rpc_script must write it back in a closed loop to the same attribute key after echo / successful send).
  2. For a parameter that genuinely has no default value or backfill source: leave it without a default and treat null as the reserved meaning of "the user entered nothing" — the rpc_script must explicitly check params.X == null and handle it safely (skip that parameter / return null), and must never write a missing parameter as 0 / empty (which could send a wrong command or even zero out a register).

A variant's discriminator field (set the default selected option via "fixed" + dftValue) and each of its extra fields follow this same rule.

1.3.2.1. Parameter Hidden Condition

Each input parameter can carry a Hidden Condition that shows or hides the field in the "Execute RPC" input dialog — typically used so a switch field controls whether another group of fields appears. There are four choices:

ChoiceBehavior
Not hidden (default)The field is always shown.
Always hide (true)The field is permanently hidden in the dialog, but its default value is still submitted with the command.
Always show (false)Equivalent to "Not hidden"; an explicit declaration that the field is always visible.
Comparison operatorPick == / === / != / > / >= / < / <=, then choose another input field of the same RPC as the reference and enter a compare value. At runtime the reference field's current value is compared with the compare value; when the result is true, the current field is hidden.

⚠️ A hidden field's default value is still submitted. Whether hidden via "Always hide" or because a comparison condition matched, the field is not dropped from the payload — the platform still sends its default value. Always give a hidden field a sensible default.

Example: parameter mode (dropdown: auto / manual) and parameter interval (only relevant in manual mode). Give interval the hidden condition == mode auto, i.e. "hide interval when mode equals auto" — switching to auto collapses interval, switching back to manual reveals it again.

Stored internally as a hidden object: { "operator": "==", "field": "mode", "value": "auto" }; when operator is true/false, only operator is kept.

When the reference field is a variant type, the comparison automatically uses its discriminator value — in the flat submitted shape the variant field itself is the discriminator string (e.g. params.mode is simply "auto"), so you don't write mode.value in the hidden condition.

1.3.2.2. Variant Type

variant is an RPC-input-only field type: a dropdown that selects a discriminator value, where each option carries its own group of "extra fields". In the "Execute RPC" dialog, once a user picks an option, that option's extra fields expand right below the dropdown for input; switching to a different option clears the previous option's extra-field values. It is typically used for "pick a command type first, then show the parameters for that type".

How to configure (in the RPC model edit dialog):

  1. Set the field Type to variant.
  2. Add options row by row in the Options table, filling in Name (display text) and Value (discriminator value).
  3. The Extra Fields [n] button at the end of each row opens the "Manage Extra Fields" dialog, where you configure any number of sub-fields for that option (sub-fields have an attr name, type, default value, unit, options, etc. just like ordinary inputs).

Submitted shape (flat, single level — important): a variant field is not a nested object inside params; it is flattened — params.<variant field name> is itself the discriminator string, and each extra field is a sibling parameter params.<extra field name>:

json
"msg": {
    "cmd": "set_interval",
    "interval": 30,
    "unit": "s"
}

Here cmd (the variant field) holds the chosen option's discriminator string, and interval / unit are that option's extra fields as sibling parameters.

⚠️ How to read it in the script: const which = params.cmd; (a string), and read the extra fields from siblings params.interval / params.unit. Never treat params.cmd as an object and read .value / .<sub-field> — in the flat shape that yields undefined, which commonly makes the script silently return null.

⚠️ The source mirror may differ from production: the source/tkl-web mirror (rpc-input-modal.tsx / dynamic-filed-input-item.tsx) form name paths look like they submit a nested object { value, ...extra fields }, and there is no flatten step anywhere in the chain; but the live deployed platform was verified to submit the flat string shape, meaning the source mirror lags behind the deployed version. Always treat the flat shape verified in production as authoritative.

Constraints:

  • A sub-field cannot share the same name as the variant field itself (in the flat shape they are siblings, so a name clash overwrites one another).
  • Sub-fields cannot nest another variant and do not support hidden conditions (kept simple).
  • Sub-fields support attribute backfill: default_from may be "shared_attrs" / "server_attrs" to resolve the device's live value (same as ordinary inputs), or "fixed" + dftValue for a constant default. The backfill key is attr_field_name (not the field name); without attr_field_name no backfill happens.
  • A variant must have Options configured (used as discriminator values), otherwise it cannot render.
  • A hidden variant field still submits in the flat shape (discriminator + each extra field as siblings); if the user never touches it, it falls back to the default discriminator (the default option set via "fixed" + dftValue) plus each extra field's default.

Best practice (merged setpoint pattern): when merging several setpoints of the same kind (e.g. "humidity setpoint / temperature setpoint") into one variant, name each extra field after its discriminator value itself (the humidity option → extra field named humidity), so the script can uniformly read params[params.<variant field name>]; point attr_field_name at the real attribute key (e.g. humidity_set), decoupled from the field name. After a successful send, the rpc_script writes the same attribute key back in a closed loop, so the dialog can backfill from the current shared_attrs.

  1. alarms

Stores alarm information for the corresponding device. You can check whether a specific alarm (identified by alarm_name) exists by accessing alarms[alarm_name]. The RPC code can perform corresponding logic based on the status of the relevant alarm.

  1. logger

Logs for RPC operations. The usage of logger is consistent with console, where user-defined messages must be passed as an Object assigned to the params variable. The logger supports three log levels: info, warn, and error, facilitating message filtering and searching by severity.

Example:

javascript
logger.info("set my paras", { params: paras })
  1. axios

Pre-configured Axios instance (30-second timeout). See §1.3.4 Calling External HTTP APIs for full details and examples.

  1. org_params

Organization-level (tenant-level) "environment variables" maintained under System Management → Server Configuration → Org Params. The platform injects them automatically when the RPC runs.

Use them for constants that are shared across RPCs and must not be exposed to callers — e.g., third-party bot webhooks, external API access keys, or base URLs that differ between environments. Rotating a key only requires editing the server configuration; every RPC picks up the new value automatically.

Full field conventions and security notes: Server Configuration §1.6 Org Params.

Signature:

javascript
function rpc_script({ device, params, alarms, logger, org_params }) {
    let webhook = org_params?.wecom_webhook_url;
    // ... use webhook to send request
}

⚠️ Do not copy org_params values into device.server_attrs or log bodies — that re-exposes the secret you were trying to keep hidden from callers and log readers.

1.3.4. Calling External HTTP APIs

RPC scripts support two ways to call external HTTP/HTTPS endpoints, suited to different scenarios:

Method 1: Inline await axios(...) (when you need the response)

Use when you need to read the external response before deciding what to do next — e.g., calling a third-party auth service, querying a business system, or computing downlink parameters dynamically.

javascript
let resp = await axios({
    method: "GET",
    url: "https://api.example.com/check",
    params: { eui: device.eui },
    headers: { Authorization: "Bearer xxxxx" }
});
if (!resp.data?.allowed) {
    return null;    // External system denied — do not dispatch
}
return [ /* ... normal downlink actions ... */ ];

Request options follow the Axios API; maximum timeout is 30 seconds.

Method 2: Return type: "axios" action (fire-and-forget)

Use when you do not need the response — e.g., webhook notifications, forwarding telemetry to a third-party platform. The platform sends the request asynchronously after the script completes, so it does not count against the 1-second script timeout and is better suited for slow external targets.

javascript
return [
  {
    sleepTimeMs: 0,
    type: "axios",
    dnMsg: {
      method: "POST",
      url: "https://api.example.com/notify",
      headers: {
        "Authorization": "Bearer xxxxx",
        "Content-Type": "application/json"
      },
      data: {
        eui: device.eui,
        name: device.name,
        telemetry: device.telemetry_data,
        time: new Date().toISOString()
      },
      timeout: 5000
    }
  }
];

dnMsg follows the Axios request config format; timeout maximum is 30000 ms. Failures (non-2xx, timeout, network error) are automatically written to the RPC log.

Inline await axios(...)Return type: "axios"
Can read response
Counts against 1 s script timeout❌ (async after script)
Suited for slow requests
Response-driven logic

1.3.3. Return Parameters

A single RPC can execute multiple sequential commands. Each item in the returned array conforms to one of the following structures:

Type 1: Send Device Command (LoRaWAN or Non-LoRaWAN Devices)

Used to deliver standard-formatted messages via Topic.

[EN] PTL-S05 ASP LoRaWAN NS and Application Server Communication Protocol

The message follows this JSON structure:

json
{
    "version": "3.0",
    "type": "data",
    "if": "loraWAN",
    "moteeui": "ABCDEF1234567890",
    "token": 1712345678901,
    "userdata": {
        "confirmed": true,
        "fpend": false,
        "port": 214,
        "TxUTCtime": "",
        "payload": "base64_encoded_data",
        "dnWaitms": 3000,
        "type": "data",
        "intervalms": 0
    }
}

🔗 For non-LoRaWAN devices, the same format should be listened on their designated topics.

Type 2: Modify Device Attributes

Use type: "modifyAttrs" to update device attributes (server_attrs or shared_attrs) within the platform database, without sending any data to the physical device.

Example — update server_attrs:

json
{
    "sleepTimeMs": 100,
    "type": "modifyAttrs",
    "dnMsg": {
        "server_attrs": {
            "covtemp": 15
        }
    }
}

This operation writes the specified attribute directly into the TKL database.

⚠️ No actual message is sent to the device. This only updates internal platform state.

Type 3: Alarm

The alarm function is implemented by setting type: "alarm". For a device or asset to enable the alarm feature, users must configure trigger model logic. The triggering conditions are defined in the trigger model, and alarm notifications are dispatched via the alarm RPC.

Field descriptions:

FieldDescription
actionType of alarm action: "new" creates a new alarm event; "clear" clears the existing alarm event.
alarm_nameName of the alarm event. Each name represents a unique alarm type. Different alarm events must have distinct names.
notice_groupsNotification groups. When selected, an email will be sent to the associated group when the alarm event occurs.
titleTitle displayed when the alarm event occurs.
descDescription of the alarm event.
levelAlarm severity level: "low", "mid", "high", "urgent".

Example:

javascript
{
    sleepTimeMs: 0,
    target: device.eui,
    type: "alarm",
    dnMsg: {
        action: "new",
        data: {
            alarm_name: "alarm test",
            notice_groups: [],
            title: title,
            desc:  "this is a alarm",
            level: "high",
        }
    }
}

Type 4: Call an External HTTP API

Set type: "axios" to have the RPC call an external HTTP/HTTPS endpoint during its execution. This is useful for integrating with business systems, third-party platforms, webhooks, and similar scenarios. The dnMsg field follows the Axios request config format.

Field descriptions:

FieldDescription
methodHTTP method: "GET" / "POST" / "PUT" / "DELETE" / "PATCH" etc. Defaults to "GET".
urlFull request URL, including the protocol (http:// or https://).
headersCustom request headers, e.g. { "Authorization": "Bearer xxx", "Content-Type": "application/json" }.
paramsURL query parameters object. Serialized and appended to the URL automatically.
dataRequest body (for POST/PUT/PATCH). Can be a JSON object, a string, or a Buffer.
timeoutRequest timeout in milliseconds. Maximum is 30000 (30 seconds); larger values are clamped.

Example — when a device raises a temperature alarm, push the device info to an external business platform:

javascript
return [
  {
    sleepTimeMs: 0,
    type: "axios",
    dnMsg: {
      method: "POST",
      url: "https://api.example.com/notify",
      headers: {
        "Authorization": "Bearer xxxxx",
        "Content-Type": "application/json"
      },
      data: {
        eui: device.eui,
        name: device.name,
        telemetry: device.telemetry_data,
        time: new Date().toISOString()
      },
      timeout: 5000
    }
  }
];

📌 Unlike downlink actions, this action type does not require a target field — the request goes to an external HTTP service and is unrelated to a device EUI. Failed requests (non-2xx, timeout, network error, etc.) are automatically written to the RPC log for troubleshooting.

Type 5: Send Notification Only (notify)

Set type: "notify" to push a message to the selected notice groups without writing into the alarm store, without updating any alarm state machine, and without de-duplication. Use it whenever you only need to send a message — daily reports, ops event reminders, third-party callback summaries, etc. — and there is no need to keep an entry in the ThinkLink alarm center or to issue a paired clear action later.

Difference vs. type: "alarm":

Aspectalarmnotify
Writes to alarm store✅ Persists to alarm_latest / alarm_history; visible in the alarm center❌ Not persisted, no trace in the alarm center
Requires action / alarm_name / level✅ Required (drive de-dup & state machine)❌ Not required
De-duplicates "same alarm"✅ Same name+title+desc+level is treated as duplicate; new is suppressed❌ Always sent
Dispatches to notice_groups✅ (shares the same notice-group dispatch as alarm)

Field descriptions:

FieldDescription
titleMessage title. Used as the email subject for the email channel, and as the first line of the markdown body for WeCom group-bot.
descMessage body. The format depends on the channel type of the receiving notice_groups (see below).
notice_groupsArray of notice-group names; the platform dispatches per the channel configured under System Management → Notice Groups.

desc format is determined by the notice group's channel type:

Notice group channelHow desc is renderedFormat to write
Email (email)Sent as the email HTML bodyHTML fragment (<div>, <font>, <table>, etc.)
WeCom group-bot (WeComWebHook)Pushed via msgtype: "markdown" with body title\ndescMarkdown (# heading, inline <font color="red">…</font>, and the rest of the subset WeCom supports)

If a single notify action lists notice groups of multiple channel types (e.g. email + WeCom), the same desc is rendered each channel's way — meaning you have to pick one format and accept suboptimal rendering on the other. To get clean rendering everywhere, split into one type: "notify" per channel, each written in the channel's native format.

Example (showing both desc styles):

javascript
return [
  {
    sleepTimeMs: 0,
    target: device.eui,
    type: "notify",
    dnMsg: {
      data: {
        title: "# Test",
        desc: "<font color=\"red\">test body</font>",   // WeCom markdown
        notice_groups: ["WeCom_test"]
      }
    }
  },
  {
    sleepTimeMs: 0,
    target: device.eui,
    type: "notify",
    dnMsg: {
      data: {
        title: "Test",
        desc: "<div style=\"color:red\">test body</div>", // Email HTML
        notice_groups: ["email_test"]
      }
    }
  }
];

💡 Need both "keep a record" and "push a message"? Use type: "alarm". Need "push only, no record"? Use type: "notify". They share the same notice-group dispatch path, so per-channel rendering rules (WeCom / email) behave identically.

Common Fields in Downlink Commands

FieldDescription
sleepTimeMsDelay (in milliseconds) before sending this command. Useful for controlling timing between multiple downlinks.
targetDefault: EUI of the target device. When operating on sub-devices, set to device.parent, because the command must be forwarded through the parent device.
typeSpecifies the type of instruction: default (normal downlink message), modifyAttrs (update server/shared attributes), alarm (raise or clear an alarm), axios (call an external HTTP API), notify (send message to notice groups only, no alarm record).

Best Practice: Always test RPC scripts in a development environment before deployment. Use the built-in debugger to validate output payloads and ensure proper encoding.

1.4. Mount RPC

A created RPC must be bound to a specific device before it can be used.

  • Operation Path: Operation and Maintenance Management → Device Management → select target device → Details → RPC
  • Operation Steps:
    1. On the Device Details page, click the RPC tab.
    2. Click Add and select the created RPC from the drop-down list.
    3. Repeat to add multiple different RPCs to the same device.

✅ Supports mounting multiple RPCs on one device, suitable for multi-function control scenarios.

1.5. Execute RPC

After the RPC is successfully mounted, you can call the device remotely.

  • Operation Path: Same as above — navigate to Device Details → RPC Management.
  • Operation Steps:
    1. Locate the mounted RPC entry.
    2. Click the Execute button in the corresponding operation column.
    3. An input window pops up; fill in the parameter values (following the "Alias" prompt).
    4. After confirmation, the system calls the script to generate instructions and sends them to the device.

The execution result can be viewed in the log or device response, depending on the device return mechanism and confirmation mode (Confirmed/Unconfirmed).

Through flexible configuration of the RPC Model, TKL achieves fine-grained remote control of LoRaWAN devices, providing an efficient means for device debugging, configuration updates, and fault resolution.

1.5.1. Bind to a Device Execution (Scheduled Dispatch)

Manual Execute is an immediate, one-time dispatch. If you want an RPC dispatched automatically on a cycle, bind it to a Device Execution task — the device execution defines "which devices, which RPC," while its bound scheduled task (a cron trigger) defines "when."

  • Operation Path: Maintenance → Device Management → target device → Details → RPC, then click "+ Add to Device Execution" in the "Bound Device Executions" column.
  • Create New Device Execution: pre-fills the current RPC and device EUI; in the drawer, pick a scheduled task, the execution type (Unlimited / Fixed Count), the per-device send interval, whether to store logs, etc., and save.
  • Add to Existing: appends the current device to an already-existing device execution task.

The "Bound Device Executions" column lists, as tags, the device execution tasks this RPC is associated with. The dropdown on each tag lets you:

  • Detail: open the device execution's detail drawer to view/edit.
  • Delete: remove the current device from this execution's target device list (only this device is removed; the device execution task itself is not deleted).

Orphan RPC notice: if a device execution references an RPC that is not mounted on the current device, that RPC is shown in red with a "This RPC is not bound to the current device" tooltip, and its "Execute" button is disabled. Either mount the RPC on the device from the RPC list first, or remove this device from the corresponding device execution.

Once a scheduled task is bound, the platform dispatches the RPC to the target devices automatically on every cron cycle, with no manual trigger. You can also "Execute Now" or "Stop" from the Device Execution list page.

For full Device Execution and Scheduled Task fields, cron expressions, and execution logs → Device Action · Scheduled Tasks

1.6. Alarm RPC

ThinkLink has a built-in general-purpose alarm RPC function called ALARM. To use it, mount the ALARM RPC onto the corresponding device or asset. After configuring the trigger model, the alarm functionality is activated. The default ALARM implementation is as follows:

javascript
function rpc_script({device, params, alarms, logger}) {
    const ACTION = { no: "no", "new": 'new', clear: 'clear' };
    let notify = params?.notify ?? [];
    let alarm_name = params?.name ?? "[alarm]";
    let action = params?.action ?? ACTION.no;
    let title = params?.title ?? "[tile]";
    let desc = params?.desc ?? "this is a description of alarm";
    let level = params?.level ?? "low";
    switch (action) {
        case ACTION.clear:
            if (!alarms[alarm_name]) action = ACTION.no;
            break;
        case ACTION.no:
            return null;
        case ACTION.new:
            let alarmInfo = alarms[alarm_name];
            if (alarmInfo == undefined) { break; }
            if (alarmInfo.title !== title || alarmInfo.desc !== desc || alarmInfo.level !== level) {
                break;
            }
            action = ACTION.no;
            break;
        default: break;
    }
    if (action == ACTION.no) return;
    return [
        {
            sleepTimeMs: 0,
            target: device.eui,
            type: "alarm",
            dnMsg: {
                action: action,
                data: {
                    alarm_name: alarm_name,
                    notice_groups: notify,
                    title: title,
                    desc: desc,
                    level: level,
                }
            }
        }
    ];
}

📌 Upgrade note: the parameter group (previously shaped as { notify: [...] }) has been replaced by notify (a flat string array [...]). Trigger models and callers must pass the new form.

1.7. RPCHelper

RPCHelper is a utility class provided by tklHelper that covers common RPC operations: sending alarm events, building parameter write/read frames, constructing LoRaWAN downlink messages, and generating Modbus frames. Import it via tklHelper in your RPC scripts.

1.7.1. Alarm — RPCHelper.makeAlarm()

Builds the standard alarm return array for an RPC script, with built-in deduplication: if an identical alarm (same name, title, desc, level) already exists, "new" is suppressed and null is returned.

javascript
RPCHelper.makeAlarm({
    alarms,          // alarms object from rpc_script input
    eui,             // target device EUI
    name,            // unique alarm event name
    action,          // "new" | "clear"
    group,           // notification group array
    title,           // alarm title
    desc,            // alarm description
    level            // "low" | "mid" | "high" | "urgent"
})

Example:

javascript
function rpc_script({ device, params, alarms, logger }) {
    return RPCHelper.makeAlarm({
        alarms: alarms,
        eui:    device.eui,
        name:   params.name,
        action: params.action,
        group:  params.notify ?? [],
        title:  params.title,
        desc:   params.desc,
        level:  params.level
    });
}

1.7.2. LoRaWAN Parameter Frames — paraWrite / paraRead

Build single-register read/write frames for the ThinkLink parameter protocol.

javascript
// Write one register
let buf = RPCHelper.paraWrite(name, type, addr, value);
// name: register namespace — "app" | "cf" | "fw" | "radio" | "ds" | "status"
// type: data type string (e.g. "uint16le")
// addr: register address (decimal or 0x-prefixed hex)
// value: value to write

// Read one register
let buf = RPCHelper.paraRead(name, type, addr);

1.7.3. Batch Parameter Frame — RPCHelper.buildFrame()

Builds a combined write + read frame for multiple registers across different namespaces, sorted by address. Handles fragmentation and frame headers automatically.

javascript
let result = RPCHelper.buildFrame({
    serverParaDef,  // array of { field_name } — fields to save to server_attrs
    paraDef,        // object mapping "namespace_addr" keys to { field_name, type, ... }
    params          // params object from rpc_script
});
// Returns: { writeBuffer, readBuffer, serverPara, log }

Example:

javascript
function rpc_script({ device, params, alarms, logger }) {
    let paraDef = {
        app_40:  { name: "upPeriod",  field_name: "upPeriod",  type: "uint32le" },
        app_142: { name: "measPeriod",field_name: "measPeriod",type: "uint16le" },
    };
    let result = RPCHelper.buildFrame({ paraDef: paraDef, params: params });
    if (!result || result.writeBuffer.length === 0) { return null; }

    return [
        {
            sleepTimeMs: 100,
            type: "modifyAttrs",
            dnMsg: { server_attrs: result.serverPara }
        },
        RPCHelper.makeMSG({
            msgType: "paras",
            device:  device,
            dnBuffer: result.writeBuffer,
            confirmed: true,
            dnWaitms: 3000
        })
    ];
}

Wraps a raw buffer into the correct ThinkLink downlink JSON envelope.

ParameterDescription
msgType"user" (custom port), "paras" (port 214), "transParent" (port 51), "swDown" (LoRa SW)
deviceDevice object
dnBufferBuffer containing the payload
portPort number (only for "user" type)
confirmedWhether to use confirmed downlink
sleepTimesleepTimeMs value in the returned item
dnWaitmsWait time for downlink window (ms)
javascript
RPCHelper.makeMSG({
    msgType:  "paras",
    device:   device,
    dnBuffer: writeBuffer,
    confirmed: true,
    dnWaitms: 3000,
    sleepTime: 0
})

1.7.5. Device Control Commands

Shorthand buffers for common LoRaWAN device operations:

javascript
RPCHelper.reset()  // Reset the device
RPCHelper.redo()   // Re-execute last command
RPCHelper.join()   // Force re-join

1.7.6. Modbus Helpers

javascript
// Build a Modbus FC03/FC06 read frame
RPCHelper.buildmodbusFrameRead(addr, code, regStart, count)

// Build a Modbus FC06 single-register write frame
RPCHelper.buildmodbusFrame06({ serverParaDef, paraDef, params })

// Build a Modbus FC10 multi-register write frame
RPCHelper.buildmodbusFrame10({ serverParaDef, paraDef, params })

// Build a generic Modbus action frame
RPCHelper.modbusAction(addr, code, regAddr, regVal)

1.8. Version History & Restore

Every time you save an RPC, the platform records a version snapshot. Click History Versions in the RPC editor to browse earlier versions. Each entry offers:

  • Detail — view the snapshot's content (and compare it against neighboring versions).
  • Restore — roll the current value back to that snapshot. Restoring creates a new version entry (so nothing in the history is lost) and reloads the list to the first page. A confirmation prompt notes that restoring produces a new version.

The same History Versions / Restore controls are available on the Thing Model, Trigger Model, and Forwarder Script editors.