On this page
Custom Node Development
Custom nodes extend rule flows with typed inputs, outputs, event handling, and side effects. Use variable conversion scripts for a single value conversion, memory scripts for derived points, and collection/business plugins for new device or upload protocols.
Reading path
Web node example → Lifecycle and parameter contracts → Troubleshooting and deployment
Start with one minimal example, then use the reference for parameters, failures and cleanup. Optional MCP materials are collapsed at the end of this page.
Before development
Choose the extension point
| Requirement | Extension |
|---|---|
| Combine variables, alarms and device status into a flow | Custom node. |
| Convert one variable before reading or writing | Data conversion script. |
| Calculate a derived point in memory | Memory-variable script. |
| Connect a new PLC/instrument/protocol | Collection plugin. |
| Send to a new platform/database/protocol server | Business plugin. |
Source references
| Source | Purpose |
|---|---|
Expressions/CustomExpressionBase.cs | Lifecycle, cancellation, managed output, skip, waiting conditions, and execution policy. |
Expressions/CustomExpressionDefinition.cs | Web parameter definitions and port contracts. |
Expressions/ExpressionInfo.cs, ExpressionsData.cs | Registered metadata and execution delegates. |
Controllers/RuleEngine/GatewayCustomNodeController.cs, Entity/CustomNode.cs | Drafts, compilation, publication, and conflict checking. |
TGateway.ScriptCompiler/ExpressionCodeGenerator.cs | Web member-code wrapping and generated setters. |
TGateway.ExpressionsGenerator/SourceGenerator/ExpressionRegistrationGenerator.cs | Stable registration and generated accessors. |
Task/RuleEngine/RuleEngineTask.cs, RuleFlowGraphBuilder.cs, RuleFlowRunner.cs | Definition validation, activation, propagation, observation, and shutdown. |
Script/EmbeddedNodes.*.cs, Script/Timers/, Script/VariableNode.cs | Built-in computation, data flow, timers, notifications, RPC, and push implementations. |
Application paths above are relative to TGateway.Application unless another project is named.
Lifecycle and parameter contracts
Complete lifecycle
- The Web editor stores node information, member code, and parameter definitions as a draft.
- Compilation wraps the draft using its stable database identity
custom-node:<Id>. Display names do not identify registrations. - DLLs are staged, loaded, validated, and published as a batch. Failure preserves the previous published files and registrations; reopening still shows the saved draft.
- A flow validates
DefinitionJson: stabletypeId, named ports, parameter contracts, and an acyclic graph. Layout is presentation data. - The host creates instances, applies inputs and node parameters, and binds the flow context, cancellation, logger, and output receiver.
- All nodes initialize through
InitAsync. Nodes without upstream connections receive an initialChangedAsyncexecution. - Successful managed execution commits output batches. State inputs in a batch are applied together before target execution; event ports retain event semantics.
- Default debounce is 10 ms.
NoDebounceremoves merge waiting; serial execution, bounded queues, and rejection still apply. - Stop/reload cancels the flow token, drains execution, calls
StopAsync, and handles disposal interfaces. A new instance resets in-memory state.
Compile/save success and active flow behavior are separate checks. Reload consuming flows and test two distinguishable inputs after publication. Stale editor revisions require reading and comparing the current node before retrying. See Code Editor for completion, XML documentation, and diagnostics.
Base class contract
| Member | Requirement |
|---|---|
Name, [RuleNode] | Implement a display name and declare a stable unique ASCII registration key on complete classes. The Web generator supplies identity and generated properties. |
InitAsync, ChangedAsync | Implement initialization and input-driven execution. Await work rather than starting detached tasks that later write outputs. |
SetOutput | Complete-class setters use it; Web code assigns generated output properties. Changing fields alone does not propagate. |
CommitOutputs | Explicitly publishes an intermediate batch. Failure, cancellation, timeout, or skip discards pending output, but cannot undo an already published batch. |
ExecutionCancellationToken | Pass to waits, writes, and network calls. It covers the total execution deadline and flow cancellation. |
LifetimeCancellationToken | Use for long-lived subscriptions and schedules; it is independent of a completed execution attempt. |
EnqueueNotification(Action, triggerKey) | Freeze external payloads before enqueueing. Publish outputs synchronously inside this managed callback; do not pass an async lambda. |
SkipExecution(code, reason) | Report an unmet condition and return immediately. Use only before output commitment or external side effects. A plain return remains a successful execution. |
SetWaitingReason(code, reason) | Describes the idle condition separately from the last result. Call before reporting skip; two nulls clear it. |
Context | Bounded JSON state shared only within the current flow instance. |
StopAsync | Asynchronous shutdown hook, also available to Web member code. Release subscriptions and resources safely on repeated cleanup. |
EffectKind | Declare pure, idempotent, or non-idempotent side effects in code; canvas parameters cannot override that safety contract. |
Two code forms
Web page nodes
Write class members only. Do not add a class wrapper, Name, or properties already generated from page parameters. With Decimal inputs Input and Scale and Decimal output Output:
/// <summary>初始化当前实例的换算输出。</summary>
public override Task InitAsync()
{
Output = 0;
return Task.CompletedTask;
}
/// <summary>计算本次倍率换算,由宿主提交输出。</summary>
public override Task ChangedAsync()
{
Output = Input * Scale;
return Task.CompletedTask;
}Full class nodes
Complete classes include their own imports, stable registration, parameters, output setters, and lifecycle methods:
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>按相同单位的输入与限值计算超限状态。</summary>
[Category("Comparison")]
[RuleNode("manual.high-limit")]
public sealed class HighLimitNode : CustomExpressionBase
{
/// <summary>节点库显示名称。</summary>
public override string Name => "High Limit";
/// <summary>当前工程量。</summary>
[ExpressionInput]
public double Input { get; set; }
/// <summary>采用相同工程单位的高限。</summary>
[ExpressionInput]
public double Limit { get; set; } = 100;
/// <summary>保存最近设置的状态。</summary>
private bool _alarm;
/// <summary>通过受管批次发布当前比较结果。</summary>
[ExpressionOutput]
public bool Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
/// <summary>建立当前实例的初始状态。</summary>
public override Task InitAsync()
{
Alarm = false;
return Task.CompletedTask;
}
/// <summary>计算本次严格大于关系。</summary>
public override Task ChangedAsync()
{
Alarm = Input > Limit;
return Task.CompletedTask;
}
}Parameter model
| Direction | Attribute and location | Contract |
|---|---|---|
| Input | [ExpressionInput], public setter, in:Input | Upstream or instance input; input assignment alone does not publish outputs. |
| Output | [ExpressionOutput], private setter, out:Result | Assign the generated Web property; complete classes use SetOutput. |
| Input/output | [ExpressionInOut], in:Value and out:Value | Distinguish host input assignment from node output publication. |
| Node parameter | [ExpressionParameter], property panel without ports | Capacities and policies are configuration rather than business events. |
Data types
Web definitions support Int16, Int32, Int64, UInt16, UInt32, UInt64, Byte, Float, Double, Decimal, Boolean, String, Object and DateTime. Numeric type, state/event semantics, nullability, array shape/elements and units are contracts; use explicit converters for mismatches.
| Type | C# type | Use and initial value |
|---|---|---|
| Boolean | bool | Enablement, interlocks, alarm state; true/false. |
| Int32 / Int64 | int / long | Counts, IDs and millisecond values; e.g. 1000. |
| Float / Double / Decimal | float / double / decimal | Engineering values and scaling; e.g. 1.5, or 1.5m in Decimal source. |
| String | string | Names, Topic, URL, templates and alarm text. Set instance inputs in the flow property panel. |
| DateTime | DateTime | Timestamps/windows; DateTime.UtcNow is a complete-class default, not a universal Web instance input. |
| Object | object or a declared type | Structured data needs an explicit JSON contract and a resolvable custom type. |
Initial Value Expression sets the C# starting expression for a state parameter, for example Decimal 1m. Events have no state initial value. Initial outputs/internal state belong in InitAsync; instance inputs and upstream connections have separate roles.
Runtime propagation rules
| Rule | Behavior |
|---|---|
| State and event | State defaults to change-only propagation against committed values; OutputOnlyOnChange=false allows repeated writes. Events retain repeated equal payloads as separate occurrences. |
| Null | Respect the nullable contract; do not silently drop null and reuse old input. JSON Null differs from Undefined. |
| Named ports | Stable typeId and port keys identify bindings. Reordering parameters preserves identity; renaming requires updating consuming flows. |
| Starting nodes | Nodes without upstream connections run ChangedAsync once at startup. |
| Debounce | Default 10 ms may merge nearby upstream updates. NoDebounce removes merge waiting but retains serial execution and bounded queues. |
| Cycles | Rejected during validation. Use hold, counters, context or memory points for state. |
| Managed outputs | Failed, canceled, timed-out or skipped execution discards pending output; explicitly committed batches cannot be rolled back. |
| External notifications | Freeze the payload, then use EnqueueNotification. Do not publish from a detached task after execution ends. |
| Branches | Rejection on one connection does not undo delivery to other branches. Inspect connection observations individually. |
| Shutdown | Cancel with the flow token, await work, then unsubscribe/dispose in StopAsync and disposal interfaces. |
Context provides Set, TryGet and Remove, with 128 keys, 64 KiB per value and 1 MiB total. Keys contain 1–64 ASCII letters, digits, dots, underscores or hyphens. It is flow-instance memory, not persistence or cross-flow storage. See Data flow.
SkipExecution explicitly reports an unmet condition; a plain return is successful. This Web example uses Boolean Enabled, Decimal Input and Decimal Output. Check the current execution reason and connection activity rather than assuming a retained display value is a new delivery.
/// <summary>初始化等待条件,不在启动时产生业务输出。</summary>
public override Task InitAsync() => Task.CompletedTask;
/// <summary>仅在启用时计算,禁用时报告明确的未执行原因。</summary>
public override Task ChangedAsync()
{
if (!Enabled)
{
SetWaitingReason("WaitingForEnabled", "等待启用");
SkipExecution("Disabled", "当前节点未启用");
return Task.CompletedTask;
}
SetWaitingReason(null, null);
Output = Input * 2m;
return Task.CompletedTask;
}Basic nodes and examples
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 | Integer, decimal, string, and Boolean conversion; JSON Numeric Extraction; Integer to Decimal; explicit JSON and Boolean state/event conversion. |
| Data flow | State Merge, Event Split, Event Batch, and Event Join. |
| Shared context | Context Write, Read, and Remove within one active flow instance. |
| Execution errors | Execution Error Catch observes failed or rejected executions and ignores successful or explicitly skipped executions. |
| 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
Define Boolean input Trigger, Int32 input DelayMs, Boolean state output Output, and Boolean event output Done. Events have no initial value. The wait stays inside the managed execution; configure its total deadline to exceed the delay. Later inputs queue rather than interrupting the current wait.
/// <summary>初始化状态输出,不生成完成事件。</summary>
public override Task InitAsync()
{
Output = false;
return Task.CompletedTask;
}
/// <summary>在本次执行的期限内等待,完成后发布状态和事件。</summary>
public override async Task ChangedAsync()
{
if (!Trigger)
{
SetWaitingReason("WaitingForTrigger", "等待触发信号");
SkipExecution("TriggerOff", "触发信号为假");
return;
}
if (DelayMs < 0)
throw new RuleBusinessRejectedException("InvalidDelay", "DelayMs must be non-negative.");
SetWaitingReason(null, null);
var delay = DelayMs;
await Task.Delay(delay, ExecutionCancellationToken).ConfigureAwait(false);
Output = true;
Done = true;
}Counter Node
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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
View parameters and code: 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;
}Event-triggered nodes
Complete class: variable notifications
Freeze the broadcast snapshot before enqueueing. State Value deduplicates; event Data retains each notification. Convert port semantics before extracting a numeric payload for comparison.
Complete example code
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将匹配变量的冻结快照交给规则调度器消费。</summary>
[Category("触发器")]
[RuleNode("manual.variable-notification")]
public sealed class SimpleVariableNotifyNode : CustomExpressionBase, IDisposable
{
/// <summary>用于节点库显示,不参与类型身份匹配。</summary>
public override string Name => "变量变化触发";
/// <summary>限定通知设备,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>限定变量名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string VariableName { get; set; } = string.Empty;
/// <summary>保存与运行时对象解耦的值快照。</summary>
private JsonElement _value;
/// <summary>发布当前值状态,相同已提交值不重复传播。</summary>
[ExpressionOutput]
public JsonElement Value
{
get => _value;
private set => SetOutput(ref _value, value);
}
/// <summary>保存本次完整变量快照。</summary>
private JsonElement _data;
/// <summary>每次匹配通知都发布一次完整快照事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Data
{
get => _data;
private set => SetOutput(ref _data, value);
}
/// <summary>流程初始化时订阅,初始化阶段不生成虚假通知。</summary>
public override Task InitAsync()
{
GlobalData.VariableValueChangeEvent += OnVariableChanged;
return Task.CompletedTask;
}
/// <summary>在广播入口固定本次数据,不在广播线程传播规则输出。</summary>
private void OnVariableChanged(VariableRuntime runtime, VariableUpdateSource source)
{
if (!runtime.IsOnline ||
(!string.IsNullOrWhiteSpace(DeviceName) && runtime.DeviceName != DeviceName) ||
(!string.IsNullOrWhiteSpace(VariableName) && runtime.Name != VariableName))
return;
var data = runtime.AdaptVariableBasicData();
data.UpdateSource = source;
var snapshot = JsonSerializer.SerializeToElement(data);
var value = JsonSerializer.SerializeToElement(data.Value);
EnqueueNotification(() =>
{
Data = snapshot;
Value = value;
}, nameof(Data));
}
/// <summary>通知源没有需要在输入变化时重复执行的业务。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>流程停止后解除全局订阅,防止保留旧实例。</summary>
public void Dispose()
{
GlobalData.VariableValueChangeEvent -= OnVariableChanged;
}
}Complete class: alarm notifications
Capture the alarm identity and event payload before managed scheduling, and release the subscription with the flow instance.
Complete example code
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将指定级别与设备的报警快照发布为事件。</summary>
[Category("触发器")]
[RuleNode("manual.alarm-notification")]
public sealed class SimpleAlarmNotifyNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "报警变化触发";
/// <summary>最低允许报警级别。</summary>
[ExpressionInput]
public int MinLevel { get; set; } = 0;
/// <summary>限定设备名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>保存与广播对象解耦的报警快照。</summary>
private JsonElement _alarm;
/// <summary>每次匹配报警发布一个 JSON 事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Alarm
{
get => _alarm;
private set => SetOutput(ref _alarm, value);
}
/// <summary>保存报警摘要。</summary>
private string _text = string.Empty;
/// <summary>发布最近报警的可读摘要状态。</summary>
[ExpressionOutput]
public string Text
{
get => _text;
private set => SetOutput(ref _text, value);
}
/// <summary>在流程初始化时建立报警订阅。</summary>
public override Task InitAsync()
{
GlobalData.AlarmChangedEvent += OnAlarmChanged;
return Task.CompletedTask;
}
/// <summary>冻结载荷后入队,避免广播对象后续修改影响当前事件。</summary>
private void OnAlarmChanged(AlarmVariable alarm)
{
if (alarm.AlarmLevel < MinLevel)
{
return;
}
if (!string.IsNullOrWhiteSpace(DeviceName) && alarm.DeviceName != DeviceName)
{
return;
}
var snapshot = JsonSerializer.SerializeToElement(alarm);
var text = $"{alarm.DeviceName}.{alarm.Name} {alarm.EventType} {alarm.AlarmText}";
EnqueueNotification(() =>
{
Alarm = snapshot;
Text = text;
}, nameof(Alarm));
}
/// <summary>只由报警订阅发布业务输出。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>释放流程实例持有的全局事件订阅。</summary>
public void Dispose()
{
GlobalData.AlarmChangedEvent -= OnAlarmChanged;
}
}Complete class: device-state notifications
Use the same snapshot/queue pattern for device status; do not forward a mutable broadcast object or leave an old-instance subscription active.
Complete example code
using System;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using TGateway.Application;
/// <summary>将设备状态快照交给规则的事件接收端。</summary>
[Category("触发器")]
[RuleNode("manual.device-notification")]
public sealed class SimpleDeviceStatusNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "设备状态触发";
/// <summary>限定设备名称,空字符串表示不限制。</summary>
[ExpressionInput]
public string DeviceName { get; set; } = string.Empty;
/// <summary>保存本次设备快照。</summary>
private JsonElement _device;
/// <summary>每次匹配状态变化发布一次 JSON 事件。</summary>
[ExpressionOutput(IsEvent = true)]
public JsonElement Device
{
get => _device;
private set => SetOutput(ref _device, value);
}
/// <summary>保存最近状态的文字表示。</summary>
private string _status = string.Empty;
/// <summary>发布最近设备状态。</summary>
[ExpressionOutput]
public string Status
{
get => _status;
private set => SetOutput(ref _status, value);
}
/// <summary>在当前流程实例中订阅设备变化。</summary>
public override Task InitAsync()
{
GlobalData.DeviceStatusChangeEvent += OnDeviceChanged;
return Task.CompletedTask;
}
/// <summary>广播入口只固定快照并提交通知。</summary>
private void OnDeviceChanged(DeviceRuntime runtime, DeviceBasicData data)
{
if (!string.IsNullOrWhiteSpace(DeviceName) && data.Name != DeviceName)
{
return;
}
var snapshot = JsonSerializer.SerializeToElement(data);
var status = data.DeviceStatus.ToString();
EnqueueNotification(() =>
{
Device = snapshot;
Status = status;
}, nameof(Device));
}
/// <summary>设备变化由订阅通知处理。</summary>
public override Task ChangedAsync() => Task.CompletedTask;
/// <summary>解除设备事件订阅,允许旧流程实例释放。</summary>
public void Dispose()
{
GlobalData.DeviceStatusChangeEvent -= OnDeviceChanged;
}
}Writeback, publishing, and timers
Variable RPC node
This Web example writes on a rising edge, so another input change while Trigger=true does not repeat the write. VariableRuntime.RpcAsync retains permissions, write conversion and the control interlock. Pass ExecutionCancellationToken; reject missing variables and failed writes rather than reporting engine success.
| Direction | Name | Type | Meaning |
|---|---|---|---|
| Input | Trigger | Boolean | Rising-edge trigger. |
| Input | DeviceName | String | Device name. |
| Input | VariableName | String | Variable name. |
| Input | WriteData | Object | Value to write. |
| Output | Success | Boolean | Write result. |
| Output | Message | String | Result message. |
/// <summary>记录上一触发状态,只在上升沿执行写入。</summary>
private bool _lastTrigger;
/// <summary>变量写入不具备可靠的远端幂等确认,禁止不明结果自动重试。</summary>
protected override RuleNodeEffectKind EffectKind => RuleNodeEffectKind.NonIdempotentSideEffect;
/// <summary>初始化本实例的触发与业务结果。</summary>
public override Task InitAsync()
{
_lastTrigger = false;
Success = false;
Message = string.Empty;
return Task.CompletedTask;
}
/// <summary>通过正常变量写入链路处理一次上升沿,并保留引擎失败语义。</summary>
public override async Task ChangedAsync()
{
var rising = Trigger && !_lastTrigger;
_lastTrigger = Trigger;
if (!rising)
{
SetWaitingReason("WaitingForRisingEdge", "等待写入触发上升沿");
SkipExecution("NoRisingEdge", "本次输入没有写入触发上升沿");
return;
}
if (!GlobalData.TryGetVariableRuntime(DeviceName, VariableName, out var variable))
throw new RuleBusinessRejectedException("VariableNotFound", $"Variable not found: {DeviceName}.{VariableName}");
SetWaitingReason(null, null);
MarkSideEffectStarted();
var result = await variable.RpcAsync(
WriteData.ToSystemTextJsonElement(),
"rule",
ExecutionCancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
throw new RuleBusinessRejectedException("VariableRpcRejected", result.ErrorMessage ?? "Variable write was rejected.");
MarkSideEffectConfirmed();
Success = true;
Message = result.GetOperResult().ToString();
}Publishing nodes
Prefer built-in MQTT, email and Webhook nodes; see the Rule engine. When extending a receiver protocol, follow the complete classes in VariableNode.cs and these contracts.
| Stage | Requirement |
|---|---|
| Declaration | Declare EffectKind. A send without a reliable idempotency key is a NonIdempotentSideEffect, not pure computation. |
| Validation | Reject invalid URLs/missing credentials with RuleBusinessRejectedException. Report Skipped only before outputs or external effects start. |
| Send | Call MarkSideEffectStarted immediately before the request and pass ExecutionCancellationToken to a controlled reusable client. |
| Confirm | Check transport status and vendor business results; call MarkSideEffectConfirmed only after confirmation, then publish the result. |
| Failure/cancel | Do not swallow exceptions or turn Success=false plus a normal return into engine failure. Propagate cancellation. An unconfirmed external effect is classified as UnknownEffect. |
| Retry | The total deadline covers all attempts/backoff. Retry only recognized transient errors; do not blindly resend an unconfirmed non-idempotent action. |
| Shutdown | Stop production, await in-flight work and release resources. Hide URL tokens, passwords and credential headers from logs. |
Every node exposes ExecutionResult. A catch node ignores Succeeded and Skipped; connect business outputs separately from execution events. See Runtime observation.
Complete class: periodic timer
This example uses TUtility.TimerX. Save and reload after changing its interval parameter. The callback only enqueues managed notifications. Tick is an event, so repeated true values represent separate cycles; disabled periods do not publish or increment the count. Use Script/Timers/IntervalTimerNode.cs for dynamic interval/Cron behavior.
Complete example code
#nullable enable
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using TGateway.Application;
using TUtility;
/// <summary>以固定周期产生事件,由宿主串行发布输出。</summary>
[Category("定时器")]
[RuleNode("manual.periodic-pulse")]
public sealed class PeriodicPulseNode : CustomExpressionBase, IDisposable
{
/// <summary>节点库显示名称。</summary>
public override string Name => "周期脉冲";
/// <summary>保存时固定的周期毫秒数,重新装载后生效。</summary>
[ExpressionParameter]
public int IntervalMs { get; set; } = 1000;
/// <summary>允许当前周期发布事件。</summary>
[ExpressionInput]
public bool Enabled { get; set; } = true;
/// <summary>保存最近一次周期事件载荷。</summary>
private bool _tick;
/// <summary>每次周期发布真事件,不需要先写假复位。</summary>
[ExpressionOutput(IsEvent = true)]
public bool Tick
{
get => _tick;
private set => SetOutput(ref _tick, value);
}
/// <summary>保存当前实例的周期次数。</summary>
private int _count;
/// <summary>发布当前实例已经完成的周期计数。</summary>
[ExpressionOutput]
public int Count
{
get => _count;
private set => SetOutput(ref _count, value);
}
/// <summary>由当前节点持有并释放的非重入定时任务。</summary>
private TimerX? _timer;
/// <summary>校验周期并建立当前流程实例的定时任务。</summary>
public override Task InitAsync()
{
if (IntervalMs < 1)
throw new RuleBusinessRejectedException("InvalidInterval", "IntervalMs must be positive.");
Count = 0;
ReportWaiting();
_timer = new TimerX(OnTimer, null, IntervalMs, IntervalMs);
return Task.CompletedTask;
}
/// <summary>启用输入变化时更新空闲等待说明。</summary>
public override Task ChangedAsync()
{
ReportWaiting();
return Task.CompletedTask;
}
/// <summary>定时线程不执行节点业务,只向流程队列提交固定通知。</summary>
private void OnTimer(object? state)
{
if (!LifetimeCancellationToken.IsCancellationRequested)
EnqueueNotification(PublishTick, nameof(Tick));
}
/// <summary>在受管串行执行内更新计数并发布本次周期事件。</summary>
private void PublishTick()
{
if (!Enabled) return;
Count++;
Tick = true;
}
/// <summary>空闲周期等待与执行结果分别报告。</summary>
private void ReportWaiting()
{
SetWaitingReason(Enabled ? "WaitingForInterval" : "WaitingForEnabled",
Enabled ? $"等待下次周期:{IntervalMs} ms" : "等待启用");
}
/// <summary>停止时移除定时任务,流程令牌同时阻止后续通知进入。</summary>
public void Dispose()
{
_timer?.Dispose();
_timer = null;
}
}Troubleshooting and deployment
Development constraints
Use legal PascalCase C# parameter names and update all consuming flows after renaming. Give network/database work a timeout, keep JSON types explicit, limit high-frequency logging, and release events/timers/connections at shutdown. Dynamic node loading requires RuntimeFeature.IsDynamicCodeSupported.
Troubleshooting
| Symptom | Check |
|---|---|
| Compilation fails | Fix the first diagnostic. Web code contains members; full source needs imports, a complete class, and a stable unique [RuleNode]. |
| Node absent after compilation | Check published registration, Loaded Nodes, runtime dynamic-code support, and consuming-flow reload. |
| Downstream retains old values | Check whether this execution wrote the port, skipped, failed, or was filtered as unchanged; inspect the named connection rather than the retained value alone. |
| Notification repeats after reload | Check subscription/timer ownership, lifetime cancellation, and disposal of the old instance. |
| External action has unknown effect | Read back the device or inspect receiver records before deciding whether to resend. |
| Context or batch lost | These are flow-instance memory state; use a persistent target when retention is required. |
| Flow validation reports a cycle | Remove the cycle and model state explicitly. |
Deployment checklist
Before deployment, verify compilation and loaded metadata, two distinct input/output cases, event repetition, skip reasons, failure/cancellation behavior, shutdown, and the real device or receiver. Export the flow before production edits and keep credentials out of logs and screenshots. Runtime hot compilation requires dynamic-code support.
Related documents
Optional: retrieve development skills and sample projects through MCP
Follow preparation, examples and contracts above without configuring an AI client. Without product source, the material below provides public contracts, matching release references and sample projects.
Retrieve the tgateway-development skill through the target gateway’s MCP. Read references/custom-nodes.md, references/rule-engine.md and references/validation.md, and select examples through examples/index.json. Query the instance’s node catalog for stable typeId, parameters and ports before creating nodes and wiring rules. Product source is not required.
Declare web-node parameters separately and provide node members, without a complete FullSource class or top-level using statements. Verify initial values, propagation, cancellation, event unsubscription and runtime reload after saving. The skill also covers generating and validating rule JSON packages; deliver the requested rule flow as well as its custom nodes.