Custom Node Development
This article is intended for developers and delivery engineers who need to extend the rule engine nodes. Custom nodes are neither data collection protocol plugins nor data forwarding plugins, but rather "function blocks" in the rule flow: upstream output changes and writes to the current node's input, the current node executes ChangedAsync, and then passes the output to the downstream node.
First, determine if it is a custom node
| Requirements | Recommended selection |
|---|---|
| Need to integrate multiple variables, alarms, Device status is orchestrated into interlocks, alarms, pushes, or control processes | custom nodes. |
| Only perform scale conversion, enumeration conversion, string parsing, | data transformation scripts before reading or writing variables, see Script Development Notes. |
| Needs to compute a derivation point from an expression in the memory variable | Memory variable script. |
| Add collection support for a new PLC, instrument, or protocol | Use a collection plugin. See Collection Plugin Development. |
| Upload data to a new platform, database, or protocol server | Use a business plugin. See Business Plugin Development. |
Source Code Entry
| Source Code | Function |
|---|---|
ThingsGatewayRuntime.Application/Expressions/CustomExpressionBase.cs | Custom node base class defining Name, InitAsync, ChangedAsync, OutputsCommitted, SetOutput, and CommitOutputs. |
ThingsGatewayRuntime.Application/Expressions/CustomExpressionDefinition.cs | WEB node parameters definition, parameter orientation, data types, and generated property rules. |
ThingsGatewayRuntime.Application/Controllers/RuleEngine/GatewayCustomNodeController.cs | Custom node creation, saving, compilation, batch compilation, Delete and hotload interfaces. |
ThingsGatewayRuntime.Application/Entity/CustomNode.cs | Custom node database tables, storing names, categories, descriptions, code, and three types of parameters JSON. |
ThingsGatewayScriptCompiler/ExpressionCodeGenerator.cs | Wrap WEB node code into inheritance CustomExpressionBase C# class. |
ThingsGatewayRuntime.ExpressionsGenerator/SourceGenerator/ExpressionRegistrationGenerator.cs | Compile-time scan node class, Generate registration code and write it to ExpressionsData. |
ThingsGatewayRuntime.Application/Expressions/ExpressionsData.cs | Stores registered node information and executes delegates for creating, reading, initializing, and changing them. |
ThingsGatewayRuntime.Application/Task/RuleEngine/RuleEngineTask.cs | Runtime of the rule flow, responsible for creating node instances, writing parameters, initializing, output propagation, Stabilization and release. |
ThingsGatewayRuntime.Application/Script/EmbeddedNodes.cs | Built-in node reference implementations for mathematics, logic, comparison, timers, counters, statistics, and more. |
ThingsGatewayRuntime.Application/Script/VariableNode.cs | Built-in variable notifications, alarm notifications, device notifications, variable RPC, MQTT/email/webhook Push node reference implementation. |
Total Lifecycle
- Create a new node in the WEB "Development Configuration → Custom Nodes" to maintain names, categories, descriptions, code, input parameters, output parameters, and input/output parameters.
- After clicking "Compile and Save," Runtime first writes the node to the
custom_nodetable. - Runtime calls
ThingsGatewayScriptCompiler, compile toCustomNode. - The compiler generates safe class names based on node names, wrapping page code and parameter properties into
CustomExpressionBasederived classes. - Write the compiled output to the runtime
CustomNodeDllsdirectory, using a filename such as<safe-name>AsyncExpression.dll. - The source generator generates the registration code for
ModuleInitializerin the DLL, and writes the node toExpressionsDatawhen loading. - When the rule flow starts, it reads the canvas JSON,
rectare nodes,edgeare connections. - Each node uses
nodeTypeNameto create an instance fromExpressionsData. - At runtime, the input parameters and input/output parameters configured on the canvas are written to the node instance.
- Runtime injects the flow log
Logger, sets the batch receiverOutputsCommitted, then calls all nodes'InitAsync. - The initial node without upstream connections will execute
ChangedAsync. - After the node output changes, runtime writes the value to the downstream input port and triggers the downstream
SmartChangedTriggerScheduler. - Default stabilization is 10 ms; No stabilization is applied after enabling "unlimited trigger" in the process.
- When a flow stops, restarts, or deletes the running context, runtime calls
TryDisposeon the node instance.
Base Class Contract
All runnable custom nodes must ultimately satisfy this contract:
public abstract class CustomExpressionBase
{
public abstract string Name { get; }
public Loggers? Logger { get; set; }
public abstract Task InitAsync();
public abstract Task ChangedAsync();
public Action<System.Collections.Immutable.ImmutableArray<RuleOutputValue>>? OutputsCommitted;
protected void SetOutput<T>(ref T field, T value, string propertyName = null);
}
| Member | Development Requirements |
|---|---|
Name | Node names referenced by runtime registration and rule flows. Web custom nodes are automatically generated by the wrapper; complete class nodes must be handwritten. |
InitAsync | Node instance creation, parameter writing, output callback settings, and then called once. Suitable for initializing outputs, subscribing to events, creating connections, or starting timers. |
ChangedAsync | when the initial node starts or after upstream input changes. Suitable for reading inputs, calculating output, executing writes, or pushing data. |
Logger | Process-specific logs. High-frequency nodes should not be flicked via normal paths to the Info log; only record abnormal, filtering, or critical actions. |
OutputsCommitted | Host batch receiver; nodes should not invoke it directly. Use SetOutput to update outputs. Successful scheduled execution commits automatically; CommitOutputs explicitly commits an intermediate batch. Failure discards pending outputs but does not undo committed batches. |
SmartChangedTriggerScheduler | runtime anti-shake trigger. Business code usually does not call it directly; it is handled by the rule engine. |
Two Code Forms
WEB Page Nodes
In the Custom Node Editor, write only the class member code, not the full class, do not write the Name property, and do not repeatedly declare page parameters. Parameter attributes are automatically generated by parameter definitions.
If the parameter definition includes inputs Input, input Scale, and output Output, the page will actually be wrapped into a class like the following:
public class MyNodeCustomNode : CustomExpressionBase
{
public override string Name => "MyNode";
[ExpressionInput]
public double Input { get; set; }
[ExpressionInput]
public double Scale { get; set; }
[ExpressionOutput]
public double Output { get; private set; }
// Insert page code from here.
}
The page should be written as:
public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}
Full Class Nodes
Full class nodes are suitable for built-in nodes, external DLLs, and event/connection/timer nodes that require IDisposable. A full class must inherit CustomExpressionBase, with parameters marked with [ExpressionInput], [ExpressionOutput], [ExpressionInOut], Output attributes are SetOutput SetOutput.
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("Comparative operations")]
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;
}
}
Parameter Model
| Parameter Orientation | Site Meaning | Generated Attributes | Line Position | Notes |
|---|---|---|---|---|
| Input parameters | Values passed in upstream or manually filled in by the rule instance panel. | [ExpressionInput] public set | Left input portsin-0, in-1. | Writing input parameters itself does not trigger output propagation; output must be set in ChangedAsync. |
| Output parameters | Results after calculation, filtering, writing, or push at the current node. | [ExpressionOutput] private set | Right output ports: out-0, out-1. | Page node directly Output = value; Full class nodes use SetOutput. |
| Input/Output Parameters | Can be received from upstream or modified by the current node before continuing output. | [ExpressionInOut] public set | One port on each sideinout-in-0, inout-out-0. | Suitable for cumulative values, tokens, and context objects; Modifying it triggers downstream. |
Data Types
The backend model supports these types: Int32, Int64, Double, Float, Decimal, Boolean, String, DateTime, Byte, Int16, UInt16, UInt32, UInt64, Object. Currently, the WEB parameter definition panel mainly exposes Int32,Int64,Float,Double,Boolean,String, Object; other types are suitable for use via interfaces, data import, or full class nodes.
| Type | C# Type | Suitable Scenario | Initial Value Writing |
|---|---|---|---|
Boolean | bool | Start/Stop, Interlock, Alarm Status, Edge Signal. | true or false. |
Int32 / Int64 | int / long | Count, Number, millisecond time, enumeration value. | 1000. |
Float / Double / Decimal | float / double / decimal | Simulation quantities, engineering quantities, and scale conversions. | 1.5, decimal can write 1.5m in full classes. |
String | string | Device name, variable name, Topic, URL, template, alert text. | In the WEB, it is usually filled out in the rule instance properties panel. |
DateTime | DateTime | Timestamp, window period, and delay judgment. | DateTime.UtcNow is only suitable for the default value of the full class; It is recommended to fill out page examples manually. |
Object | object or custom types | VariableBasicData, AlarmVariable, dictionary, anonymous object, JSON object. | Connects upstream objects for the most stable output; When filling out JSON manually, make sure the target type can be converted. |
The current WEB parameter definition panel mainly maintains parameter names, types, and descriptions; InitialValue is the interface and source code model field. If the page does not have dedicated initial input values, please assign default values to output and internal fields in InitAsync, or fill in instance parameters in the node properties panel of the rule flow.
Runtime Propagation Rules
| Rules | Instructions |
|---|---|
| Only changes in output propagate | Having a new value at the input does not mean the downstream will receive the value. You must set output properties or input/output properties. |
null value will not be written downstream input | RuleEngineTask currently only writes downstream properties when value != null, But it still triggers downstream nodes. When you need to pass "No Data," it is recommended to output the object wrapper state, for example, { Valid = false }. |
| port maps | by index parameter name. At runtime, out-0 is mapped to the first output parameter, and in-0 to the first input parameter. After modifying the parameter order, check the old process wiring. |
| The initial node executes once | nodes without upstream connections execute once after the process starts. ChangedAsync, constant, periodic sources, and event sources can all serve as starting nodes. |
| Default 10 ms stabilization | When multiple upstream outputs continuously for short periods, downstream execution may be merged. When triggering is needed sequentially, enable "unlimited triggering" in the process, but assess CPU and external system pressure. |
| Loops will alert and stop repeated propagation; | Rule engine detects loop dependencies and repeated visits in single-pass propagation chains. Do not use node loops for high-speed control loops. |
| Release the instance when stopping | After the full class node implements IDisposable, the process stops releasing resources such as event subscriptions, connections, and timers. |
List of Built-in Node Types
The following types have been checked from source code EmbeddedNodes.cs and VariableNode.cs. When developing new nodes, first look at similar built-in implementations to avoid rebuilding base nodes.
| Type | Built-in nodes |
|---|---|
| Mathematical operations | Addition, subtraction, multiplication, division, modulus, exponentiation , square root, absolute value, rounding, numerical clamping, linear scaling. |
| Logical operations | logical and, logical OR, logical non-OR, logical XOR, logical NOR, logical AND, logical NO. |
| Comparative Operations | Determine within the range of equal, not equal, greater than, greater than, less than, less than, less than or equal to. |
| Conditional Judgment | Conditional selection, conditional selection (string), threshold triggering, multi-branch processing. |
| String Processing | String concatenation, string formatting, string truncation, string replacement, uppercase and lowercase conversion, space removal, inclusion check, string length. |
| Type conversion | converts to integers, floating-point numbers, strings, and booleans. |
| Timers | Delay, single-time timers, cycle timers. |
| Counters | Counters, add-minus counters. |
| Edge Detection | Rising Edge Detection, Falling Edge Detection, Double Edge Detection. |
| Data Statistics | Average, Moving Average, Max-Mini, and Accumulator. |
| Change detection | Change detection, change rate. |
| Maintain | values, sampling hold, flip-flops. |
| Constants | Numeric Constants, String Constants, Boolean Constants. |
| Trigonometric Functions | Sine, Cosine, Tangent, Arctangent 2. |
| Triggers | Variable notification rules, alarm notification rules, device notification rules. |
| Variable RPC Node | Variable RPC Node. |
| Data Push | MQTT client upload, email push, Webhook push. |
Demo Syntax Conventions
The following "Page Parameters" are filled in according to the three types of parameters in the custom node editor. Except for the "full class demo," all code should be pasted into the custom web node code editing area. Do not add extra class names, namespaces, or Name attributes.
Constant Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Value | Double | Constant values entered in the Rule Process node properties panel. |
| Output | Output | Double | Output constant values downstream. |
public override Task InitAsync()
{
Output = Value;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = Value;
return Task.CompletedTask;
}
Mathematical Operation Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | RawValue | Double | Original Range Values. |
| Input | RawMin | Double | Raw Lower Limit. |
| Enter | RawMax | Double | raw limit. |
| Input | EngMin | Double | project lower limit. |
| Enter | EngMax | Double | project limit. |
| Output | Value | Double | Converted project values. |
| Outputs | Error | Boolean | whether the range configuration is incorrect. |
public override Task InitAsync()
{
Value = 0;
Error = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Math.Abs(RawMax - RawMin) < 0.000001)
{
Error = true;
return Task.CompletedTask;
}
Error = false;
Value = Math.Round((RawValue - RawMin) * (EngMax - EngMin) / (RawMax - RawMin) + EngMin, 3);
return Task.CompletedTask;
}
Logical Operation Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | AutoMode | Boolean | Auto Mode. |
| Enter | EmergencyStop | Boolean | Emergency Stop status. |
| Input | PressureOk | Boolean | Pressure Allow. |
| Input | Fault | Boolean | fault status. |
| Output | Allowed | Boolean | to start up. |
public override Task InitAsync()
{
Allowed = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Allowed = AutoMode && !EmergencyStop && PressureOk && !Fault;
return Task.CompletedTask;
}
Comparison and threshold nodes
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | PV | Double | process values. |
| Enter | HighLimit | Double | High Limit values. |
| Input | Hysteresis | Double | Echo to prevent tipping point jitter. |
| Output | Alarm | Boolean | Is currently alarming? |
| Output | RisingEdge | Boolean | alarm just occurred. |
| Output | FallingEdge | Boolean | alarm just restored. |
private bool _active;
public override Task InitAsync()
{
_active = false;
Alarm = false;
RisingEdge = false;
FallingEdge = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var previous = _active;
if (!_active && PV >= HighLimit)
{
_active = true;
}
else if (_active && PV <= HighLimit - Math.Abs(Hysteresis))
{
_active = false;
}
Alarm = _active;
RisingEdge = !previous && _active;
FallingEdge = previous && !_active;
return Task.CompletedTask;
}
Conditional selection node
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | UseManual | Boolean | Whether to use a manual set value. |
| Enter | ManualValue | Double | manual set values. |
| Enter | AutoValue | Double | to automatically calculate the value. |
| Output | Output | Double | Final output value. |
| Output | Source | String | Output Source. |
public override Task InitAsync()
{
Output = 0;
Source = "Auto";
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Output = UseManual ? ManualValue : AutoValue;
Source = UseManual ? "Manual" : "Auto";
return Task.CompletedTask;
}
String processing node
Page parameters:
| direction | name | type | Description |
|---|---|---|---|
| Enter | DeviceName | String | devicename. |
| Enter | VariableName | String | variable name. |
| Enter | ValueText | String | current value text. |
| Enter | Unit | String | units. |
| Output | Message | String | Formed prompt text. |
public override Task InitAsync()
{
Message = string.Empty;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var unit = string.IsNullOrWhiteSpace(Unit) ? string.Empty : $" {Unit}";
Message = $"{DeviceName}.{VariableName} = {ValueText}{unit}";
return Task.CompletedTask;
}
Type conversion node
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | InputText | String | External text values. |
| Output | Value | Double | parsed values. |
| Output | Success | Boolean | is parsed successfully. |
| Outputs | ErrorMessage | String | error messages. |
public override Task InitAsync()
{
Value = 0;
Success = false;
ErrorMessage = string.Empty;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (double.TryParse(InputText, out var number))
{
Value = number;
Success = true;
ErrorMessage = string.Empty;
}
else
{
Success = false;
ErrorMessage = $"Cannot convert to number: {InputText}";
}
return Task.CompletedTask;
}
Timer Node
This demo is a single-time delay node that can be pasted to the web. It is recommended to implement the long-term periodic timer using the following full class IDisposable.
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Trigger | Boolean | Trigger Signal. |
| Enter | DelayMs | Int32 | Delay time, milliseconds. |
| Output | Output | Boolean | Delayed output. |
| Output | Done | Boolean | delayed completion flag. |
private System.Threading.CancellationTokenSource? _delayCts;
public override Task InitAsync()
{
Output = false;
Done = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
_delayCts?.Cancel();
_delayCts?.Dispose();
_delayCts = null;
if (!Trigger)
{
Output = false;
Done = false;
return Task.CompletedTask;
}
Done = false;
var cts = new System.Threading.CancellationTokenSource();
_delayCts = cts;
_ = DelayAsync(cts);
return Task.CompletedTask;
}
private async Task DelayAsync(System.Threading.CancellationTokenSource cts)
{
try
{
await Task.Delay(Math.Max(1, DelayMs), cts.Token).ConfigureAwait(false);
if (!cts.IsCancellationRequested)
{
Output = true;
Done = true;
}
}
catch (TaskCanceledException)
{
}
}
Counter Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Increment | Boolean | Count Pulse. |
| input | Reset | Boolean | Reset. |
| Enter | MaxValue | Int32 | upper limit. |
| Outputs | Count | Int32 | Current Count. |
| Output | Overflow | Boolean | are capped. |
private bool _lastIncrement;
private int _count;
public override Task InitAsync()
{
_lastIncrement = false;
_count = 0;
Count = 0;
Overflow = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Reset)
{
_count = 0;
}
else if (Increment && !_lastIncrement)
{
_count++;
}
_lastIncrement = Increment;
Overflow = MaxValue > 0 && _count >= MaxValue;
Count = Overflow && MaxValue > 0 ? MaxValue : _count;
return Task.CompletedTask;
}
Edge Detection Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Input | Input | Boolean | Current Switch Value. |
| Output | Rising | Boolean | Rising Edge. |
| output | Falling | Boolean | Falling Edge. |
| Output | Changed | Boolean | Arbitrary variation. |
private bool _previous;
public override Task InitAsync()
{
_previous = Input;
Rising = false;
Falling = false;
Changed = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
Rising = Input && !_previous;
Falling = !Input && _previous;
Changed = Input != _previous;
_previous = Input;
return Task.CompletedTask;
}
Data Statistics Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Input | Input | Double | Current sampled value. |
| Enter | WindowSize | Int32 | slide the window size. |
| Output | Average | Double | Sliding Average. |
| Output | SampleCount | Int32 | Current sample count. |
private readonly Queue<double> _samples = new();
public override Task InitAsync()
{
_samples.Clear();
Average = 0;
SampleCount = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var size = Math.Max(1, WindowSize);
_samples.Enqueue(Input);
while (_samples.Count > size)
{
_samples.Dequeue();
}
SampleCount = _samples.Count;
Average = Math.Round(_samples.Average(), 3);
return Task.CompletedTask;
}
Change detection node
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Input | Double | Current value. |
| Enter the | Threshold | Double | to determine the change threshold. |
| Output | Changed | Boolean | exceeds the threshold. |
| Output | Delta | Double | The current change amount. |
| Output | RatePerSecond | Double | rate of change per second. |
private double _lastValue;
private DateTime _lastTime;
public override Task InitAsync()
{
_lastValue = Input;
_lastTime = DateTime.UtcNow;
Changed = false;
Delta = 0;
RatePerSecond = 0;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var now = DateTime.UtcNow;
var seconds = Math.Max(0.001, (now - _lastTime).TotalSeconds);
var delta = Input - _lastValue;
Delta = Math.Round(delta, 3);
RatePerSecond = Math.Round(delta / seconds, 3);
Changed = Math.Abs(delta) >= Math.Abs(Threshold);
_lastValue = Input;
_lastTime = now;
return Task.CompletedTask;
}
Hold node
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Input | Double | Current value. |
| Input | Sample | Boolean | sampling signal. |
| Input | Hold | Boolean | hold signal. |
| Output | Output | Double | output values. |
private double _held;
public override Task InitAsync()
{
_held = Input;
Output = Input;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
if (Sample)
{
_held = Input;
}
Output = Hold ? _held : Input;
return Task.CompletedTask;
}
Trigonometric Function Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Angle | Double | Angle or radian. |
| Input | UseDegrees | Boolean | true means the input is in degrees. |
| Outputs | SinValue | Double | Sine Values. |
| Outputs | CosValue | Double | Cosine values. |
public override Task InitAsync()
{
SinValue = 0;
CosValue = 1;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
var radians = UseDegrees ? Angle * Math.PI / 180.0 : Angle;
SinValue = Math.Round(Math.Sin(radians), 6);
CosValue = Math.Round(Math.Cos(radians), 6);
return Task.CompletedTask;
}
Object Structured Data Node
Page Parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Input | InputData | Object | Upstream objects, such as variable notification outputs VariableBasicData. |
| Outputs | Json | String | serialized JSON. |
| Output | HasData | Boolean | Whether there is valid data. |
public override Task InitAsync()
{
Json = string.Empty;
HasData = false;
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
HasData = InputData != null;
Json = InputData == null ? string.Empty : InputData.ToSystemTextJsonString();
return Task.CompletedTask;
}
Full Class Demo: Variable Notification Trigger Node
To subscribe to global events, the full class must implement IDisposable. It is suitable for turning runtime variable changes into the starting point of the rule flow.
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("Trigger")]
public sealed class SimpleVariableNotifyNode : CustomExpressionBase, IDisposable
{
public override string Name => "Variable changes trigger";
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
[ExpressionInput]
public string VariableName { get; set; } = string.Empty;
private object? _value;
[ExpressionOutput]
public object? Value
{
get => _value;
private set => SetOutput(ref _value, value);
}
private VariableBasicData? _data;
[ExpressionOutput]
public VariableBasicData? Data
{
get => _data;
private set => SetOutput(ref _data, value);
}
public override Task InitAsync()
{
GlobalData.VariableValueChangeEvent += OnVariableChanged;
return Task.CompletedTask;
}
private void OnVariableChanged(VariableRuntime runtime, VariableBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.DeviceName != DeviceName)
{
return;
}
if (!string.IsNullOrWhiteSpace(VariableName) && data.Name != VariableName)
{
return;
}
Data = data;
Value = data.Value;
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.VariableValueChangeEvent -= OnVariableChanged;
}
}
Full Class Demo: Alarm Notification Trigger Node
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("Trigger")]
public sealed class SimpleAlarmNotifyNode : CustomExpressionBase, IDisposable
{
public override string Name => "Alarm changes triggered";
[ExpressionInput]
public int MinLevel { get; set; } = 0;
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
private AlarmVariable? _alarm;
[ExpressionOutput]
public AlarmVariable? Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
private string _text = string.Empty;
[ExpressionOutput]
public string Text
{
get => _text;
private set => SetOutput(ref _text, value);
}
public override Task InitAsync()
{
GlobalData.AlarmChangedEvent += OnAlarmChanged;
return Task.CompletedTask;
}
private void OnAlarmChanged(AlarmVariable alarm)
{
if (alarm.AlarmLevel < MinLevel)
{
return;
}
if (!string.IsNullOrWhiteSpace(DeviceName) && alarm.DeviceName != DeviceName)
{
return;
}
Alarm = alarm;
Text = $"{alarm.DeviceName}.{alarm.Name} {alarm.EventType} {alarm.AlarmText}";
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.AlarmChangedEvent -= OnAlarmChanged;
}
}
Full Class Demo: Device Status Trigger Node
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("Trigger")]
public sealed class SimpleDeviceStatusNode : CustomExpressionBase, IDisposable
{
public override string Name => "Device status trigger";
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
private DeviceBasicData? _device;
[ExpressionOutput]
public DeviceBasicData? Device
{
get => _device;
private set => SetOutput(ref _device, value);
}
private string _status = string.Empty;
[ExpressionOutput]
public string Status
{
get => _status;
private set => SetOutput(ref _status, value);
}
public override Task InitAsync()
{
GlobalData.DeviceStatusChangeEvent += OnDeviceChanged;
return Task.CompletedTask;
}
private void OnDeviceChanged(DeviceRuntime runtime, DeviceBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.Name != DeviceName)
{
return;
}
Device = data;
Status = data.DeviceStatus.ToString();
}
public override Task ChangedAsync() => Task.CompletedTask;
public void Dispose()
{
GlobalData.DeviceStatusChangeEvent -= OnDeviceChanged;
}
}
Variable RPC Node
In field terminology, RPC means that an external system or rule flow writes a value back to a point. This demo triggers one write on the rising edge, avoiding repeated writes on every input change while Trigger=true.
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Trigger | Boolean | Write trigger. |
| Enter | DeviceName | String | Device Name. |
| Enter | VariableName | String | variable name. |
| Enter | WriteData | Object | write values. |
| Output | Success | Boolean | is successful. |
| Outputs | Message | String | result message. |
private bool _lastTrigger;
public override Task InitAsync()
{
_lastTrigger = false;
Success = false;
Message = string.Empty;
return Task.CompletedTask;
}
public override async Task ChangedAsync()
{
var rising = Trigger && !_lastTrigger;
_lastTrigger = Trigger;
if (!rising)
{
return;
}
if (!GlobalData.TryGetVariableRuntime(DeviceName, VariableName, out var variable))
{
Success = false;
Message = $"Variable not found: {DeviceName}.{VariableName}";
return;
}
var result = (await variable.RpcAsync(
WriteData.ToSystemTextJsonElement(),
"rule",
System.Threading.CancellationToken.None).ConfigureAwait(false)).GetOperResult();
Success = result.IsSuccess;
Message = result.ToString();
}
Data Push Nodes
Data push nodes in the source code include MQTT client uploads, email pushes, and webhook pushes. When running a secondary account, focus on handling connection multiplexing, timeouts, failure outputs, password log anonymization, and resource release. Below are the minimum Webhook page nodes; For MQTT and email that require persistent connections or channel object pushes, it is recommended to refer to VariableNode.cs to write the full class and implement IDisposable.
Page parameters:
| Direction | Name | Type | Description |
|---|---|---|---|
| Enter | Enabled | Boolean | enabled. |
| Enter | Url | String | Webhook address. |
| Input | InputData | Object | the data to be pushed. |
| Output | Success | Boolean | send whether it was successful. |
| Outputs | ErrorMessage | String | error messages. |
private static readonly System.Net.Http.HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
public override Task InitAsync()
{
Success = false;
ErrorMessage = string.Empty;
return Task.CompletedTask;
}
public override async Task ChangedAsync()
{
if (!Enabled || string.IsNullOrWhiteSpace(Url) || InputData == null)
{
return;
}
try
{
var json = InputData.ToSystemTextJsonString();
using var content = new System.Net.Http.StringContent(
json,
System.Text.Encoding.UTF8,
"application/json");
using var response = await HttpClient.PostAsync(Url, content).ConfigureAwait(false);
Success = response.IsSuccessStatusCode;
ErrorMessage = Success ? string.Empty : $"{(int)response.StatusCode} {response.ReasonPhrase}";
}
catch (Exception ex)
{
Success = false;
ErrorMessage = ex.Message;
Logger?.LogWarning(ex, "Webhook push failed");
}
}
Full Class Demo: Periodic Timer
Periodic source nodes generate output themselves when there is no upstream input. It must release Timer; otherwise, repeated triggers may occur after the process restarts.
using System.ComponentModel;
using ThingsGatewayRuntime.Application;
[Category("Timer")]
public sealed class PeriodicPulseNode : CustomExpressionBase, IDisposable
{
public override string Name => "Periodic pulse";
[ExpressionInput]
public int IntervalMs { get; set; } = 1000;
[ExpressionInput]
public bool Enabled { get; set; } = true;
private bool _tick;
[ExpressionOutput]
public bool Tick
{
get => _tick;
private set => SetOutput(ref _tick, value);
}
private int _count;
[ExpressionOutput]
public int Count
{
get => _count;
private set => SetOutput(ref _count, value);
}
private System.Threading.Timer? _timer;
public override Task InitAsync()
{
_timer = new System.Threading.Timer(OnTimer, null, 0, Math.Max(1, IntervalMs));
return Task.CompletedTask;
}
public override Task ChangedAsync()
{
_timer?.Change(0, Math.Max(1, IntervalMs));
return Task.CompletedTask;
}
private void OnTimer(object? state)
{
if (!Enabled)
{
return;
}
Tick = true;
Count++;
Tick = false;
}
public void Dispose()
{
_timer?.Dispose();
}
}
Development Notes
| Scenario | Requirements |
|---|---|
| Parameter Naming | Use C# Attribute Valid Names, PascalCase is recommended, such as DeviceName and HighLimit. Do not use Chinese, spaces, or hyphens. |
| Parameter Renaming | The rule flow stores port indexes and node properties. After renaming or reordering, recheck the old process connections and instance parameters. |
| Output Assignment | Page nodes directly assign value to output attributes; A full class node must be called SetOutput or an equivalent callback. Only changing private fields will not trigger downstream. |
| Asynchronous Time Consumption | ChangedAsync, timeout and exception handling must be set to avoid indefinite waiting for rule flows. |
| Event Subscription | Subscribe to GlobalData events, create timers, open connected nodes must fully implement IDisposable and release resources. |
| High-frequency trigger | Default stabilization protects downstream. Before enabling "Unlimited Triggering," evaluate the worst-case frequency, external system throttling, and CPU usage. |
| Object type | Prioritizes passing objects via upstream connection. When manually filling in JSON or custom types, make sure CustomType can be loaded and converted at runtime. |
| Log | Do not output passwords, tokens, or certificate contents in the logs. High-frequency nodes only record anomalies and critical state changes. |
| AOT | Dynamic Loading CustomNodeDlls Dependency RuntimeFeature.IsDynamicCodeSupported. AOT or the prohibition of dynamic code environments is not suitable for runtime hotcompilation nodes. |
Troubleshooting
| Phenomenon | Check the link |
|---|---|
| Compilation failure | First, check the first red diagnosis; Confirm that the page code is not fully written as a class; Confirm that the parameter name is not a C# keyword; Make sure the namespace used in the code is using or written in full name. |
| Compilation successful but no nodes in the rules panel | Refresh the rules page; View "Loaded Node"; Make sure the compiled output DLL is located at CustomNodeDlls; Confirm that the runtime environment supports dynamic code. |
| After the process starts, the node does not execute | Confirm that the process is enabled; If there is no upstream confirmation node, it can be used as the starting node; If there is an upstream, confirm that the upstream output has indeed been assigned. |
| Downstream does not receive value | Confirm that the current node is set as either output or input/output parameters; Does the full class use SetOutput; Check whether the wiring is connected from the right output port to the left input port. |
| Downstream is triggered but the input is empty | Current source code pairs null output is not written to downstream input. Wrap "null state" with an object, or output explicit values such as empty strings,false, 0, etc. |
| Parameter value writing failure | Check whether the rule instance property can be converted to parameter type; Object Object parameters and CustomType; Number type checks whether decimals and integers match. |
| Repeated notifications after restart | Check whether event nodes, timer nodes, MQTT/email/Webhook nodes are implemented and properly released IDisposable. |
| Loop alarm in the process | Check if the canvas has loops; Do not use nodes to write back to each other to implement control loops; If necessary, split into state variables or timed source triggers. |
| RPC write failure | Check device name, variable name, variable protection type, write expression, collection plugin write capability, and device online status. |
| Push node blockage | Check external URLs, DNS, certificates, timeout settings, proxies, firewalls; External failures should output errors rather than swallow exceptions. |
Launch Checklist
| Checklist | Requirements |
|---|---|
| Compilation | Compilation successful in batches, and after service restart, the "Loaded Nodes" will still be visible. |
| Parameters | Each parameter is described in a field understandable way; Input, output, and input-output directions are correct. |
| Workflow | First verify the initial trigger, output propagation, runtime display, and logs with 3 to 5 variables, then expand the scope. |
| Exceptions | Communication failures, variables missing, JSON parsing failures, and external push failures all have clear outputs or logs. |
| Resources | Events, timers, connections, channels, and clients can be released when the process stops. |
| Performance | High-frequency processes do not enable meaningless logs; External requests have timeouts; Bulk push or RPC has cost-cutting strategies. |
| Rollback | Export files of old node code, old parameter tables, and rule flow are preserved before production changes. |