mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
4756e7c245
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.
830 lines
32 KiB
Markdown
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
|
|
<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.
|
|
|