Skip to main content

Script Development

This article is intended for developers and delivery engineers who need to extend the capabilities of ThingsGatewayRuntime scripts. It's not a typical field configuration manual, but rather a guide explaining how scripts are compiled, loaded, registered, bound, and executed, and how each script type should be written.

Source Code Entry

Source CodeFunction
ThingsGatewayRuntime.Application/Expressions/ExpressionInfo.csScript type enumeration, script metadata, input/output parameter definitions.
ThingsGatewayRuntime.Application/Expressions/DynamicBase.csRuntime base classes for data transformation, memory variables, dynamic models, dynamic SQL, MQTT RPC, and complete source code.
ThingsGatewayRuntime.Application/Expressions/CustomExpressionBase.csCustom node script base class and output change callback.
ThingsGatewayScriptCompiler/ExpressionCodeGenerator.cswraps the scripts filled in the page into C# classes and compiles them into DLLs.
ThingsGatewayRuntime.ExpressionsGenerator/ExpressionRegistrationGenerator.csCompile-time scanning script class, via ModuleInitializer registers into ExpressionsData.
ThingsGatewayRuntime.Application/Expressions/ExpressionsData.csSave the registered script delegate and search for script instances by name.
ThingsGatewayRuntime.Application/Expressions/ExpressionsHelper.csRuntime executes data transformation, dynamic model, dynamic SQL, and MQTT RPC according to the script name.
ThingsGatewayRuntime.Application/Controllers/GatewayScriptController.csWEB script management interface: create, save, compile, batch compile, delete, hotload.
ThingsGatewayRuntime.Application/Controllers/RuleEngine/GatewayCustomNodeController.csCustomize, save, compile, and hotload nodes.
ThingsGatewayRuntime.Application/Task/RuleEngine/RuleEngineTask.csStarts the rule flow, creates node instances, propagates input/output, and triggers anti-shake.
ThingsGatewayRuntime.Application/Expressions/RuntimeDllLoader.csloads PluginDlls,ScriptDlls, CustomNodeDlls at startup.

Total Life Cycle

  1. Create scripts or custom nodes on the web, and write source code, classifications, descriptions, and parameters into the database.
  2. When compilation is clicked, the runtime calls the external ThingsGatewayScriptCompiler process.
  3. The compiler wraps the source code by script type: write a regular script ScriptDlls, a custom node writes CustomNodeDlls.
  4. The source generator generates registration code inside the script DLL, and when the module loads, it writes ExpressionsData.
  5. After successful compilation, the DLL is hotloaded during runtime; When the service starts, PluginDlls,ScriptDlls, CustomNodeDlls are also loaded.
  6. Functional configuration binds scripts by script name, such as variable read expressions, memory variable expressions, data forwarding entity scripts, historical table scripts, MQTT RPC scripts, and rule nodes.
  7. Execute the script once the trigger condition is met. Script exceptions affect corresponding functions, such as offline variables, batch forwarding failures, unresponsive RPCs, or rule nodes stopping propagation.
  8. When deleting scripts, database records are deleted and the corresponding DLL is attempted; If the DLL is occupied, the deletion logic will attempt to rename it to .del.

Type Overview

