Skip to main content

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

RequirementsRecommended selection
Need to integrate multiple variables, alarms, Device status is orchestrated into interlocks, alarms, pushes, or control processescustom 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 variableMemory variable script.
Add collection support for a new PLC, instrument, or protocolUse a collection plugin. See Collection Plugin Development.
Upload data to a new platform, database, or protocol serverUse a business plugin. See Business Plugin Development.

Source Code Entry

Source CodeFunction
ThingsGatewayRuntime.Application/Expressions/CustomExpressionBase.csCustom node base class defining Name, InitAsync, ChangedAsync, OutputsCommitted, SetOutput, and CommitOutputs.
ThingsGatewayRuntime.Application/Expressions/CustomExpressionDefinition.csWEB node parameters definition, parameter orientation, data types, and generated property rules.
ThingsGatewayRuntime.Application/Controllers/RuleEngine/GatewayCustomNodeController.csCustom node creation, saving, compilation, batch compilation, Delete and hotload interfaces.
ThingsGatewayRuntime.Application/Entity/CustomNode.csCustom node database tables, storing names, categories, descriptions, code, and three types of parameters JSON.
ThingsGatewayScriptCompiler/ExpressionCodeGenerator.csWrap WEB node code into inheritance CustomExpressionBase C# class.
ThingsGatewayRuntime.ExpressionsGenerator/SourceGenerator/ExpressionRegistrationGenerator.csCompile-time scan node class, Generate registration code and write it to ExpressionsData.
ThingsGatewayRuntime.Application/Expressions/ExpressionsData.csStores registered node information and executes delegates for creating, reading, initializing, and changing them.
ThingsGatewayRuntime.Application/Task/RuleEngine/RuleEngineTask.csRuntime of the rule flow, responsible for creating node instances, writing parameters, initializing, output propagation, Stabilization and release.
ThingsGatewayRuntime.Application/Script/EmbeddedNodes.csBuilt-in node reference implementations for mathematics, logic, comparison, timers, counters, statistics, and more.
ThingsGatewayRuntime.Application/Script/VariableNode.csBuilt-in variable notifications, alarm notifications, device notifications, variable RPC, MQTT/email/webhook Push node reference implementation.

Total Lifecycle

  1. 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.
  2. After clicking "Compile and Save," Runtime first writes the node to the custom_node table.
  3. Runtime calls ThingsGatewayScriptCompiler, compile to CustomNode.
  4. The compiler generates safe class names based on node names, wrapping page code and parameter properties into CustomExpressionBase derived classes.
  5. Write the compiled output to the runtime CustomNodeDlls directory, using a filename such as <safe-name>AsyncExpression.dll.
  6. The source generator generates the registration code for ModuleInitializer in the DLL, and writes the node to ExpressionsData when loading.
  7. When the rule flow starts, it reads the canvas JSON, rect are nodes, edge are connections.
  8. Each node uses nodeTypeName to create an instance from ExpressionsData.
  9. At runtime, the input parameters and input/output parameters configured on the canvas are written to the node instance.
  10. Runtime injects the flow log Logger, sets the batch receiver OutputsCommitted, then calls all nodes' InitAsync.
  11. The initial node without upstream connections will execute ChangedAsync.
  12. After the node output changes, runtime writes the value to the downstream input port and triggers the downstream SmartChangedTriggerScheduler.
  13. Default stabilization is 10 ms; No stabilization is applied after enabling "unlimited trigger" in the process.
  14. When a flow stops, restarts, or deletes the running context, runtime calls TryDispose on 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);
}
MemberDevelopment Requirements
NameNode names referenced by runtime registration and rule flows. Web custom nodes are automatically generated by the wrapper; complete class nodes must be handwritten.
InitAsyncNode instance creation, parameter writing, output callback settings, and then called once. Suitable for initializing outputs, subscribing to events, creating connections, or starting timers.
ChangedAsyncwhen the initial node starts or after upstream input changes. Suitable for reading inputs, calculating output, executing writes, or pushing data.
LoggerProcess-specific logs. High-frequency nodes should not be flicked via normal paths to the Info log; only record abnormal, filtering, or critical actions.
OutputsCommittedHost 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.
SmartChangedTriggerSchedulerruntime 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 OrientationSite MeaningGenerated AttributesLine PositionNotes
Input parametersValues passed in upstream or manually filled in by the rule instance panel.[ExpressionInput] public setLeft input portsin-0, in-1.Writing input parameters itself does not trigger output propagation; output must be set in ChangedAsync.
Output parametersResults after calculation, filtering, writing, or push at the current node.[ExpressionOutput] private setRight output ports: out-0, out-1.Page node directly Output = value; Full class nodes use SetOutput.
Input/Output ParametersCan be received from upstream or modified by the current node before continuing output.[ExpressionInOut] public setOne 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.

