Files
UA-.NETStandard/Docs/SourceGeneratedNodeManagers.md
Marc Schier 4756e7c245 DI (OPC 10000-100) with samples (Pump, SW Update), fluent API gap closures and model dependency implementation (#3808)
This PR implements the **OPC UA Device Integration (DI, OPC 10000-100)**
companion-spec library trio (`Opc.Ua.Di` / `Opc.Ua.Di.Server` /
`Opc.Ua.Di.Client`) plus a worked example
(`Applications/MinimalPumpServer`) demonstrating the OPC 40223 Pumps
companion specification end-to-end with full simulation. It also closes
11 fluent-API gaps in `Opc.Ua.Server.Fluent`, three source-generator
bugs, integrates the DI layer into the unified `services.AddOpcUa()`
Microsoft.Extensions DI hosting pattern, and adds 250+ new tests.

**Worked example — `Applications/MinimalPumpServer/`**
- AOT-compatible console host using `Host.CreateApplicationBuilder` +
`AddOpcUa()`.
- `PumpNodeManager` inherits from `DiNodeManager`; multi-namespace (DI +
Machinery + Pumps) via runtime NodeSet2 loading and
`ModelLoaderBuilder`.
- Demonstrates declarative second-pump creation via
`.ConfigureDevicesFor<PumpNodeManager>(...)` and software-package
seeding from the same configurator.
- Full simulation: pressure, temperatures, power, flow, efficiency,
level, cavitation/overheat fault injection, alarm setup, NAMUR-style
supervision.

**Documentation**
- `Docs/Opc.Ua.Di/` — README, DeviceBuilder, Hosting, LockService,
SoftwareUpdate, ClientHelpers (6 new developer guides).
- `Docs/Opc.Ua.Di/ComplianceMatrix.md` — every DI declaration mapped to
In-v1 / Generated / Deferred / Excluded.
- `Docs/CompanionSpecLibraries.md` — packaging guide for new
companion-spec libraries.
- `Docs/FluentApiGaps.md` — historical record of G1–G11.
- `Docs/SourceGeneratedNodeManagers.md` updated for richer node
managers.
- `Docs/plans/StateMachineBuilder.md` — deferred state-machine builder
design.
2026-06-04 05:57:57 +02:00

830 lines
32 KiB
Markdown

# Source-Generated NodeManagers
This guide explains how to use the OPC UA stack source generator to emit a
ready-to-host `AsyncCustomNodeManager` for an information model design XML, and
how to wire callbacks (read/write/method/lifecycle) using the fluent
`INodeManagerBuilder` API. The combination is designed for **single-file,
NativeAOT-friendly** servers — see
`Applications/MinimalBoilerServer` for the canonical sample.
## What the generator produces
The base source generator already emits, for each model design:
- `Add{Ns}(NodeStateCollection, ISystemContext)` — populates a node
collection.
- `Add{Ns}(INodeStateFactoryBuilder)` — registers strongly-typed activators.
- `Add{Ns}DataTypes(IEncodeableFactoryBuilder)` — registers encodeables.
When `ModelSourceGeneratorGenerateNodeManager=true` is set **or** a
class is annotated with `[Opc.Ua.Server.Fluent.NodeManagerAttribute]`,
the generator **additionally** emits, in either the `{ModelNamespace}`
namespace (legacy MSBuild mode) or the user class's namespace
(attribute mode):
- `public partial class {Ns}NodeManager : AsyncCustomNodeManager` (legacy)
or `public partial class {UserClass} : AsyncCustomNodeManager` (attribute)
- Constructor `(IServerInternal, ApplicationConfiguration)`.
- Pre-registers the model namespace URI.
- `LoadPredefinedNodesAsync` returns
`new NodeStateCollection().Add{Ns}(context)` wrapped in a
`ValueTask<NodeStateCollection>`.
- `CreateAddressSpaceAsync` `await`s `base.CreateAddressSpaceAsync`,
then builds a fluent `INodeManagerBuilder`, invokes
`Configure(builder)`, calls `builder.Seal()`, and replays
`NotifyNodeAdded` for every predefined node so per-node lifecycle
hooks fire deterministically.
- `AddPredefinedNodeAsync` / `RemovePredefinedNodeAsync` overrides
forward to base and then dispatch the lifecycle notification.
- `OnMonitoredItemCreated` (still synchronous on the base) dispatches
the per-node hook.
- Declares `partial void Configure(INodeManagerBuilder builder);` for
user wiring.
- `public class {Ns}NodeManagerFactory : IAsyncNodeManagerFactory`
- Returns the namespace URI in `NamespacesUris`.
- `CreateAsync(IServerInternal, ApplicationConfiguration, CancellationToken)`
returns a `ValueTask<IAsyncNodeManager>` containing a new manager
instance.
- Both members are `virtual` so consumers can subclass to add a second
namespace or swap in a manager subclass.
`AddNodeManager` on `StandardServer` has overloads for both
`INodeManagerFactory` and `IAsyncNodeManagerFactory`; the generated
async factory binds to the latter automatically.
## Opting in
Add the generator analyzer to your project (this is what
`OPCFoundation.Opc.Ua.SourceGeneration.props` is for) and choose **one**
of the two opt-in modes:
### Per-class opt-in via `[NodeManager]` (recommended)
Annotate the user-authored partial class that should host the generated
manager:
```csharp
using Opc.Ua.Server.Fluent;
namespace MyCompany.MyServer;
[NodeManager]
public partial class MyDeviceNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
// wire your callbacks here
}
}
```
The generator emits a sibling `partial class MyDeviceNodeManager :
AsyncCustomNodeManager` and a `MyDeviceNodeManagerFactory` (implementing
`IAsyncNodeManagerFactory`) in the same namespace as the user class. No
MSBuild flag is required.
When a project carries multiple model designs, disambiguate which
design the attribute targets via either:
```csharp
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]
```
or by file stem:
```csharp
[NodeManager(Design = "BoilerDesign")]
```
Set `GenerateFactory = false` to suppress factory emission when you want
to ship a hand-written `IAsyncNodeManagerFactory`.
### Project-wide opt-in via MSBuild property (legacy)
If you prefer a generator-derived class identity (`{Prefix}NodeManager` /
`{Prefix}NodeManagerFactory`) without authoring a stub partial, set the
opt-in property:
```xml
<PropertyGroup>
<ModelSourceGeneratorGenerateNodeManager>true</ModelSourceGeneratorGenerateNodeManager>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="Generated\MyModelDesign.xml" />
<AdditionalFiles Include="Generated\MyModelDesign.csv" />
</ItemGroup>
```
This emits `{Prefix}NodeManager` + `{Prefix}NodeManagerFactory` for
every design in the project. Wire callbacks by adding a sibling
`partial class {Prefix}NodeManager` that implements `Configure`.
Without either opt-in, only the existing `Add{Ns}*` extensions are
emitted — hand-written `AsyncCustomNodeManager` (or legacy
`CustomNodeManager2`) subclasses keep working unchanged.
## Wiring callbacks: the `Configure` partial
Author a sibling partial that fills in `Configure`:
```csharp
namespace MyModel;
public partial class MyModelNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/Drum1001/LevelIndicator/Output")
.OnRead(MyReadHandler);
// Resolve a singleton instance by its TypeDefinitionId — stable
// across deployments and independent of where the instance sits
// in the tree. Ideal for well-known types like
// HistoryServerCapabilities or a single BoilerType instance.
builder
.NodeFromTypeId(ExpandedNodeId.ToNodeId(
MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris))
.OnNodeAdded((ctx, node) => /* ... */);
// For multi-instance types, disambiguate with a BrowseName:
builder
.NodeFromTypeId(
ExpandedNodeId.ToNodeId(MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris),
new QualifiedName("Boiler #2", nsIndex))
.OnRead(MyReadHandler);
}
}
```
Path syntax is `/`-separated **BrowseNames**, rooted at the model
namespace's predefined nodes. Optional `ns=N;` prefix lets you target a
different namespace.
### Addressing modes
| Method | Resolves by | Use when |
|--------|-------------|----------|
| `Node(string path)` | BrowseName path | Deterministic tree layout, multiple siblings |
| `Node(NodeId id)` / `Node<TState>(NodeId id)` | Absolute NodeId | You own the id (e.g. generated `Variables.*`) |
| `NodeFromTypeId(NodeId typeId)` / `NodeFromTypeId<TState>(NodeId typeId)` | `BaseInstanceState.TypeDefinitionId` | Singleton instance of a well-known type |
| `NodeFromTypeId(NodeId typeId, QualifiedName browseName)` | TypeDefinitionId + BrowseName | Multi-instance types — pick one |
`NodeFromTypeId` walks every predefined node owned by this manager
(and their sub-trees) at Configure-time. Error matrix:
* `BadNodeIdInvalid` — `typeId` is null or `IsNull`.
* `BadNodeIdUnknown` — no instance carries that `TypeDefinitionId`, or
the optional `browseName` disambiguator finds no match.
* `BadBrowseNameDuplicated` — more than one candidate and no
disambiguator was supplied (or multiple candidates share the same
`browseName`).
* `BadTypeMismatch` — typed overload's `TState` cast fails.
The builder exposes:
| Method | Wires |
|--------|-------|
| `OnRead` / `OnReadAsync` | `BaseVariableState.OnReadValue` |
| `OnWrite` / `OnWriteAsync` | `BaseVariableState.OnWriteValue` |
| `OnCall` / `OnCallAsync` | `MethodState.OnCallMethod*` |
| `OnNodeAdded` / `OnNodeRemoved` | Lifecycle dispatch from `NotifyNodeAdded` |
| `OnEvent`, `OnConditionRefresh`, `OnHistoryRead`, `OnHistoryUpdate`, `OnMonitoredItemCreated` | Manager-level dispatch keyed by `NodeId` |
`INodeManagerBuilder.NodeManager` is typed as `IAsyncNodeManager`. Use
`builder.NodeManager.SyncNodeManager` to obtain the synchronous
`INodeManager` facade for legacy interop, or cast it to your concrete
manager type if you need direct access.
All resolution happens **once** during `CreateAddressSpaceAsync`,
against the in-memory predefined-node tree. There is no reflection, no
`Activator.CreateInstance`, no `Expression.Compile` — the whole pipeline
is NativeAOT-safe.
## Typed model-traversal — the `Configure(I{Manager}NodeManagerBuilder)` partial
Alongside the string/NodeId/TypeId addressing surface above, the
generator emits a **second** `Configure` partial whose builder parameter
exposes one IntelliSense-aware accessor per predefined instance, child,
variable and method in the model. Every wiring site becomes a chain of
properties — typos are compile-time errors, not startup-time
`ServiceResultException`s.
```csharp
public partial class BoilerNodeManager
{
// Untyped Configure remains available for nodes outside the model
// (e.g. dynamic instances, foreign-namespace nodes, or just to keep
// hand-written wiring side-by-side with typed wiring).
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
.OnRead(GenerateDrumLevel);
}
// Typed Configure: every accessor below is a generated property
// resolved against the model. The compiler enforces both the path
// shape AND the value type of every leaf.
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// Variable: typed Func<double> handler — the generator removed
// the ref-Variant boilerplate.
builder.Boilers.Boiler__1.LCX001.Measurement
.OnRead(GenerateLevelMeasurement);
// Variable, async: routes through BaseVariableState.ReadAttributeAsync
// outside the lock so the lambda may freely await.
builder.Boilers.Boiler__1.PipeX002.FTX002.Output
.OnRead(GenerateOutputFlowAsync);
// Method, async: typed OnCall(Func<CancellationToken,ValueTask>)
// overload. Bind sync Action variants the same way.
builder.Boilers.Boiler__1.Simulation.Halt
.OnCall(HaltSimulationAsync);
}
}
```
Both partials are optional and both run; wiring the same node from
both is illegal and throws at startup. Choose whichever shape best fits
each call site — typed for everything declared in the model, untyped
for everything else.
### What the generator emits per model
For a model with `N` ObjectTypes and `M` predefined instances/children
the generator emits, into a single `{Manager}.FluentBuilders.g.cs`:
- `internal interface I{Manager}NodeManagerBuilder : INodeManagerBuilder`
— one accessor per top-level predefined instance.
- `internal sealed class {Manager}NodeManagerTypedBuilder` — proxy that
forwards `INodeManagerBuilder` members to the runtime builder while
surfacing the typed accessors.
- One `internal sealed class` per instance node — whose properties map
to typed `IVariableBuilder<TValue>`, child wrapper instances, and
method wrappers.
- One `internal sealed class` per method — exposing typed
`OnCall(Func<TIn1, …, TResult>)` and async
`OnCall(Func<TIn1, …, CancellationToken, ValueTask<TResult>>)`
overloads when the model declares input/output arguments
(the generator handles `Variant.TryGetValue` unpacking and
`Variant.From<T>` boxing — see [Methods with arguments](#methods-with-arguments--typed-oncall-overloads)).
Argument-less methods keep the no-arg `OnCall(Action)` /
`OnCall(Func<CancellationToken, ValueTask>)` overloads.
All emitted types are `internal sealed` because `Configure` is a
private partial — the surface never escapes the assembly. Child
accessors resolve namespace indices lazily through
`ISystemContext.NamespaceUris.GetIndexOrAppend(...)` so the wrappers
work regardless of the namespace-table order at runtime.
### Methods with arguments — typed `OnCall` overloads
When a model method declares input or output arguments the generator
emits **typed `OnCall` overloads** that bind directly to the user
handler's parameters and return value. Inputs are unboxed via
`Variant.TryGetValue<T>(out T)`, the boxed result is written back
through `Variant.From<T>(value)`, and `BadInvalidArgument` /
`BadArgumentsMissing` is returned when the wire shape does not match
the declared signature — none of which the user has to spell out.
Two overloads are emitted per method:
- `OnCall(Func<TIn1, TIn2, …, TResult> handler)` — synchronous
dispatch through `MethodState.OnCallMethod2`.
- `OnCall(Func<TIn1, TIn2, …, CancellationToken, ValueTask<TResult>>
handler)` — async dispatch through `MethodState.OnCallMethod2Async`,
awaited inside `AsyncCustomNodeManager.CallAsync` so the lambda may
freely `await`.
Methods with multiple output arguments are bound to a `ValueTuple`
return — slot `i` is written from `__r.Item{i+1}`. Methods with no
return value (action-only) keep the existing `OnCall(Action)` /
`OnCall(Func<CancellationToken, ValueTask>)` overloads.
```csharp
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
public partial class CalcNodeManager
{
partial void Configure(ICalcNodeManagerBuilder builder)
{
// Sync int+int → int. The generator unpacks each Variant
// through Variant.TryGetValue<int> and boxes the result back
// through Variant.From<int>.
builder.Calculator.Add
.OnCall((int a, int b) => a + b);
// Async double+double → double. The CancellationToken is
// forwarded by AsyncCustomNodeManager.CallAsync so the
// handler may freely await and honour cancellation.
builder.Calculator.Multiply
.OnCall(async (double x, double y, CancellationToken ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();
return x * y;
});
// Sync string+string → string. Reference-typed inputs and
// return values use the same Variant.TryGetValue / Variant.From
// path; the handler can null-coalesce safely because a missing
// input is reported as BadInvalidArgument before the lambda
// ever runs.
builder.Calculator.Concat
.OnCall((string left, string right) =>
(left ?? string.Empty) + (right ?? string.Empty));
}
}
```
The end-to-end sample lives in
`Applications/MinimalCalcServer/` (model in `Model/Calc.xml`, wiring
in `CalcNodeManager.Configure.cs`). The companion AOT round-trip tests
in `Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs` exercise
each shape over a real `Session.CallAsync(...)`.
## Event sources — typed `Publish<TEvent>` on notifier wrappers
Beyond reads, writes and method calls, the fluent API lets callers
register an `IAsyncEnumerable<TEvent>` against any notifier object so
events flow into the standard `NodeState.ReportEvent` path
automatically. The runtime owns the entire lifecycle: it starts the
iterator the first time a client subscribes to events on the notifier
(or any ancestor that walks via inverse `HasNotifier` /
`HasEventSource` references), cancels it when the last interested
monitored item disappears, and disposes it on manager teardown.
Generated managers derive from `Opc.Ua.Server.Fluent.FluentNodeManagerBase`
out of the box, so wiring is one call:
```csharp
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// The DrumX001 wrapper exposes Publish<TEvent> because the model
// declares EventNotifier=SubscribeToEvents on the node. Lazy by
// default — the iterator only runs while a client is monitoring.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync);
}
private async IAsyncEnumerable<BaseEventState> GenerateDrumHeartbeatAsync(
BaseObjectState notifier,
ISystemContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { yield break; }
var ev = new BaseEventState(parent: notifier);
ev.Severity = PropertyState<ushort>.With<VariantBuilder>(
ev, (ushort)EventSeverity.Medium);
ev.Message = PropertyState<LocalizedText>.With<VariantBuilder>(
ev, new LocalizedText("Drum heartbeat"));
yield return ev;
}
}
```
The runtime auto-populates `EventId`, `EventType`, `SourceNode`,
`SourceName` (browse name of the notifier), `Time`, `ReceiveTime`,
`Severity` (Medium when 0) and `Message` (empty `LocalizedText` when
unset) on the way out, so the iterator only sets the user-meaningful
fields.
### Where the typed overload appears
The generator emits `Publish<TEvent>` on a wrapper **only** when the
underlying node qualifies as an event source:
- `ObjectDesign.SupportsEvents == true` (i.e. the model declares
`EventNotifier=SubscribeToEvents`, `HasNotifier`, or
`HasEventSource`), or
- The node has a forward `GeneratesEvent` / `AlwaysGeneratesEvent`
reference.
`TEvent` is constrained to `BaseEventState` — pass any subtype that
fits the model's event hierarchy. For nodes outside the model, or
hand-written managers, the same `Publish<TNotifier, TEvent>` extension
is available directly on `INodeBuilder<TNotifier>` where
`TNotifier : BaseObjectState`.
### Two registration shapes
```csharp
// Direct stream — registry uses the same instance for every activation.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(channel.Reader.ReadAllAsync(default));
// Factory — registry calls the factory each time a client subscribes,
// so the iterator can capture the live notifier / context / token.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(
(notifier, context, ct) => GenerateAsync(notifier, context, ct));
```
### Tuning lifecycle with `EventPublishOptions`
```csharp
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync,
new EventPublishOptions
{
// Keep iterator running even with no monitored items.
AlwaysOn = false,
// Skip default population of EventId / EventType / Time /
// ReceiveTime / SourceNode / SourceName / Severity / Message.
SkipDefaultPopulation = false,
// Register the notifier as a server-wide root notifier so
// clients can monitor events on the Server object itself.
RegisterAsRootNotifier = true,
// Bound how long the registry waits for the iterator to
// honour cancellation on deactivation.
CancellationTimeout = TimeSpan.FromSeconds(5),
// Optional fault-handler invoked when the iterator throws.
OnError = (notifier, exception, context) => { /* log */ }
});
```
### Hand-written node managers
Managers that don't use the source generator can opt in by deriving
from `Opc.Ua.Server.Fluent.FluentNodeManagerBase` and calling
`AttachToBuilder(builder)` from inside their address-space-build
callback. Once attached, all `Publish` extensions resolve against the
manager's registry exactly as for generated managers.
The end-to-end sample lives in
`Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs`
(wiring `GenerateDrumHeartbeatAsync` on the drum). The companion AOT
round-trip test in
`Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs` subscribes a
real client `MonitoredItem` with an `EventFilter` and asserts the
heartbeats arrive end-to-end under NativeAOT constraints (no JIT, no
reflection).
## Single-file `Program.cs` — what it looks like
The shipping `services.AddOpcUa().AddServer(...)` extension wires the
server into the .NET Generic Host: configuration, certificate check,
`ApplicationInstance` lifetime and Ctrl+C/SIGTERM handling are all owned
by the host. User code stays at ~12 lines.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole();
builder.Services
.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "MyServer";
o.ApplicationUri = "urn:localhost:MyServer";
o.ProductUri = "uri:opcfoundation.org:MyServer";
o.AutoAcceptUntrustedCertificates = true;
o.EndpointUrls.Add("opc.tcp://localhost:51210/MyServer");
})
.AddNodeManager<MyModel.MyModelNodeManagerFactory>();
await builder.Build().RunAsync();
```
`AddOpcUa()` registers a `ServiceProviderTelemetryContext` that adapts
the host's `ILoggerFactory` to `ITelemetryContext` — no separate logging
pipeline is required. `IOpcUaServerBuilder.AddNodeManager<T>()` registers
an `IAsyncNodeManagerFactory`; use `AddSyncNodeManager<T>()` for the
legacy `INodeManagerFactory`. For advanced configuration (custom security
policies, additional builder calls), set `OpcUaServerOptions.ConfigureBuilder`.
That's the whole server. The Boiler version is in
`Applications/MinimalBoilerServer/Program.cs`.
## Multi-namespace and manager-swap subclassing
Because the generated factory members are `virtual`, you can extend
without forking:
```csharp
public sealed class MyExtendedFactory : MyModel.MyModelNodeManagerFactory
{
public override ArrayOf<string> NamespacesUris
{
get
{
var ns = base.NamespacesUris;
ns.Add("urn:my:second:namespace");
return ns;
}
}
public override ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration cfg,
CancellationToken cancellationToken = default)
=> new(new MyExtendedNodeManager(server, cfg));
}
```
The `Tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs`
suite verifies these subclassing scenarios.
## NativeAOT publishing
The project that hosts the generated manager only needs the standard AOT
settings:
```xml
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
```
Use `Microsoft.Extensions.Logging.Console` for AOT-friendly logging
(Serilog providers vary in AOT compatibility). Validate with:
```cmd
dotnet publish -c Release -r win-x64
```
`Applications/MinimalBoilerServer` publishes cleanly with **zero AOT/trim
warnings** (~29 MB self-contained EXE).
## Building richer node managers — the fluent extension surface
The Configure callback wires read/write/method/event hooks against
already-loaded predefined nodes, but real-world servers also need to
materialise dynamic instances, attach engineering units to measurements,
build alarms, run simulation loops, populate identification properties,
and compose multiple companion-spec models into a single address space.
The extensions below cover those workflows. All are AOT/trim safe and
follow the same return-the-same-builder chaining contract as the core
`INodeBuilder` API.
### Engineering units & EU range
`IVariableBuilder<TValue>.WithEngineeringUnits` and `.WithEURange`
attach the standard `EngineeringUnits` and `EURange` property children
on a `BaseAnalogState` variable. The extensions create the property
child on demand (matching the runtime's `AddEngineeringUnits` /
`AddEURange` helpers) and then set the Value attribute.
```csharp
builder.Variable<double>("Pumps/Pump #1/Operational/Measurements/FluidTemperature")
.OnRead(SimulateTemperature)
.WithEngineeringUnits(
new EUInformation("K", "Kelvin",
"http://www.opcfoundation.org/UA/units/un/cefact"))
.WithEURange(min: 233.15, max: 473.15);
// Convenience: set both at once.
builder.Variable<double>("Pumps/Pump #1/Operational/Measurements/Pressure")
.OnRead(SimulatePressure)
.WithUnits(EUInformations.Pascal, min: 0, max: 1_000_000);
```
Fail-fast behaviour: calling these on a non-`BaseAnalogState` variable
throws `ServiceResultException` with
`StatusCodes.BadTypeMismatch` — analog-only properties don't apply to
plain `BaseDataVariableState` nodes.
### Bulk property initialisation
`INodeBuilder.WithProperty` resolves a property child by browse-name
and writes its Value attribute. Typed overloads exist for every
built-in OPC UA scalar (`string`, `int`, `uint`, `double`, `bool`,
`DateTimeUtc`, `NodeId`, `LocalizedText`, `QualifiedName`, etc.) plus a
generic `Variant` escape hatch.
```csharp
builder.Node("Pumps/Pump #1/Identification")
.WithProperty("Manufacturer", "SimPump Corp")
.WithProperty("Model", "PumpX-2000")
.WithProperty("SerialNumber", "SN-001")
.WithProperty("DeviceClass", "Pump")
.WithProperty("ProductInstanceUri",
"urn:simdevice:SimPump:PumpX-2000:SN-001");
```
Reference resolution is by browse-name only (case-sensitive,
namespace-agnostic), matching the AOT-safe constraint of the rest of
the fluent surface. Throws `BadNodeIdUnknown` when the property child
is missing and `BadTypeMismatch` when the child exists but isn't a
variable.
### References & dynamic child objects
`INodeBuilder.Organizes`, `.HasComponent`, `.HasProperty` and the
generic `.AddReference(typeId, isInverse, target)` add forward /
inverse references on the current node. They're the foundation for
OPC UA Device Integration (DI)'s FunctionalGroup pattern — group
unrelated variables under a shared object via `Organizes`.
```csharp
// Wire existing measurement variables into a custom FunctionalGroup.
builder.Node("Pumps/Pump #1/Operational/MyGroup")
.Organizes(temperatureNodeId)
.Organizes(pressureNodeId)
.HasProperty(metadataNodeId);
```
`INodeBuilder.AddObject(browseName, typeDefinitionId)` synthesises a
new `BaseObjectState` child under the current node and returns a typed
builder for the new object. NodeIds follow the
`{parentIdentifier}_{childName}` pattern used by the source generator's
default factory.
```csharp
// Create a custom FunctionalGroup, then attach measurements.
builder.Node("Pumps/Pump #1")
.AddObject(new QualifiedName("CustomMetrics", pumpsNs))
.Organizes(t1).Organizes(t2);
```
Newly created objects are reachable through navigation from the parent
immediately. If you also need direct NodeId lookup (e.g. for `Read`
service calls that target the new node by id), invoke
`AsyncCustomNodeManager.AddPredefinedNodeAsync` on the new node from a
deferred `OnNodeAdded` callback.
### Creating instances of model types
`INodeBuilder.CreateInstance<TState>(name, factory)` materialises a
new `BaseInstanceState` subtype using a user-supplied factory delegate
— typically a generated `Create<TypeName>` method from the source
generator output. The returned `IInstanceBuilder<TState>` exposes
`.Configure(builder => …)` for inline child wiring, `.AsNode()` for a
typed `INodeBuilder<TState>` view, and `.Done()` to return to the
parent builder.
```csharp
builder.Node("Pumps")
.CreateInstance(
new QualifiedName("Pump #2", pumpsNs),
pumpTypeId,
parent => context.CreatePumpType(parent))
.Configure(p2 =>
p2.AsNode()
.WithProperty("Manufacturer", "Vendor B")
.WithProperty("SerialNumber", "SN-002"));
```
The factory pattern keeps the API reflection-free and AOT safe — the
generator already emits the per-type `Create<Type>` extension methods
that the factory delegate calls into.
### Alarm setup (MVP)
`INodeBuilder.CreateLimitAlarm`, `.CreateExclusiveLimitAlarm` and
`.CreateOffNormalAlarm` attach a fresh alarm condition under the
current node and return an `IAlarmBuilder<TState>` for further
configuration:
```csharp
builder.Node("Pumps/Pump #1/Events")
.CreateLimitAlarm(new QualifiedName("OverTempAlarm", pumpsNs))
.WithLimits(highHigh: 380, high: 370, low: 273, lowLow: 263)
.MonitorVariable(temperatureNode)
.OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good)
.OnConfirm((ctx, condition, eventId, comment) => ServiceResult.Good);
```
For full state access (severity tables, retain flag, branches), use
the `.ConfigureAlarm(Action<TState>)` escape hatch:
```csharp
builder.Node("Events")
.CreateLimitAlarm(new QualifiedName("Custom", ns))
.WithLimits(high: 100)
.ConfigureAlarm(alarm =>
{
alarm.Retain!.Value = true;
// any state-class mutation goes here
});
```
### Boolean supervision → alarm activation (NAMUR pattern)
`IVariableBuilder<bool>.OnRisingEdge` / `.OnFallingEdge` register
callbacks that fire when the variable's value transitions. The
`.ActivatesAlarm(alarmBuilder)` extension wires the bool variable to
an `AlarmConditionState`'s ActiveState so it flips in lockstep with
the supervision flag — exactly the OPC UA DI / NAMUR NE 107 pattern.
```csharp
IAlarmBuilder<NonExclusiveLimitAlarmState> cavitationAlarm =
builder.Node("Events").CreateLimitAlarm(name)
.ConfigureAlarm(a => a.Severity!.Value = (ushort)EventSeverity.Medium);
builder.Variable<bool>("Pump #1/Events/Supervision/ProcessFluid/Cavitation")
.ActivatesAlarm(cavitationAlarm);
```
Detection is value-change based: transitions only fire when something
else (an `OnWrite` handler, a simulation tick, a client write) actually
mutates the variable.
### Simulation timers
`INodeManagerBuilder.Simulation(interval).OnTick(...)` registers a
periodic background loop owned by the `FluentNodeManagerBase`. Each
tick fires on a `PeriodicTimer` and is cancelled when the manager is
disposed; exceptions inside handlers are logged and do not kill the
loop.
```csharp
partial void Configure(INodeManagerBuilder builder)
{
builder.Simulation(TimeSpan.FromMilliseconds(250))
.OnTick((ctx, elapsed) =>
{
m_temperature = 313.15 + 5 * Math.Sin(m_t * 0.01);
m_pressure = 200000 + 50000 * Math.Sin(m_t * 0.03);
m_t++;
});
}
```
Async tick handlers receive a `CancellationToken` honouring manager
disposal — use it for any awaitable work inside the loop. Multiple
`.OnTick` calls on the same `Simulation()` builder all fire on every
tick.
The simulation registry **requires** the manager to derive from
`FluentNodeManagerBase` (the source generator-emitted manager already
does); calling `.Simulation()` on a plain `CustomNodeManager2` throws
`StatusCodes.BadConfigurationError`.
### Multi-model composition
The only supported mode for combining models is **source-generated
library references**. Each companion spec is built once into its
own model library (a `Libraries/Opc.Ua.{Spec}/` project that
consumes the ModelDesign XML and emits an `AddOpcUa{Spec}`
extension method); the consumer adds project references and calls
the generated extensions directly in dependency order:
```csharp
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context, CancellationToken ct = default)
{
var nodes = new NodeStateCollection();
nodes.AddOpcUaDi(context);
nodes.AddOpcUaMachinery(context);
nodes.AddOpcUaPumps(context);
return new ValueTask<NodeStateCollection>(nodes);
}
```
Source-generated models are AOT-friendly, deterministic, and
produce typed `*State` / `*Client` proxies. **Every application-
owned model must ship as source-generated content** — companion
specs ship as project references; locally-owned NodeSet2 XMLs are
wired through `<AdditionalFiles>` so the source generator emits
the same typed surface inside the consuming assembly. Each
`AddOpcUa{Spec}(context)` extension is idempotent and re-entrant,
so direct chaining in dependency order is the recommended pattern.
## Current limitations
- **Browse-path wildcards** (`*`, `**`) are not supported. Wire each
path explicitly or resolve by NodeId / TypeDefinitionId.
- **HistoryRead/Update** integration is delegate-only in v1; deeper
paging/queueing still requires `INodeManager2` work.
## Sample
- `Applications/MinimalBoilerServer/` — a fully self-contained,
NativeAOT single-file Boiler server. Read it top-to-bottom in
&lt;200 lines.
- `Applications/MinimalCalcServer/` — a calculator server that
exercises the typed
[methods-with-arguments OnCall overloads](#methods-with-arguments--typed-oncall-overloads)
end-to-end (sync `int+int → int`, async `double+double → double`,
sync `string+string → string`).
- `Applications/PumpDeviceIntegrationServer/` — the full OPC 40223
Pumps companion server. Exercises every fluent extension above
(engineering units, identification properties, FunctionalGroup
wiring, instance creation, limit alarm with NAMUR-style boolean
supervision, periodic simulation tick, and multi-model loader for
DI + Machinery + Pumps), and additionally attaches the OPC
10000-100 software-update facet to a second declarative pump
device.