TypePage Type ValueBase ClassUser Code StyleMain Binding Location
Data TransformationDataTransExpressionDatatransExecute Method BodyCollect variables to read and write expressions.
Memory Variable CalculationMemoryVariableDatatransMemoryVariableExpressionDatatransExecute Method BodyMemory Variable Read Expression.
Dynamic Model - VariableDynamicModel_VariableBasicDataDynamicModelBase<VariableBasicData>GetList Method BodyVariable entity script for data forwarding target.
Dynamic Model - DeviceDynamicModel_DeviceBasicDataDynamicModelBase<DeviceBasicData>GetList Method BodyDevice entity script for data forwarding target.
Dynamic Model - AlarmDynamicModel_AlarmVariableDynamicModelBase<AlarmVariable>GetList Method BodyAlert entity script for data forwarding targets.
Dynamic Model - Plugin EventDynamicModel_PluginEventDataDynamicModelBase<PluginEventData>GetList Method BodyPlugin Event Entity Script for Data Forwarding Target.
Dynamic SQL variablesDynamicSQL_VariableBasicDataDynamicSQLBase<VariableBasicData>class member source codeHistorical and real-time data table scripts.
Dynamic SQL - DeviceDynamicSQL_DeviceBasicDataDynamicSQLBase<DeviceBasicData>class member source codeframework support; Standard targets currently have no direct consumption entry points and are usually used for secondary targets.
Dynamic SQL AlarmDynamicSQL_AlarmVariableDynamicSQLBase<AlarmVariable>Class member source codeHistorical alarm table script.
Dynamic SQL plugin eventsDynamicSQL_PluginEventDataDynamicSQLBase<PluginEventData>class member source codeframework support; Standard targets currently have no direct consumption entry points and are usually used for secondary targets.
MQTT RPCMQTTDynamicRPCMQTTDynamicRPCBaseClass Member Source CodeRPC script for the MQTT Client/Server target.
Full SourceFullSourceDynamicBaseComplete C# SourceAdvanced Extensions, Allows custom code to retrieve instances by name.
Custom NodesCustomNodeCustomExpressionBaseInitAsync/ChangedAsync method or full node classRule engine flow node.

Public Rule

The script name is the runtime lookup key. Do not frequently change names in the production environment; Variables, forwarding targets, MQTT RPC, or rule flows are saved by script names.

Not all page scripts are written into the entire class. DataTrans, MemoryVariableDatatrans, and four types of dynamic models are written as the method body; Dynamic SQL and MQTT RPC write class members; FullSource Write the full source code; Custom nodes generate properties based on input and output parameters on the node page, then insert the code into the class.

scripts can use System,System.Linq,System.Collections.Generic, ThingsGatewayRuntime.Application, ThingsGatewayRuntime, TUtility, TUtility.Extension, TUtility.Json.Extension, TUtility.Log. If extra namespace is needed, write using at the beginning of the script, and the compiler will move it outside the wrapper class.

Do not rely on the packaging logic of "automatically return when there is no return." The source code does have auto-filling, but it only performs string checks, making it unreliable when encountering comments or complex code; For the official script, please explicitly return.

Scripts with input parameters currently only have runtime write entry for DataTrans and MemoryVariableDatatrans. Parameters become script class properties, and the parameter values in the variable configuration are written to the script instance when the variable is initialized.

Dynamically loads dependencies RuntimeFeature.IsDynamicCodeSupported. AOT or prohibition of dynamic code release methods cannot hotload script DLLs; This environment should be compiled and the extension validated before release.

Script instances are usually reused. Do not put temporary results from single executions into instance fields unless you clearly need to save state across executions and have considered concurrency, restart, and release.

You can write logs in scripts, but for high-frequency variable scripts, you shouldn't write Info logs every time you run normally. It is recommended to write Warnings or Debugs only when there are exceptions, filters, or when the format does not meet expectations.

Data Entity Quick Lookup

EntityCommon FieldsDescription
VariableBasicDataId,DeviceName,Name,Value,RawValue,CollectTime,ChangeTime,IsOnline,DataType,Unit,RegisterAddress,CollectGroup,Remark1 to Remark5The current value of the variable or the historical sampled value.Value is the project value converted by the script, RawValue is the original value.
DeviceBasicDataId, Name, ActiveTime, DeviceStatus, LastErrorMessage,PluginName,Description, Remark1 to Remark5device state changes or snapshots.
AlarmVariableAlarmId, VariableId, DeviceName, Name, AlarmLevel, AlarmType, EventType, AlarmTime, EventTime, FinishTime, ConfirmTime, AlarmText, AlarmCode, AlarmLimitIncidents such as alarm occurrence, recovery, and confirmation.
PluginEventDataDeviceName, ObjectValueplugin custom events; the event body is JsonElement.

Data Transformation Script

Purpose

