# 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 `samples/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`. - `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` 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 true ``` 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(NodeId id)` | Absolute NodeId | You own the id (e.g. generated `Variables.*`) | | `NodeFromTypeId(NodeId typeId)` / `NodeFromTypeId(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 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) // 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`, child wrapper instances, and method wrappers. - One `internal sealed class` per method — exposing typed `OnCall(...)` overloads bound to the method's declared arguments (the generator handles `Variant.TryGetValue` unpacking and `Variant.From` boxing — see [Methods with arguments](#methods-with-arguments--typed-oncall-overloads)). A method with inputs but no output binds to `OnCall(Action)`; a method with neither inputs nor outputs keeps the argument-less `OnCall(Action)` / `OnCall(Func)` 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(out T)`, the boxed result is written back through `Variant.From(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, shaped by the declared arguments: - **Inputs and outputs** → `OnCall(Func handler)` (synchronous dispatch through `MethodState.OnCallMethod2`) and `OnCall(Func> handler)` (async dispatch through `MethodState.OnCallMethod2Async`, awaited inside `AsyncCustomNodeManager.CallAsync` so the lambda may freely `await`). - **Inputs but no output** (a `void`-returning action) → `OnCall(Action handler)` and `OnCall(Func handler)`. The inputs are still unpacked via `Variant.TryGetValue`, so `builder.X.SetOutputVal.OnCall((float v) => …)` binds directly to the argument. - **No inputs and no output** → the argument-less `OnCall(Action)` / `OnCall(Func)` overloads. Methods with multiple output arguments are bound to a `ValueTuple` return — slot `i` is written from `__r.Item{i+1}`. The declared arguments are resolved from the method itself and, when the method carries none of its own, from its method declaration / method type. This means **instance methods imported from a NodeSet2** (whose `InputArguments`/`OutputArguments` live on the referenced declaration) get the same typed `OnCall` overloads as methods authored in a ModelDesign. ```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 and boxes the result back // through Variant.From. 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 `samples/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` on notifier wrappers Beyond reads, writes and method calls, the fluent API lets callers register an `IAsyncEnumerable` 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 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(GenerateDrumHeartbeatAsync); } private async IAsyncEnumerable 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.With( ev, (ushort)EventSeverity.Medium); ev.Message = PropertyState.With( 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` 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` extension is available directly on `INodeBuilder` where `TNotifier : BaseObjectState`. ### Two registration shapes ```csharp // Direct stream — registry uses the same instance for every activation. builder.Boilers.Boiler__1.DrumX001 .Publish(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( (notifier, context, ct) => GenerateAsync(notifier, context, ct)); ``` ### Tuning lifecycle with `EventPublishOptions` ```csharp builder.Boilers.Boiler__1.DrumX001 .Publish(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 `samples/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(); await builder.Build().RunAsync(); ``` `AddOpcUa()` registers a `ServiceProviderTelemetryContext` that adapts the host's `ILoggerFactory` to `ITelemetryContext` — no separate logging pipeline is required. `IOpcUaServerBuilder.AddNodeManager()` registers an `IAsyncNodeManagerFactory`; use `AddSyncNodeManager()` 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 `samples/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 NamespacesUris { get { var ns = base.NamespacesUris; ns.Add("urn:my:second:namespace"); return ns; } } public override ValueTask 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 true true net10.0 ``` 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 ``` `samples/MinimalBoilerServer` publishes cleanly with **zero AOT/trim warnings** (~29 MB self-contained EXE). ## Runtime NodeSet alternative When you want to host a NodeSet2 document without any source generation — for example a companion-spec XML received from a vendor, or a model that changes more frequently than you rebuild — use [AddRuntimeNodeSet](RuntimeNodeSets.md) instead. The runtime path loads a file or stream at server startup, imports nodes in topological dependency order, and exposes them through the same untyped `INodeManagerBuilder` surface as the `Configure` partial above. No rebuild is needed when the XML content changes; restart the server to pick up new content. See [RuntimeNodeSets.md](RuntimeNodeSets.md) for a side-by-side comparison of the two paths. ## 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.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("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("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` writes the Value attribute of a property child, **creating the property first when it does not already exist**. 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. When the child exists it is updated; when it exists but isn't a variable the call throws `BadTypeMismatch`. When the child is **missing**, `WithProperty` materialises a new read-only `PropertyState` (data type inferred from the value) under the current node and registers it with the owning node manager. This makes the helper usable on freshly built nodes such as custom DI functional groups — not just on properties that come from a loaded model: ```csharp // "Diagnostics" is a custom functional group with no model-defined // properties; WithProperty creates each one on the fly. node.WithProperty("LastError", string.Empty) .WithProperty("ErrorCount", 0) .WithProperty("LastSelfTest", (DateTimeUtc)DateTime.UtcNow); ``` Auto-created properties are read-only by default. Grant write access with the fluent `Writable()` helper — either standalone on a resolved variable, or inline via the `WithProperty(name, value, configure)` overload that positions a builder on the new property: ```csharp node.WithProperty("LastError", Variant.From(string.Empty), p => p.Writable()) .WithProperty("ErrorCount", 0); // or, on an existing variable: builder.Node("Pumps/Pump #1/Operational/SetPoint").Writable(); ``` ### 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(name, factory)` materialises a new `BaseInstanceState` subtype using a user-supplied factory delegate — typically a generated `Create` method from the source generator output. The returned `IInstanceBuilder` exposes `.Configure(builder => …)` for inline child wiring, `.AsNode()` for a typed `INodeBuilder` 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` 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` 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)` 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.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 cavitationAlarm = builder.Node("Events").CreateLimitAlarm(name) .ConfigureAlarm(a => a.Severity!.Value = (ushort)EventSeverity.Medium); builder.Variable("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`. ### Pushing runtime value changes to subscribers `OnRead` getters are invoked on the **Attribute (Read) service**, but a value that only lives behind a getter — or in a backing field mutated by an `OnCall` handler — will **not** reach subscribed MonitoredItems on its own. In previous implementations the fix was to mutate `Node.Value` and call `Node.ClearChangeMasks(...)`, but that node handle is deliberately unavailable through the fluent surface once `Configure` returns (the builder is sealed). Two fluent mechanisms close that gap. **1. `Bind(out IValueUpdater)` — explicit push.** Capture a runtime handle during `Configure` and store it on the manager; the handle survives sealing. `SetValue` assigns the value, timestamp and status and flushes the change mask in one serialized call, so both reads *and* subscriptions see the update: ```csharp private IValueUpdater m_ao01 = null!; partial void Configure(IMyNodeManagerBuilder builder) { builder.MyEquipment03.AO01.Builder.AsVariable() .Bind(out m_ao01); builder.MyEquipment03.SetOutputVal .OnCall((float value) => m_ao01.SetValue(value)); } ``` `IValueUpdater` also exposes `SetValue(value, statusCode)`, `SetValue(value, statusCode, sourceTimestamp)`, and `NotifyChange()` (flush a notification after an in-place mutation without changing the value). **2. `PollEvery(interval, getter)` — opt-in auto-sampling.** Register a periodic loop that reads the getter and pushes a change only when the value actually differs, so subscriptions update automatically with no change-notification code. An initial sample is applied immediately: ```csharp builder.MyEquipment03.AO01 .PollEvery(TimeSpan.FromMilliseconds(250), () => m_ao01Value); ``` Like `Simulation`, `PollEvery` reuses the manager-owned loop infrastructure and therefore **requires** the manager to derive from `FluentNodeManagerBase`; calling it 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 `src/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 LoadPredefinedNodesAsync( ISystemContext context, CancellationToken ct = default) { var nodes = new NodeStateCollection(); nodes.AddOpcUaDi(context); nodes.AddOpcUaMachinery(context); nodes.AddOpcUaPumps(context); return new ValueTask(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 `` 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. ### Mixing ModelDesign and NodeSet2 in one project A `ModelDesign` XML and a `NodeSet2` XML can be combined in the same project, and a node in one may reference a type defined in the other. A common split is to author the reusable **object types** as a NodeSet2 (e.g. exported from a modelling tool such as SiOME) and the concrete **instances** as a ModelDesign whose `TypeDefinition` points at those NodeSet2 types: ```xml http://example.org/EquipmentInstances http://example.org/EquipmentTypes ``` ```xml http://example.org/EquipmentTypes http://example.org/EquipmentInstances ``` The generator resolves the cross-model reference automatically — every input is supplied to the others as a resolution dependency (both `ModelDesign → NodeSet2` and `ModelDesign → ModelDesign`). > **Binding a `[NodeManager]` in a mixed project.** A `[NodeManager]` > may target the namespace of *either* input — the NodeSet2 type model > or the ModelDesign instance model — by setting its `NamespaceUri` to > that model's URI. Binding is resolved across both the NodeSet2 and the > ModelDesign generation passes, so a manager bound to the NodeSet2 types > is **not** reported as unmatched (`MODELGEN010`) just because the > project also contains a ModelDesign — and vice-versa. The generated > node-manager class name and namespace come from the annotated partial > class itself, **not** from `ModelSourceGeneratorPrefix`/`Name` (those > control the generated `*State`/type class names — see the note below). > **C# namespace of a NodeSet2 model.** The generated C# namespace for > a NodeSet2 input is derived from its `ModelUri` unless you set > `ModelSourceGeneratorPrefix` (C# namespace / prefix) and > `ModelSourceGeneratorName` (the `Namespaces` class identifier) on that > `AdditionalFiles` entry. A `Prefix`/`Name` declared *inside* a > referencing ModelDesign's `` does **not** rename the > NodeSet2's generated types — set the per-file MSBuild metadata on the > NodeSet2 entry to control it. ## 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. - **Reserved child names.** A component/property whose BrowseName matches a built-in `NodeState` attribute member (for example `Description` or `DisplayName`) shadows that member on the generated `*State` class and produces code that does not compile. Rename such children (the OPC UA `Description`/`DisplayName` *attributes* are always available without a dedicated child). ## Sample - `samples/MinimalBoilerServer/` — a fully self-contained, NativeAOT single-file Boiler server. Read it top-to-bottom in <200 lines. - `samples/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`). - `samples/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.