TypeC# TypeSuitable ScenarioInitial Value Writing
BooleanboolStart/Stop, Interlock, Alarm Status, Edge Signal.true or false.
Int32 / Int64int / longCount, Number, millisecond time, enumeration value.1000.
Float / Double / Decimalfloat / double / decimalSimulation quantities, engineering quantities, and scale conversions.1.5, decimal can write 1.5m in full classes.
StringstringDevice name, variable name, Topic, URL, template, alert text.In the WEB, it is usually filled out in the rule instance properties panel.
DateTimeDateTimeTimestamp, 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.
Objectobject or custom typesVariableBasicData, 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

RulesInstructions
Only changes in output propagateHaving 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 inputRuleEngineTask 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 mapsby 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 oncenodes 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 stabilizationWhen 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 stoppingAfter 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.

TypeBuilt-in nodes
Mathematical operationsAddition, subtraction, multiplication, division, modulus, exponentiation , square root, absolute value, rounding, numerical clamping, linear scaling.
Logical operationslogical and, logical OR, logical non-OR, logical XOR, logical NOR, logical AND, logical NO.
Comparative OperationsDetermine within the range of equal, not equal, greater than, greater than, less than, less than, less than or equal to.
Conditional JudgmentConditional selection, conditional selection (string), threshold triggering, multi-branch processing.
String ProcessingString concatenation, string formatting, string truncation, string replacement, uppercase and lowercase conversion, space removal, inclusion check, string length.
Type conversionconverts to integers, floating-point numbers, strings, and booleans.
TimersDelay, single-time timers, cycle timers.
CountersCounters, add-minus counters.
Edge DetectionRising Edge Detection, Falling Edge Detection, Double Edge Detection.
Data StatisticsAverage, Moving Average, Max-Mini, and Accumulator.
Change detectionChange detection, change rate.
Maintainvalues, sampling hold, flip-flops.
ConstantsNumeric Constants, String Constants, Boolean Constants.
Trigonometric FunctionsSine, Cosine, Tangent, Arctangent 2.
TriggersVariable notification rules, alarm notification rules, device notification rules.
Variable RPC NodeVariable RPC Node.
Data PushMQTT 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:

DirectionNameTypeDescription
EnterValueDoubleConstant values entered in the Rule Process node properties panel.
OutputOutputDoubleOutput 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:

DirectionNameTypeDescription
EnterRawValueDoubleOriginal Range Values.
InputRawMinDoubleRaw Lower Limit.
EnterRawMaxDoubleraw limit.
InputEngMinDoubleproject lower limit.
EnterEngMaxDoubleproject limit.
OutputValueDoubleConverted project values.
OutputsErrorBooleanwhether 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:

DirectionNameTypeDescription
EnterAutoModeBooleanAuto Mode.
EnterEmergencyStopBooleanEmergency Stop status.
InputPressureOkBooleanPressure Allow.
InputFaultBooleanfault status.
OutputAllowedBooleanto 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:

DirectionNameTypeDescription
EnterPVDoubleprocess values.
EnterHighLimitDoubleHigh Limit values.
InputHysteresisDoubleEcho to prevent tipping point jitter.
OutputAlarmBooleanIs currently alarming?
OutputRisingEdgeBooleanalarm just occurred.
OutputFallingEdgeBooleanalarm 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:

DirectionNameTypeDescription
EnterUseManualBooleanWhether to use a manual set value.
EnterManualValueDoublemanual set values.
EnterAutoValueDoubleto automatically calculate the value.
OutputOutputDoubleFinal output value.
OutputSourceStringOutput 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:

directionnametypeDescription
EnterDeviceNameStringdevicename.
EnterVariableNameStringvariable name.
EnterValueTextStringcurrent value text.
EnterUnitStringunits.
OutputMessageStringFormed 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:

DirectionNameTypeDescription
EnterInputTextStringExternal text values.
OutputValueDoubleparsed values.
OutputSuccessBooleanis parsed successfully.
OutputsErrorMessageStringerror 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:

DirectionNameTypeDescription
EnterTriggerBooleanTrigger Signal.
EnterDelayMsInt32Delay time, milliseconds.
OutputOutputBooleanDelayed output.
OutputDoneBooleandelayed 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:

DirectionNameTypeDescription
EnterIncrementBooleanCount Pulse.
inputResetBooleanReset.
EnterMaxValueInt32upper limit.
OutputsCountInt32Current Count.
OutputOverflowBooleanare 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:

DirectionNameTypeDescription
InputInputBooleanCurrent Switch Value.
OutputRisingBooleanRising Edge.
outputFallingBooleanFalling Edge.
OutputChangedBooleanArbitrary 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:

DirectionNameTypeDescription
InputInputDoubleCurrent sampled value.
EnterWindowSizeInt32slide the window size.
OutputAverageDoubleSliding Average.
OutputSampleCountInt32Current 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:

DirectionNameTypeDescription
EnterInputDoubleCurrent value.
Enter theThresholdDoubleto determine the change threshold.
OutputChangedBooleanexceeds the threshold.
OutputDeltaDoubleThe current change amount.
OutputRatePerSecondDoublerate 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:

DirectionNameTypeDescription
EnterInputDoubleCurrent value.
InputSampleBooleansampling signal.
InputHoldBooleanhold signal.
OutputOutputDoubleoutput 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:

DirectionNameTypeDescription
EnterAngleDoubleAngle or radian.
InputUseDegreesBooleantrue means the input is in degrees.
OutputsSinValueDoubleSine Values.
OutputsCosValueDoubleCosine 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:

DirectionNameTypeDescription
InputInputDataObjectUpstream objects, such as variable notification outputs VariableBasicData.
OutputsJsonStringserialized JSON.
OutputHasDataBooleanWhether 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:

DirectionNameTypeDescription
EnterTriggerBooleanWrite trigger.
EnterDeviceNameStringDevice Name.
EnterVariableNameStringvariable name.
EnterWriteDataObjectwrite values.
OutputSuccessBooleanis successful.
OutputsMessageStringresult 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:

DirectionNameTypeDescription
EnterEnabledBooleanenabled.
EnterUrlStringWebhook address.
InputInputDataObjectthe data to be pushed.
OutputSuccessBooleansend whether it was successful.
OutputsErrorMessageStringerror 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

ScenarioRequirements
Parameter NamingUse C# Attribute Valid Names, PascalCase is recommended, such as DeviceName and HighLimit. Do not use Chinese, spaces, or hyphens.
Parameter RenamingThe rule flow stores port indexes and node properties. After renaming or reordering, recheck the old process connections and instance parameters.
Output AssignmentPage 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 ConsumptionChangedAsync, timeout and exception handling must be set to avoid indefinite waiting for rule flows.
Event SubscriptionSubscribe to GlobalData events, create timers, open connected nodes must fully implement IDisposable and release resources.
High-frequency triggerDefault stabilization protects downstream. Before enabling "Unlimited Triggering," evaluate the worst-case frequency, external system throttling, and CPU usage.
Object typePrioritizes passing objects via upstream connection. When manually filling in JSON or custom types, make sure CustomType can be loaded and converted at runtime.
LogDo not output passwords, tokens, or certificate contents in the logs. High-frequency nodes only record anomalies and critical state changes.
AOTDynamic Loading CustomNodeDlls Dependency RuntimeFeature.IsDynamicCodeSupported. AOT or the prohibition of dynamic code environments is not suitable for runtime hotcompilation nodes.

Troubleshooting

PhenomenonCheck the link
Compilation failureFirst, 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 panelRefresh 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 executeConfirm 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 valueConfirm 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 emptyCurrent 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 failureCheck 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 restartCheck whether event nodes, timer nodes, MQTT/email/Webhook nodes are implemented and properly released IDisposable.
Loop alarm in the processCheck 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 failureCheck device name, variable name, variable protection type, write expression, collection plugin write capability, and device online status.
Push node blockageCheck external URLs, DNS, certificates, timeout settings, proxies, firewalls; External failures should output errors rather than swallow exceptions.

Launch Checklist

ChecklistRequirements
CompilationCompilation successful in batches, and after service restart, the "Loaded Nodes" will still be visible.
ParametersEach parameter is described in a field understandable way; Input, output, and input-output directions are correct.
WorkflowFirst verify the initial trigger, output propagation, runtime display, and logs with 3 to 5 variables, then expand the scope.
ExceptionsCommunication failures, variables missing, JSON parsing failures, and external push failures all have clear outputs or logs.
ResourcesEvents, timers, connections, channels, and clients can be released when the process stops.
PerformanceHigh-frequency processes do not enable meaningless logs; External requests have timeouts; Bulk push or RPC has cost-cutting strategies.
RollbackExport files of old node code, old parameter tables, and rule flow are preserved before production changes.