Data transformation script converts the original value of a variable into an engineering value, and can also convert engineering values back to device values before writing to the device externally.

Runtime

R ead expressions are executed in VariableRuntime.SetValue in the following order: fetch the plugin to read the value, write RawValue execute the read expression, write Value. If reading an expression fails, the variable is set offline and the conversion failure information is recorded.

Write expressions are executed in CollectBase.InvokeWriteAsync or InvokeMethodAsync. The order is: receive external write values, execute write expressions, and hand the converted values to the collection plugin to write to the device.

Method Form

public override object Execute(object raw, Logger? logger)
{
// The user script is written here.
}

Only the method is written on the page; you can directly use raw and logger.

Demo: Analog Output Ratio Conversion

if (!TryConvertToDouble(raw, out var rawValue))
{
logger?.LogWarning($"AI value is not numeric: {raw}");
return raw;
}

var engineeringValue = rawValue * 0.1;
return Math.Round(engineeringValue, 2);

Demo: Linear Conversion with Parameters

Added to the script input parameters:

Parameter NameTypeInitial ValueDescription
ScaleDouble0.1Scaling Coefficient.
OffsetDouble0offset.

Script content:

if (!TryConvertToDouble(raw, out var rawValue))
{
return raw;
}

return Math.Round(rawValue * Scale + Offset, 3);

Notes

The return value type must match the variable data type. For example, configure the variable as Double, and do not return strings that cannot be converted.

Do not directly access external networks or databases when reading expressions. It is executed by collecting hot paths, and blocking slows variable refresh.

Writing the expression is used for the pre-write conversion; failure will prevent the current write. In the reverse script, prioritize input validation and clearly state error messages.

Memory Variable Calculation Script

Purpose

Memory Variable Script is used to compute internal gateway variables and does not directly access the PLC. It can read other variables and generate derived values, state values, aggregates, or interlock judgment results.

Runtime

Memory variables are managed by MemoryDriver. If a variable is not read, it is saved as a written value; When there is a read expression, the runtime will perform the calculation triggered by memory variables. The triggering method can be periodic, or you can try triggering based on changes in dependency variables recorded in Tag(device, variable).

Method Form

public override object Execute(object raw, Logger? logger)
{
// The user script is written here.
}

You can use Tag("Device Name", "VariableName" to get other variable runtime objects. Tag also adds dependencies to the Tags collection, establishing change-triggered relationships at runtime.

Demo: Calculating the average temperature value of two channels

var left = Tag("PLC_1", "Temp_Left").Value;
var right = Tag("PLC_1", "Temp_Right").Value;

if (!double.TryParse(Convert.ToString(left), out var leftValue) ||
!double.TryParse(Convert.ToString(right), out var rightValue))
{
logger?.LogWarning("Temperature source value is not numeric.");
return null;
}

return Math.Round((leftValue + rightValue) / 2.0, 1);

Demo: Run Allowed State

var autoMode = Convert.ToBoolean(Tag("PLC_1", "AutoMode").Value);
var emergencyStop = Convert.ToBoolean(Tag("PLC_1", "EmergencyStop").Value);
var pressureOk = Convert.ToBoolean(Tag("PLC_1", "PressureOk").Value);

return autoMode && !emergencyStop && pressureOk;

Notes

Tag must be runtime names, not descriptions, addresses, or external mapping names.

Do not place Tag in branches that may not necessarily be executed, otherwise the dependent variable set may be incomplete and the change-triggered relationship may also be incomplete.

If the dependent variable does not exist, Tag throws an exception, causing the script to fail. Before going live, verify dependency names with a small number of variables.

If the script depends on time, cumulative values, or external states, even with Tag, it is recommended to keep the cycle trigger to avoid calculating only when the variable changes.

Dynamic Model Script

Purpose

Dynamic model script reshapes entities before data forwarding. It does not connect to external systems, only handles VariableBasicData,DeviceBasicData, AlarmVariable, or PluginEventData Become objects needed by the target system, anonymous objects, or dictionaries.

The forwarding base class continues to use script output for Topic grouping, default JSON serialization, or content template replacement. A ${property-name} placeholder in the Topic template reads that property from the script output object.

Method Form

public override IEnumerable<object> GetList(IEnumerable<T> datas, Logger? logger)
{
// The user script is written here.
}

On the page, only the method body is written; it must return IEnumerable<object> or a set that can be assigned to it.

Demo: Variable Dynamic Model

Type Selection DynamicModel_VariableBasicData.

return datas.Select(data => new
{
device = data.DeviceName,
tag = data.Name,
value = data.Value,
raw = data.RawValue,
unit = data.Unit,
quality = data.IsOnline ? "Good" : "Bad",
ts = data.CollectTime
});

If the forwarding target Topic is written as factory/${device}/${tag}, it will run grouped according to the script's output device and tag.

Demo: Device Dynamic Model

Type Selection DynamicModel_DeviceBasicData.

return datas.Select(device => new
{
device = device.Name,
status = device.DeviceStatus.ToString(),
online = device.DeviceStatus.ToString() == "OnLine",
plugin = device.PluginName,
activeTime = device.ActiveTime,
message = device.LastErrorMessage
});

Demo: Alarm Dynamic Model

Type Selection DynamicModel_AlarmVariable.

return datas.Select(alarm => new
{
id = alarm.AlarmId,
device = alarm.DeviceName,
tag = alarm.Name,
level = alarm.AlarmLevel,
alarmType = alarm.AlarmType?.ToString(),
eventType = alarm.EventType.ToString(),
text = alarm.AlarmText ?? alarm.AlarmCode,
eventTime = alarm.EventTime
});

Demo: Plugin Event Dynamic Model

Type Selection DynamicModel_PluginEventData.

return datas.Select(item => new
{
device = item.DeviceName,
eventBody = item.ObjectValue,
receivedAt = DateTime.UtcNow
});

Notes

Property names on the returned object affect both the Topic template and the content template. Before renaming a field, check the ${property-name} placeholders configured on the forwarding target.

Do not filter out large amounts of data in dynamic models unless explicitly required by the target protocol. Scope filtering should be prioritized in the data forwarding group.

Return Dictionary<string, object?> is also acceptable, but the field names must match the template.

Dynamic model scripts only change the uploaded content, not the runtime variables themselves.

Dynamic SQL Scripts

Uses

Dynamic SQL Scripts delegate the logic for creating tables, deleting and saving databases to scripts. It is used in scenarios where projects require fixed table structures, wide table fields, special field naming, or special cleanup strategies.

Method Forms

Dynamic SQL is not a method body script, but rather member source code written into the generating class. Three methods must be implemented:

public override Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken);

public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken);

public override Task DBSavable(OrmClient db, IEnumerable<T> datas, Logger? logger, CancellationToken cancellationToken);

DBInit runs when the target initializes the table structure; DBSavable runs during batch writes; DBDeletable runs when data exceeding the retention period is cleaned up.

Note: QuestDB and TDengine history targets use their database-level TTL/KEEP retention mechanisms. When a target uses a History table script, the script or database owns cleanup, so do not rely on the target's Retention days setting alone.

Demo: Historical Variable Wide Table

Type Select DynamicSQL_VariableBasicData, can be bound to the historical data forwarding target "History Table Script".

[OrmTable("tg_his_variable_wide")]
private sealed class HisVariableWideRow
{
[OrmColumn(IsPrimaryKey = true)]
public string Id { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string? Unit { get; set; }
public bool IsOnline { get; set; }
public DateTime CollectTime { get; set; }
}

public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<HisVariableWideRow>(cancellationToken).ConfigureAwait(false);
}

public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<HisVariableWideRow>()
.Where(row => row.CollectTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}

public override async Task DBSavable(OrmClient db, IEnumerable<VariableBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new HisVariableWideRow
{
Id = $"{data.Id}:{data.CollectTime:O}",
DeviceName = data.DeviceName,
VariableName = data.Name,
Value = data.Value?.ToString() ?? string.Empty,
Unit = data.Unit,
IsOnline = data.IsOnline,
CollectTime = data.CollectTime
}).ToList();

if (rows.Count == 0)
{
return;
}

await db.BulkCopy<HisVariableWideRow>()
.BulkInsertAsync(rows, cancellationToken)
.ConfigureAwait(false);
}

Demo: Real-time variable table

type selection DynamicSQL_VariableBasicData, can be bound to the real-time data forwarding target "real-time table script".

[OrmTable("tg_real_variable")]
private sealed class RealVariableRow
{
[OrmColumn(IsPrimaryKey = true)]
public long VariableId { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool IsOnline { get; set; }
public DateTime CollectTime { get; set; }
public DateTime UpdateTime { get; set; }
}

public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<RealVariableRow>(cancellationToken).ConfigureAwait(false);
}

public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
return Task.FromResult(0);
}

public override async Task DBSavable(OrmClient db, IEnumerable<VariableBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new RealVariableRow
{
VariableId = data.Id,
DeviceName = data.DeviceName,
VariableName = data.Name,
Value = data.Value?.ToString() ?? string.Empty,
IsOnline = data.IsOnline,
CollectTime = data.CollectTime,
UpdateTime = DateTime.UtcNow
}).ToList();

if (rows.Count > 0)
{
await db.BulkCopy<RealVariableRow>()
.BulkMergeAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}

Demo: Device Status Table

Type Selection DynamicSQL_DeviceBasicData. Current standard targets mainly use variable and alarm dynamic SQL; Device dynamic SQL is a framework support capability, usually called by the Bin-Open data forwarding target.

[OrmTable("tg_device_state")]
private sealed class DeviceStateRow
{
[OrmColumn(IsPrimaryKey = true)]
public long DeviceId { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string PluginName { get; set; } = string.Empty;
public string? LastErrorMessage { get; set; }
public DateTime ActiveTime { get; set; }
public DateTime UpdateTime { get; set; }
}

public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<DeviceStateRow>(cancellationToken).ConfigureAwait(false);
}

public override Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
return Task.FromResult(0);
}

public override async Task DBSavable(OrmClient db, IEnumerable<DeviceBasicData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(data => new DeviceStateRow
{
DeviceId = data.Id,
DeviceName = data.Name,
Status = data.DeviceStatus.ToString(),
PluginName = data.PluginName,
LastErrorMessage = data.LastErrorMessage,
ActiveTime = data.ActiveTime,
UpdateTime = DateTime.UtcNow
}).ToList();

if (rows.Count > 0)
{
await db.BulkCopy<DeviceStateRow>()
.BulkMergeAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}

Demo: Historical Alarm Table

Select DynamicSQL_AlarmVariable to bind to the "Historical Alarm Table Script" of the historical alarm forwarding target.

[OrmTable("tg_alarm_history_wide")]
private sealed class AlarmHistoryRow
{
[OrmColumn(IsPrimaryKey = true)]
public string AlarmId { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string VariableName { get; set; } = string.Empty;
public int AlarmLevel { get; set; }
public string EventType { get; set; } = string.Empty;
public string? AlarmText { get; set; }
public DateTime EventTime { get; set; }
}

public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<AlarmHistoryRow>(cancellationToken).ConfigureAwait(false);
}

public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<AlarmHistoryRow>()
.Where(row => row.EventTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}

public override async Task DBSavable(OrmClient db, IEnumerable<AlarmVariable> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(alarm => new AlarmHistoryRow
{
AlarmId = $"{alarm.AlarmId}:{alarm.EventType}:{alarm.EventTime:O}",
DeviceName = alarm.DeviceName,
VariableName = alarm.Name,
AlarmLevel = alarm.AlarmLevel,
EventType = alarm.EventType.ToString(),
AlarmText = alarm.AlarmText,
EventTime = alarm.EventTime
}).ToList();

if (rows.Count > 0)
{
await db.BulkCopy<AlarmHistoryRow>()
.BulkInsertAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}

Demo: Plugin Event Table

Type selection DynamicSQL_PluginEventData. Currently, the standard target does not have a direct consumption entry point and is usually called by the second-opening target.

[OrmTable("tg_plugin_event")]
private sealed class PluginEventRow
{
[OrmColumn(IsPrimaryKey = true)]
public string Id { get; set; } = string.Empty;
public string DeviceName { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}

public override async Task DBInit(OrmClient db, Logger? logger, CancellationToken cancellationToken)
{
await db.CodeFirst.InitTableAsync<PluginEventRow>(cancellationToken).ConfigureAwait(false);
}

public override async Task<int> DBDeletable(OrmClient db, int days, Logger? logger, CancellationToken cancellationToken)
{
var before = DateTime.UtcNow.AddDays(-days);
return await db.Deletable<PluginEventRow>()
.Where(row => row.CreateTime < before)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
}

public override async Task DBSavable(OrmClient db, IEnumerable<PluginEventData> datas, Logger? logger, CancellationToken cancellationToken)
{
var rows = datas.Select(item => new PluginEventRow
{
Id = Guid.NewGuid().ToString("N"),
DeviceName = item.DeviceName,
Payload = item.ObjectValue.GetRawText(),
CreateTime = DateTime.UtcNow
}).ToList();

if (rows.Count > 0)
{
await db.BulkCopy<PluginEventRow>()
.BulkInsertAsync(rows, cancellationToken)
.ConfigureAwait(false);
}
}

Notes

The signature of a dynamic SQL method must be exactly the same as DynamicSQLBase<T>. If old templates or handwritten templates are missing OrmClient dborLogger? logger, int days will fail to compile.

Do not write database entries one by one in DBSavable. Prioritize batch writing or merging; otherwise, high-frequency historical data will drag down the database.

DBDeletable must respect the number of days retained. Split tables, wide tables, and real-time tables should all clearly indicate whether cleanup is needed.

If the target uses offline cache, throwing an exception DBSavable will cause the batch to fail and retain the cache; Don't swallow database failures as successes.

MQTT RPC Script

Purpose

MQTT RPC script is used to customize the parsing and response of MQTT writing, querying, or control messages. Standard MQTT Client/Server targets parse by default when there is no script:

{
"PLC_1": {
"StartCommand": true,
"SpeedSet": 1200
}
}

scripts can convert the project's custom payload into this structure, then call getRpcResult execute internal gateway writing.

Method Form

MQTT RPC writes a complete method and must implement:

public override Task RPCInvokeAsync(
MqttArrivedMessage message,
Func<TopicArray, CancellationToken, Task> publish,
Func<Dictionary<string, Dictionary<string, JsonElement>>, ValueTask<Dictionary<string, Dictionary<string, IOperResult>>>> getRpcResult,
Logger? logger = null,
CancellationToken cancellationToken = default);

message is the received MQTT message; publish replies to messages; getRpcResult sends a "device name -> variable name -> value" write request to the gateway for execution.

Demo: Compatible with custom write formats

Enter payload:

{
"device": "PLC_1",
"values": {
"StartCommand": true,
"SpeedSet": 1200
}
}

Script content:

using System.Text;

private sealed class RpcRequest
{
public string Device { get; set; } = string.Empty;
public Dictionary<string, JsonElement> Values { get; set; } = new();
}

public override async Task RPCInvokeAsync(
MqttArrivedMessage message,
Func<TopicArray, CancellationToken, Task> publish,
Func<Dictionary<string, Dictionary<string, JsonElement>>, ValueTask<Dictionary<string, Dictionary<string, IOperResult>>>> getRpcResult,
Logger? logger = null,
CancellationToken cancellationToken = default)
{
var json = Encoding.UTF8.GetString(message.Payload);
var request = json.FromSystemTextJsonString<RpcRequest>();
if (request == null || string.IsNullOrWhiteSpace(request.Device))
{
logger?.LogWarning("Invalid MQTT RPC payload.");
return;
}

var rpcData = new Dictionary<string, Dictionary<string, JsonElement>>
{
[request.Device] = request.Values
};

var result = await getRpcResult(rpcData).ConfigureAwait(false);

using var response = new TopicArray
{
Topic = $"{message.TopicName}/Response",
Payload = new
{
success = result.Values.SelectMany(device => device.Values).All(item => item.IsSuccess),
detail = result
}.ToSystemTextJsonUtf8Bytes()
};

await publish(response, cancellationToken).ConfigureAwait(false);
}

Notes

The response must be published to the Topic defined by the project contract. The default logic uses ${original-topic}/Response. A custom script may change this, but the external system must be updated at the same time.

Do not bypass getRpcResult when running variable operations, as this will bypass variable write permissions, write expressions, collect plugin write flow, and RPC logs.

Scripts should capture and log payloads that cannot be parsed. When parsing fails and throws an exception directly, the external system usually only sees timeout.

ThingsBoard client plugin has its own RPC parsing flow, and the current source code does not use BigTextScriptRpc.

Full Source Script

Purpose

Full Source Script is suitable for advanced extensions: compile the full C# type into the script DLL, and at runtime retrieve the instance via GetFullSource(). It is not equivalent to data transformation or dynamic models; ordinary variables and forwarding targets do not automatically call it.

Source Form

FullSource No method packaging; page content must be complete C# source code. The source generator identifies the complete source code script by inheriting DynamicBase.

Demo: Maintenance Window Judgment Tool

using System;
using System.Diagnostics.CodeAnalysis;
using ThingsGatewayRuntime.Application;

#nullable enable

[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public sealed class MaintenanceWindowHelper : DynamicBase
{
public bool IsInWindow(DateTime utcNow, int startHourUtc, int endHourUtc)
{
var hour = utcNow.Hour;
if (startHourUtc <= endHourUtc)
{
return hour >= startHourUtc && hour < endHourUtc;
}

return hour >= startHourUtc || hour < endHourUtc;
}
}

Dial side diagram:

var helper = "MaintenanceWindowHelper".GetFullSource() as MaintenanceWindowHelper;
var allow = helper?.IsInWindow(DateTime.UtcNow, 16, 18) == true;

Notes

The full source code script must guarantee the class name, namespace, dependencies, and access levels of the script itself. It is recommended to keep the class name consistent with the script name.

DynamicBase currently does not define Name abstract properties; Do not copy old templates with public override string Name, or the compilation will fail.

Complete source code scripts are more suitable as secondary account extensions and are not recommended for direct maintenance by on-site delivery personnel.

Custom Node Script

Purpose

Custom nodes are used for rule engines. Nodes have input, output, and input/output parameters. During runtime, nodes are connected into a directed graph: after the upstream output changes, it writes to the downstream input, which then triggers the downstream ChangedAsync.

Lifecycle

  1. Reads layout data when the rule flow starts.
  2. Each node uses NodeTypeName to create an instance from ExpressionsData.
  3. At runtime, the input and input/output parameters configured on the page are written into the instance.
  4. Set OutputsCommitted to receive output batches. State inputs from the same batch are applied together before downstream execution.
  5. Call each node's InitAsync.
  6. A starter node without upstream will execute ChangedAsync once.
  7. When the upstream output changes, the target node triggers SmartChangedTriggerScheduler via ChangedAsync. The default stabilization is 10 ms; after configuring NoDebounce in the process, anti-stabilization is removed.
  8. Release node instances when the process stops; Nodes implementing IDisposable will be called by TryDispose.

Generated Page Code Form

If you configure input Input, input Scale, and output Output on the node page, the script content only needs to be written as follows

public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}

public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}

Demo: Pump Operating Hours Cumulative Nodes

Node Parameter Recommendations:

Parameter NameDirectionTypeInitial ValueDescription
RunningInputBooleanfalsePump operating status.
ResetinputBooleanfalsereset cumulative values.
HoursoutputsDoublecumulative running hours.

Script Content:

private DateTime _lastTime = DateTime.UtcNow;
private double _hours;

public override Task InitAsync()
{
_lastTime = DateTime.UtcNow;
_hours = 0;
Hours = 0;
return Task.CompletedTask;
}

public override Task ChangedAsync()
{
var now = DateTime.UtcNow;
if (Reset)
{
_hours = 0;
}
else if (Running)
{
_hours += (now - _lastTime).TotalHours;
}

_lastTime = now;
Hours = Math.Round(_hours, 3);
return Task.CompletedTask;
}

Demo: Complete Custom Node Class

Embedded nodes and external DLLs can directly inherit CustomExpressionBase:

using System.ComponentModel;
using ThingsGatewayRuntime.Application;

[Category("Calculation")]
public sealed class HighLimitNode : CustomExpressionBase
{
public override string Name => "Limit judgment";

[ExpressionInput]
public double Input { get; set; }

[ExpressionInput]
public double Limit { get; set; } = 100;

private bool _alarm;

[ExpressionOutput]
public bool Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}

public override Task InitAsync()
{
Alarm = false;
return Task.CompletedTask;
}

public override Task ChangedAsync()
{
Alarm = Input > Limit;
return Task.CompletedTask;
}
}

Notes

Update output properties through SetOutput. Successful scheduled execution commits the output batch automatically; CommitOutputs explicitly commits an intermediate batch. Failure discards pending outputs. Do not invoke the host's OutputsCommitted directly; changing fields alone does not trigger downstream nodes.

Nodes subscribed to global events, created timers, and opened network connections must implement IDisposable, unsubscribe in Dispose, and release resources.

ChangedAsync may be triggered frequently. Long-duration actions should consider stabilization, timeout, cancellation, and repeated triggers.

The rule engine detects loop propagation; if the same propagation chain repeatedly visits nodes, propagation stops. Do not use node loops to achieve high-speed cyclic control.

Troubleshooting

PhenomenonChecklist
Compilation FailureFirst, look at the first red error; Confirm that script types match code morphology, and whether dynamic SQL and MQTT RPC have written override methods.
Compilation successful but script not found in the listConfirm that the DLL is hotloaded; Refresh "Loaded Script"; Check the script name, type, and classification.
Variable expressions are not effectiveConfirm that the variable is bound to the script name; The read expression type must be DataTrans, and the memory variable read expression must be MemoryVariableDatatrans.
Writing not to the deviceCheck if the write expression is throwing an exception; Check whether the collection plugin supports write or RPC; Check the type and permissions of variable protection.
Memory variables remain unchangedCheck the trigger method, dependency variable name, Tag executed, whether dependency variables are online, and whether scripts return convertible types.
Dynamic model Topic property does not existThe ${field} placeholder in the Topic template does not match a property on the script output object.
Dynamic SQL not executedConfirm that the target attribute has bound the corresponding table script; Historical variables/real-time variables use variable dynamic SQL, and historical alarms use alarm dynamic SQL.
MQTT RPC unresponsiveCheck if RPC topics match; Whether the payload can be parsed; Whether the script calls publish; Whether external systems are listening for correct responses to topics.
Custom nodes do not triggerCheck whether node output is set through SetOutput; Check whether the connection port matches the parameters; Whether processes are enabled; Does image stabilization affect observation?
AOT environment scripts are not availableWhen dynamic code is not supported, script DLLs will not be hotloaded; they need to be switched to non-AOT deployments or precompiled and validated during the release process.

Launch Checklist

ChecklistRequirements
Type MatchScript type, binding position, and entity type are consistent.
CompilationIndividual compilations succeed, batch compilation successful, and even after reboot, the loaded script still remains.
ParametersEnter the parameter name, type, initial value, and the parameter values in the variable configuration are consistent.
PerformanceHigh-frequency scripts do not access slow external resources, do not write database entries one by one, and flush Info logs on abnormal paths.
Exceptionsclear logs for parsing failures, variables not present, database failures, and RPC failures.
DataFirst verify input, output, time, units, and online status with 3 to 5 points, then expand the scope.
RevertKeep the old script content or export the configuration, and confirm that reverting is possible before production modifications.