diff --git a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs index 42d1123211..ac6389f23d 100644 --- a/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs +++ b/Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs @@ -28,7 +28,10 @@ * ======================================================================*/ using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Server.Fluent; @@ -52,30 +55,62 @@ namespace Boiler /// after base.CreateAddressSpace has materialized the /// predefined Boiler instance, so all browse paths into the /// Boilers/Boiler #1 sub-tree are addressable here. + /// + /// The wiring below is intentionally a mix of the four addressing + /// styles — string browse path, absolute , + /// type-definition lookup, and the new typed + /// surface — to demonstrate + /// that the legacy and source-generator-friendly APIs interoperate. + /// /// [NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")] public partial class BoilerNodeManager { private long m_drumLevelTicks; private long m_pipeFlowTicks; + private long m_inputFlowTicks; + private long m_drumHeartbeatTicks; partial void Configure(INodeManagerBuilder builder) { - // Addressing by browse-path — works against the deployment - // tree produced by the generator from the NodeSet2. + // (1) Legacy browse-path addressing with the lower-level + // ref-Variant callback. Use this when you need full control + // over the StatusCode / SourceTimestamp returned per read. builder .Node("Boilers/Boiler #1/DrumX001/LIX001/Output") .OnRead(GenerateDrumLevel); - // Addressing by absolute NodeId — use the generator's - // strongly-typed identifier table instead of a magic string. + // (2) Absolute NodeId addressing using the strongly-typed + // identifier table generated from the NodeSet2. builder .Node(ExpandedNodeId.ToNodeId( VariableIds.Boilers_Boiler__1_PipeX001_FTX001_Output, Server.NamespaceUris)) .OnRead(GeneratePipeFlow); - // Addressing by TypeDefinitionId — robust for well-known + // (3) New typed IVariableBuilder via the absolute NodeId + // table — the simple Func overload removes the + // ref-Variant boilerplate from the lambda and runs through + // the same sync read path as (1). + builder + .Variable(ExpandedNodeId.ToNodeId( + VariableIds.Boilers_Boiler__1_FCX001_Measurement, + Server.NamespaceUris)) + .OnRead(GenerateInputFlow); + + // (4) New typed async IVariableBuilder overload — the + // handler runs OUTSIDE the NodeState lock (lock-released + // semantics in BaseVariableState.ReadAttributeAsync), so the + // lambda may freely await without tying up a thread-pool + // thread. Hooked to the second pipe's flow output to show + // the routing end-to-end through AsyncCustomNodeManager. + builder + .Variable(ExpandedNodeId.ToNodeId( + VariableIds.Boilers_Boiler__1_PipeX002_FTX002_Output, + Server.NamespaceUris)) + .OnRead(GenerateOutputFlowAsync); + + // (5) TypeDefinitionId addressing — robust for well-known // singletons, independent of browse-path layout. builder .NodeFromTypeId(ExpandedNodeId.ToNodeId(ObjectTypeIds.BoilerType, Server.NamespaceUris)) @@ -86,6 +121,112 @@ partial void Configure(INodeManagerBuilder builder) node.BrowseName)); } + /// + /// Source-generator-emitted typed builder partial. The fluent + /// surface here walks the model's predefined-instance tree + /// directly: each segment is a generated property whose return + /// type is the typed wrapper for the next node. Browse paths, + /// NodeIds, and namespace-index lookups are eliminated at the + /// callsite — IntelliSense surfaces every legal child, and + /// typos are compile-time errors. + /// + /// + /// This partial coexists with ; + /// the generated CreateAddressSpaceAsync override invokes + /// both. Wiring the same node from both partials is illegal and + /// will throw at startup, so the targets here are deliberately + /// disjoint from the ones in the non-typed partial above. + /// + partial void Configure(IBoilerNodeManagerBuilder builder) + { + // (6) Typed traversal — the LCX001 level controller measurement + // is reached via generated accessors with no string paths or + // NodeIds in sight. The Func handler is the same shape + // as wiring (3) but the resolution is fully type-checked. + builder.Boilers.Boiler__1.LCX001.Measurement + .OnRead(GenerateLevelControlMeasurement); + + // (7) Typed traversal of a method node — the Halt method is + // bound to an async lambda. The generator emits the typed + // OnCall(Func) overload that + // erases the (ISystemContext, MethodState, NodeId, ArrayOf, + // List, CancellationToken)/ServiceResult plumbing entirely. + builder.Boilers.Boiler__1.Simulation.Halt + .OnCall(HaltSimulationAsync); + + // (8) Event publish source — the source-generated typed + // wrapper for DrumX001 exposes Publish because the + // model declares EventNotifier=SubscribeToEvents on this + // node. The factory iterator runs lazily: the registry + // activates it the first time a client subscribes to events + // on the drum (or any ancestor that walks via inverse + // HasNotifier/HasEventSource references) and cancels it once + // the last interested monitored item disappears. The + // registry auto-populates EventId/EventType/Time/SourceNode + // so the iterator only fills the user-meaningful fields. + builder.Boilers.Boiler__1.DrumX001 + .Publish(GenerateDrumHeartbeatAsync); + } + + private long m_levelMeasurementTicks; + + private double GenerateLevelControlMeasurement() + { + long t = Interlocked.Increment(ref m_levelMeasurementTicks); + return 50.0 + (10.0 * Math.Cos(t * 0.05)); + } + + private async ValueTask HaltSimulationAsync(CancellationToken cancellationToken) + { + // Token-aware async work to demonstrate the end-to-end async + // method call path through AsyncCustomNodeManager.CallAsync. + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + Server.Telemetry.CreateLogger() + .LogInformation("Boiler simulation halted."); + } + + /// + /// Lazily emits a synthetic heartbeat + /// every 500ms while at least one client is monitoring events on + /// the drum notifier. Cancellation tears the iterator down on the + /// last unsubscribe (or on manager disposal). The registry fills + /// in EventId, EventType, SourceNode, + /// SourceName, Time, and ReceiveTime on the + /// way out, so the iterator only sets the user-meaningful fields. + /// + private async IAsyncEnumerable GenerateDrumHeartbeatAsync( + BaseObjectState notifier, + ISystemContext context, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + Task delay = Task.Delay( + TimeSpan.FromMilliseconds(500), cancellationToken); + try + { + await delay.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + yield break; + } + + long sequence = Interlocked.Increment(ref m_drumHeartbeatTicks); + var ev = new BaseEventState(parent: notifier); + ev.Severity = PropertyState.With( + ev, (ushort)EventSeverity.Medium); + ev.Message = PropertyState.With( + ev, + new LocalizedText(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "Drum heartbeat #{0}", + sequence))); + yield return ev; + } + } + private ServiceResult GenerateDrumLevel( ISystemContext context, NodeState node, @@ -120,5 +261,23 @@ private ServiceResult GeneratePipeFlow( timestamp = DateTimeUtc.Now; return ServiceResult.Good; } + + private double GenerateInputFlow() + { + long t = Interlocked.Increment(ref m_inputFlowTicks); + return 80.0 + (15.0 * Math.Sin(t * 0.09)); + } + + private async ValueTask GenerateOutputFlowAsync(CancellationToken cancellationToken) + { + // Token-aware no-op delay simulates an out-of-process source + // (a database round-trip, a remote sensor read, etc.) without + // pulling in a real I/O dependency. Cancellation correctness + // here flows all the way back to AsyncCustomNodeManager.ReadAsync. + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + long t = Interlocked.Increment(ref m_pipeFlowTicks); + return 105.0 + (25.0 * Math.Cos(t * 0.07)); + } } } diff --git a/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs b/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs new file mode 100644 index 0000000000..9449d2c296 --- /dev/null +++ b/Applications/MinimalCalcServer/CalcNodeManager.Configure.cs @@ -0,0 +1,96 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua; +using Opc.Ua.Server.Fluent; + +namespace Calc +{ + /// + /// Source-generated CustomNodeManager2 for the calculator + /// sample. The [NodeManager] attribute opts this partial class + /// in to source generation: the generator emits the sibling partial + /// that owns the predefined-node load and calls back into the + /// Configure partials in CalcNodeManager.Configure.cs. + /// + /// + /// The calculator model intentionally exposes three method shapes — + /// sync int+int→int, async double+double→double, and reference-typed + /// string+string→string — to exercise the generator's typed + /// OnCall input-unpack and output-box code paths end-to-end. + /// The wiring lives in the second partial; this file holds only the + /// attribute-bearing class declaration so that the source generator + /// pipeline ([NodeManager] + AdditionalFiles NodeSet2) + /// can be reasoned about in one glance. + /// + [NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")] + public partial class CalcNodeManager + { + partial void Configure(INodeManagerBuilder builder) + { + // Intentionally empty. Kept to mirror the Boiler sample and + // demonstrate that the typed and non-typed Configure partials + // coexist on the same class — the generated address-space + // bootstrap invokes both. The calculator sample wires every + // node through the typed surface in + // Configure(ICalcNodeManagerBuilder). + } + + partial void Configure(ICalcNodeManagerBuilder builder) + { + // Sync int+int→int — exercises Variant.TryGetValue on + // each input arg and Variant.From on the boxed result. + builder.Calculator.Add + .OnCall((int a, int b) => a + b); + + // Async double+double→double — exercises the typed async + // OnCall overload (Func>) end-to-end through + // AsyncCustomNodeManager.CallAsync, plus Variant.From + // on the boxed result. + builder.Calculator.Multiply + .OnCall(async (double x, double y, CancellationToken ct) => + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return x * y; + }); + + // Sync string+string→string — exercises reference-type + // marshalling on both inputs and the output. Coalesces null + // inputs to empty so the handler is well-defined when a + // client passes a null Variant in either slot. + builder.Calculator.Concat + .OnCall((string left, string right) => + (left ?? string.Empty) + (right ?? string.Empty)); + } + } +} diff --git a/Applications/MinimalCalcServer/MinimalCalcServer.csproj b/Applications/MinimalCalcServer/MinimalCalcServer.csproj new file mode 100644 index 0000000000..d357d0ea36 --- /dev/null +++ b/Applications/MinimalCalcServer/MinimalCalcServer.csproj @@ -0,0 +1,37 @@ + + + net10.0 + Exe + MinimalCalcServer + MinimalCalcServer + OPC Foundation + Self-contained .NET console OPC UA server demonstrating source-generated NodeManagers + the typed fluent OnCall surface for methods with arguments. Native AOT compatible. + Copyright © 2004-2025 OPC Foundation, Inc + Calc + enable + false + $(NoWarn);CA1822 + true + + + + + + + + Analyzer + false + + + + + + + + + http://opcfoundation.org/UA/Calc/ + Calc + Calc + + + diff --git a/Applications/MinimalCalcServer/Model/Calc.xml b/Applications/MinimalCalcServer/Model/Calc.xml new file mode 100644 index 0000000000..5899501979 --- /dev/null +++ b/Applications/MinimalCalcServer/Model/Calc.xml @@ -0,0 +1,114 @@ + + + + + + http://opcfoundation.org/UA/Calc/ + http://opcfoundation.org/UA/ + + + + + Returns a + b. + + + First addend. + + + Second addend. + + + + + Computed sum. + + + + + + Returns a * b. Wired with the asynchronous typed OnCall overload. + + + First factor. + + + Second factor. + + + + + Computed product. + + + + + + Returns left + right. + + + Left-hand string. + + + Right-hand string. + + + + + Concatenated value. + + + + + + + An object type that exposes Add/Multiply/Concat methods exercising the typed fluent OnCall surface. + + + + + + + + + + A calculator exposing Add, Multiply and Concat under the Objects folder. + + + ua:Organizes + ua:ObjectsFolder + + + + + diff --git a/Applications/MinimalCalcServer/Program.cs b/Applications/MinimalCalcServer/Program.cs new file mode 100644 index 0000000000..d2eaca35dd --- /dev/null +++ b/Applications/MinimalCalcServer/Program.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua.Server.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +int port = int.TryParse(builder.Configuration["port"], out int p) ? p : 62542; + +builder.Services + .AddOpcUaServer(o => + { + o.ApplicationName = "MinimalCalcServer"; + o.ApplicationUri = "urn:localhost:OPCFoundation:MinimalCalcServer"; + o.ProductUri = "uri:opcfoundation.org:MinimalCalcServer"; + o.AutoAcceptUntrustedCertificates = true; + o.EndpointUrls.Add($"opc.tcp://localhost:{port}/MinimalCalcServer"); + }) + .AddNodeManager(); + +await builder.Build().RunAsync().ConfigureAwait(false); diff --git a/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs b/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/Applications/MinimalCalcServer/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/Docs/SourceGeneratedNodeManagers.md b/Docs/SourceGeneratedNodeManagers.md index ec65974138..6965402084 100644 --- a/Docs/SourceGeneratedNodeManagers.md +++ b/Docs/SourceGeneratedNodeManagers.md @@ -202,6 +202,278 @@ 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(Func)` and async + `OnCall(Func>)` + overloads when the model declares input/output arguments + (the generator handles `Variant.TryGetValue` unpacking and + `Variant.From` boxing — see [Methods with arguments](#methods-with-arguments--typed-oncall-overloads)). + Argument-less methods keep the no-arg `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: + +- `OnCall(Func handler)` — synchronous + dispatch through `MethodState.OnCallMethod2`. +- `OnCall(Func> + 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)` 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 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 +`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` 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 +`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 `Opc.Ua.Server.Hosting.AddOpcUaServer(...)` extension wires the @@ -308,5 +580,11 @@ warnings** (~29 MB self-contained EXE). ## Sample -`Applications/MinimalBoilerServer/` — a fully self-contained, NativeAOT -single-file Boiler server. Read it top-to-bottom in <200 lines. +- `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`). diff --git a/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs b/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs new file mode 100644 index 0000000000..d0962fc01a --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/EventNotifierBuilderExtensions.cs @@ -0,0 +1,177 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Extension methods that register an external event source against a + /// notifier node resolved by the fluent + /// surface. The source's items are delivered through + /// on the notifier, so monitored + /// items on the notifier (or on an ancestor that the notifier is + /// reachable from via inverse HasNotifier references) receive + /// the events using the standard OPC UA event-dispatch path. + /// + /// + /// + /// The extensions require the manager being wired to derive from + /// (the + /// is attached during the manager's + /// startup). Calling Publish on a builder backed by a manager + /// that does not opt in throws + /// with + /// . + /// + /// + /// By default each registered source is lazy: the iterator only runs + /// while at least one monitored item is interested in the notifier. + /// See for tuning. + /// + /// + public static class EventNotifierBuilderExtensions + { + /// + /// Registers as the event source for + /// the resolved notifier. is invoked + /// each time the source activates (lazy default activates on the + /// first monitored-item subscription; eager activation under + /// activates once at + /// builder seal time). The factory receives the notifier node, + /// the manager's , and a + /// that the iterator is required + /// to honor. + /// + /// + /// Notifier node type. Constrained to + /// because only object nodes carry + /// the EventNotifier attribute. + /// + /// + /// Event payload type. Must derive from + /// ; the registry covariantly streams + /// the items as BaseEventState through + /// ReportEvent. + /// + public static INodeBuilder Publish( + this INodeBuilder nodeBuilder, + Func> factory, + EventPublishOptions options = null) + where TNotifier : BaseObjectState + where TEvent : BaseEventState + { + if (nodeBuilder == null) + { + throw new ArgumentNullException(nameof(nodeBuilder)); + } + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + EventSourceRegistry registry = GetRegistryOrThrow(nodeBuilder); + TNotifier notifier = nodeBuilder.Node; + + // IAsyncEnumerable is covariant on TEvent so the + // cast at GetAsyncEnumerator time is allocation-free. + registry.Register( + notifier, + (n, ctx, ct) => factory((TNotifier)n, ctx, ct), + options); + + return nodeBuilder; + } + + /// + /// Registers the supplied as the event + /// source for the resolved notifier. Each activation of the + /// source calls GetAsyncEnumerator on the same + /// instance — callers whose source is + /// not re-iterable (e.g. a one-shot iterator) should use the + /// factory overload instead. + /// + /// + /// Notifier node type. Constrained to + /// . + /// + /// + /// Event payload type. Must derive from + /// . + /// + public static INodeBuilder Publish( + this INodeBuilder nodeBuilder, + IAsyncEnumerable source, + EventPublishOptions options = null) + where TNotifier : BaseObjectState + where TEvent : BaseEventState + { + if (nodeBuilder == null) + { + throw new ArgumentNullException(nameof(nodeBuilder)); + } + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + EventSourceRegistry registry = GetRegistryOrThrow(nodeBuilder); + TNotifier notifier = nodeBuilder.Node; + + registry.Register( + notifier, + (_, _, _) => source, + options); + + return nodeBuilder; + } + + private static EventSourceRegistry GetRegistryOrThrow( + INodeBuilder nodeBuilder) + where TNotifier : NodeState + { + if (nodeBuilder.Builder is not NodeManagerBuilder concrete || + concrete.EventSources == null) + { + string managerTypeName = nodeBuilder.Builder?.NodeManager?.GetType().FullName + ?? "(unknown)"; + + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Publish requires the node manager to derive from FluentNodeManagerBase. " + + "Manager type '{0}' does not opt in.", + managerTypeName); + } + + return concrete.EventSources; + } + } +} diff --git a/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs b/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs new file mode 100644 index 0000000000..d9f8a9af3f --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/EventPublishOptions.cs @@ -0,0 +1,110 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Optional knobs for + /// EventNotifierBuilderExtensions.Publish. + /// + /// + /// + /// All properties are init-only so the same options instance can safely be + /// reused across multiple Publish registrations. + /// + /// + /// Default behavior (no options supplied): lazy activation, default-field + /// population enabled, error policy logs and continues, not registered as + /// a root notifier. + /// + /// + public sealed record EventPublishOptions + { + /// + /// When true, the source's iterator is pumped immediately at + /// builder seal time and stays active until the manager disposes — + /// regardless of whether any client is monitoring the node. Useful + /// for diagnostic event streams whose backpressure or history-storage + /// guarantees must be preserved across reconnect storms. Default + /// false (lazy activation: events are pulled only while + /// is true). + /// + public bool AlwaysOn { get; init; } + + /// + /// When true, the registry does NOT auto-populate + /// EventId, EventType, SourceNode, + /// SourceName, Time, ReceiveTime, + /// Severity, and Message on each yielded event. Use + /// this when your iterator emits fully-populated events + /// (e.g. replayed from a history store). Default false + /// (matches BaseEventState.Initialize). + /// + public bool SkipDefaultPopulation { get; init; } + + /// + /// When true, the notifier node is added to the manager's + /// root-notifier collection so its events propagate to clients that + /// subscribe to the Server object. Default false: + /// only direct subscribers to the notifier (or to an ancestor that + /// the notifier is reachable from via inverse HasNotifier + /// references) receive events. + /// + /// + /// Enabling this option overwrites the notifier's + /// with the manager's + /// root-notifier handler, which is incompatible with attaching an + /// OnEvent interceptor or with direct per-node event monitored + /// items on the same notifier; the runtime emits a debug-level log + /// line documenting the trade-off. + /// + public bool RegisterAsRootNotifier { get; init; } + + /// + /// Maximum time the registry waits for the iterator to honor its + /// when the source + /// deactivates or the manager disposes. After the timeout, the source + /// is flagged as leaked: any further events the iterator yields are + /// silently dropped. Default 5 seconds. + /// + public TimeSpan CancellationTimeout { get; init; } = TimeSpan.FromSeconds(5); + + /// + /// Optional sink that observes exceptions thrown by the factory + /// invocation, the iterator MoveNextAsync call, or + /// . The registry already logs at + /// Error level using the manager's logger; this hook is for + /// callers that want to bubble the exception into their own + /// telemetry. Default null. + /// + public Action OnError { get; init; } + } +} diff --git a/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs b/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs new file mode 100644 index 0000000000..169cf7f9f2 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/EventSourceRegistry.cs @@ -0,0 +1,646 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Coordinates the lifecycle of Publish-registered event sources + /// for a single node manager. Activation is driven off + /// : each call to the manager's + /// SubscribeToEvents override triggers a reconcile pass that + /// starts iterators for newly-monitored sources and cancels iterators + /// for sources whose subscriber count dropped to zero. Disposing the + /// registry cancels every running iterator and waits for them to drain + /// (bounded by ). + /// + /// + /// + /// The registry runs a single background reconcile loop that consumes + /// signals via a . Signals coalesce — multiple + /// rapid sub/unsub calls produce at most one extra reconcile pass. + /// Each registered source runs in its own background + /// so backpressure on one source does not block another. + /// + /// + /// Threading: takes a short private lock to + /// mutate the source dictionary; everything else runs lock-free off + /// the reconcile worker. + /// + /// + internal sealed class EventSourceRegistry : IDisposable + { + public EventSourceRegistry( + FluentNodeManagerBase owner, + ILogger logger) + { + m_owner = owner ?? throw new ArgumentNullException(nameof(owner)); + m_logger = logger; + m_reconcileSignal = new SemaphoreSlim(0, 1); + m_managerCts = new CancellationTokenSource(); + m_reconcileTask = Task.Run(() => RunReconcileLoopAsync(m_managerCts.Token)); + } + + /// + /// Registers as the event source for + /// . Auto-promotes the notifier's + /// flag and, when + /// requested in , also registers the + /// notifier as a root notifier with the owning manager. Throws if a + /// source is already registered for the same notifier. + /// + /// + /// Designed to be called from the manager's Configure + /// delegate, which runs single-threaded before clients connect. + /// In that context the synchronous wait on + /// + /// (when + /// is set) cannot deadlock because no other thread is contending + /// on the manager's monitored-item semaphore. + /// + public void Register( + BaseObjectState notifier, + Func> factory, + EventPublishOptions options) + { + if (notifier == null) + { + throw new ArgumentNullException(nameof(notifier)); + } + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + options ??= new EventPublishOptions(); + + ValidateOptions(options); + + lock (m_sourcesLock) + { + ThrowIfDisposed(); + if (m_sources.ContainsKey(notifier.NodeId)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Node '{0}' (id '{1}') already has a Publish event source registered.", + notifier.BrowseName, + notifier.NodeId); + } + + // Auto-promote EventNotifier so clients can subscribe to events on + // the node — Boiler-style models do not set this flag by default. + if ((notifier.EventNotifier & EventNotifiers.SubscribeToEvents) == 0) + { + notifier.EventNotifier |= EventNotifiers.SubscribeToEvents; + m_logger?.LogDebug( + "Publish: promoted EventNotifier of '{Browse}' (id '{NodeId}') to include SubscribeToEvents.", + notifier.BrowseName, + notifier.NodeId); + } + + m_sources[notifier.NodeId] = new SourceEntry(notifier, factory, options); + } + + // Root-notifier registration runs eagerly OUTSIDE m_sourcesLock so + // existing Server-level event monitored items get attached + // immediately. Lazy activation gated on AreEventsMonitored cannot + // bootstrap a root notifier (the gate is on the wrong node). + if (options.RegisterAsRootNotifier) + { + try + { + m_owner.AddRootNotifierFromFluentAsync(notifier, CancellationToken.None) + .GetAwaiter() + .GetResult(); + m_logger?.LogDebug( + "Publish: registered '{Browse}' (id '{NodeId}') as a root notifier (RegisterAsRootNotifier=true).", + notifier.BrowseName, + notifier.NodeId); + } + catch (Exception ex) + { + lock (m_sourcesLock) + { + m_sources.Remove(notifier.NodeId); + } + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + ex, + "Publish: failed to add '{0}' (id '{1}') as a root notifier.", + notifier.BrowseName, + notifier.NodeId); + } + } + + SignalReconcile(); + } + + private static void ValidateOptions(EventPublishOptions options) + { + TimeSpan timeout = options.CancellationTimeout; + if (timeout != Timeout.InfiniteTimeSpan && timeout < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(options), + timeout, + "EventPublishOptions.CancellationTimeout must be non-negative or Timeout.InfiniteTimeSpan."); + } + } + + /// + /// Signals the registry to walk every source and reconcile its + /// activation state against . + /// Called by after the base + /// SubscribeToEvents implementation has updated the ref-count + /// recursively. + /// + public void SignalReconcile() + { + if (Volatile.Read(ref m_disposed) != 0) + { + return; + } + // Coalesce: only one reconcile pass is pending at a time. + try + { + m_reconcileSignal.Release(); + } + catch (SemaphoreFullException) + { + // Another signal is already pending — that's the whole point. + } + catch (ObjectDisposedException) + { + // Lost the race with Dispose — that's fine, the registry is + // shutting down and no further reconciliation is needed. + } + } + + /// + /// Cancels every running iterator, waits for them to drain (bounded + /// by each source's ), + /// and stops the reconcile loop. Idempotent. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref m_disposed, 1) != 0) + { + return; + } + + // Stop the reconcile loop first so it cannot race with us. + try + { + m_managerCts.Cancel(); + } + catch (ObjectDisposedException) + { + } + + // Reconcile loop awaits the signal — releasing here unblocks + // its WaitAsync so it can observe the cancellation token. + try + { + m_reconcileSignal.Release(); + } + catch (SemaphoreFullException) + { + } + catch (ObjectDisposedException) + { + } + + // Wait for the reconcile task to actually exit. We must wait long + // enough to cover the worst case where the loop is mid-pass when + // the cancel fires: the in-flight ReconcileAll may DeactivateSource + // each registered source, and each DeactivateSource blocks on its + // worker task for up to entry.Options.CancellationTimeout. Use the + // largest per-source timeout, plus a safety margin, instead of a + // hardcoded 5s that could be exceeded by a single slow source. + TimeSpan waitFor = ComputeReconcileWaitTimeout(); + try + { + m_reconcileTask.Wait(waitFor); + } + catch (AggregateException) + { + } + + // Deactivate every source so their iterators get cancelled. + List snapshot; + lock (m_sourcesLock) + { + snapshot = new List(m_sources.Values); + m_sources.Clear(); + } + + foreach (SourceEntry entry in snapshot) + { + DeactivateSource(entry, force: true); + } + + m_reconcileSignal.Dispose(); + m_managerCts.Dispose(); + } + + private TimeSpan ComputeReconcileWaitTimeout() + { + TimeSpan maxPerSource = TimeSpan.Zero; + lock (m_sourcesLock) + { + foreach (SourceEntry entry in m_sources.Values) + { + TimeSpan t = entry.Options.CancellationTimeout; + if (t == Timeout.InfiniteTimeSpan) + { + return Timeout.InfiniteTimeSpan; + } + if (t > maxPerSource) + { + maxPerSource = t; + } + } + } + // The reconcile loop only needs the larger of its in-flight pass and + // a small bookkeeping margin; per-source deactivation runs again on + // the disposer thread below. + return maxPerSource + TimeSpan.FromSeconds(5); + } + + private async Task RunReconcileLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await m_reconcileSignal.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + + if (ct.IsCancellationRequested) + { + return; + } + + ReconcileAll(); + } + } + + private void ReconcileAll() + { + List snapshot; + lock (m_sourcesLock) + { + snapshot = new List(m_sources.Values); + } + + foreach (SourceEntry entry in snapshot) + { + try + { + bool wantActive = entry.Options.AlwaysOn || entry.Notifier.AreEventsMonitored; + if (wantActive && entry.WorkerCts == null) + { + ActivateSource(entry); + } + else if (!wantActive && entry.WorkerCts != null) + { + DeactivateSource(entry, force: false); + } + } + catch (Exception ex) + { + m_logger?.LogError( + ex, + "Publish: reconcile pass failed for '{Browse}' (id '{NodeId}').", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + } + } + } + + private void ActivateSource(SourceEntry entry) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(m_managerCts.Token); + entry.WorkerCts = cts; + Volatile.Write(ref entry.LeakedFaulted, 0); + entry.WorkerTask = Task.Run(() => RunSourceAsync(entry, cts.Token)); + m_logger?.LogDebug( + "Publish: activated source for '{Browse}' (id '{NodeId}').", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + } + + private void DeactivateSource(SourceEntry entry, bool force) + { + CancellationTokenSource cts = entry.WorkerCts; + Task worker = entry.WorkerTask; + entry.WorkerCts = null; + entry.WorkerTask = null; + + if (cts == null) + { + return; + } + + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + + try + { + bool completed = worker?.Wait(entry.Options.CancellationTimeout) ?? true; + if (!completed) + { + Volatile.Write(ref entry.LeakedFaulted, 1); + m_logger?.LogWarning( + "Publish: source for '{Browse}' (id '{NodeId}') did not honor cancellation within {Timeout}; further yielded events will be discarded.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId, + entry.Options.CancellationTimeout); + } + } + catch (AggregateException ex) + { + ex.Handle(e => e is OperationCanceledException); + } + finally + { + cts.Dispose(); + } + + if (force) + { + m_logger?.LogDebug( + "Publish: tore down source for '{Browse}' (id '{NodeId}') on dispose.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + } + else + { + m_logger?.LogDebug( + "Publish: deactivated source for '{Browse}' (id '{NodeId}').", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + } + } + + private async Task RunSourceAsync(SourceEntry entry, CancellationToken ct) + { + ISystemContext systemContext = m_owner.SystemContext; + + System.Collections.Generic.IAsyncEnumerable stream; + try + { + stream = entry.Factory(entry.Notifier, systemContext, ct); + if (stream == null) + { + m_logger?.LogError( + "Publish: factory for '{Browse}' (id '{NodeId}') returned a null stream.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + return; + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + m_logger?.LogError( + ex, + "Publish: factory invocation for '{Browse}' (id '{NodeId}') threw.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + try + { + entry.Options.OnError?.Invoke(ex); + } + catch + { + // Swallow secondary errors from the user hook. + } + return; + } + + try + { + await foreach (BaseEventState e in stream.WithCancellation(ct).ConfigureAwait(false)) + { + if (ct.IsCancellationRequested || + Volatile.Read(ref entry.LeakedFaulted) != 0) + { + return; + } + DispatchEvent(entry, systemContext, e); + } + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + catch (Exception ex) + { + m_logger?.LogError( + ex, + "Publish: iterator for '{Browse}' (id '{NodeId}') threw — stopping that source only.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + try + { + entry.Options.OnError?.Invoke(ex); + } + catch + { + } + } + } + + private void DispatchEvent(SourceEntry entry, ISystemContext context, BaseEventState e) + { + if (e == null) + { + return; + } + + try + { + if (!entry.Options.SkipDefaultPopulation) + { + PopulateDefaults(entry.Notifier, context, e); + } + + entry.Notifier.ReportEvent(context, e); + } + catch (Exception ex) + { + m_logger?.LogError( + ex, + "Publish: ReportEvent for '{Browse}' (id '{NodeId}') threw — dropping this event and continuing iterator.", + entry.Notifier.BrowseName, + entry.Notifier.NodeId); + try + { + entry.Options.OnError?.Invoke(ex); + } + catch + { + } + } + } + + /// + /// Mirrors BaseEventState.Initialize for fields the user + /// did not populate themselves: EventId (random uuid), + /// EventType / TypeDefinitionId (default for the + /// CLR type), SourceNode, SourceName, Time, + /// ReceiveTime, Severity (Medium when 0), + /// Message. + /// + private static void PopulateDefaults(BaseObjectState notifier, ISystemContext context, BaseEventState e) + { + if (e.EventId == null || e.EventId.Value.IsNull) + { + e.EventId = PropertyState.With( + e, + Uuid.NewUuid().ToByteString()); + } + + if (e.EventType == null || e.EventType.Value.IsNull) + { + NodeId defaultType = e.GetDefaultTypeDefinitionId(context); + e.EventType = PropertyState.With(e, defaultType); + if (e.TypeDefinitionId.IsNull) + { + e.TypeDefinitionId = defaultType; + } + } + else if (e.TypeDefinitionId.IsNull) + { + e.TypeDefinitionId = e.EventType.Value; + } + + if ((e.SourceNode == null || e.SourceNode.Value.IsNull) && + !notifier.NodeId.IsNull) + { + e.SourceNode = PropertyState.With(e, notifier.NodeId); + } + + if ((e.SourceName == null || e.SourceName.Value == null) && + !notifier.BrowseName.IsNull) + { + e.SourceName = PropertyState.With( + e, + notifier.BrowseName.Name); + } + + if (e.Time == null || e.Time.Value.IsNull) + { + e.Time = PropertyState.With(e, DateTimeUtc.Now); + } + + if (e.ReceiveTime == null || e.ReceiveTime.Value.IsNull) + { + e.ReceiveTime = PropertyState.With( + e, + DateTimeUtc.Now); + } + + if (e.Severity == null || e.Severity.Value == 0) + { + e.Severity = PropertyState.With( + e, + (ushort)EventSeverity.Medium); + } + + if (e.Message == null) + { + e.Message = PropertyState.With( + e, + new LocalizedText(string.Empty)); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref m_disposed) != 0) + { + throw new ObjectDisposedException(nameof(EventSourceRegistry)); + } + } + + private sealed class SourceEntry + { + public SourceEntry( + BaseObjectState notifier, + Func> factory, + EventPublishOptions options) + { + Notifier = notifier; + Factory = factory; + Options = options; + } + + public BaseObjectState Notifier { get; } + public Func> Factory { get; } + public EventPublishOptions Options { get; } + public CancellationTokenSource WorkerCts; + public Task WorkerTask; + public int LeakedFaulted; + } + + private readonly FluentNodeManagerBase m_owner; + private readonly ILogger m_logger; + private readonly SemaphoreSlim m_reconcileSignal; + private readonly CancellationTokenSource m_managerCts; + private readonly Task m_reconcileTask; + private readonly object m_sourcesLock = new object(); + private readonly Dictionary m_sources = new Dictionary(); + private int m_disposed; + } +} diff --git a/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs b/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs new file mode 100644 index 0000000000..32a6af41d8 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs @@ -0,0 +1,221 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Opt-in base class for node managers that want to use the fluent + /// Publish surface (external event sources delivered through + /// ). The source-generator-emitted + /// NodeManagerBase derives from this class when any wrapper + /// in the design exposes a Publish binding; hand-written + /// managers can also derive directly. + /// + /// + /// + /// This class owns an whose + /// reconcile loop runs as long as the manager is alive. Override + /// hooks the registry so it + /// activates and deactivates sources in lock-step with + /// . Dispose(bool) + /// tears the registry down before the base implementation runs so + /// no iterator outlives the manager. + /// + /// + /// Subclasses should never call into + /// outside the fluent builder pipeline; the surface is exposed for + /// generated code only. + /// + /// + public abstract class FluentNodeManagerBase : AsyncCustomNodeManager + { + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + params string[] namespaceUris) + : base(server, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + ILogger logger, + params string[] namespaceUris) + : base(server, logger, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + ApplicationConfiguration configuration, + params string[] namespaceUris) + : base(server, configuration, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + ApplicationConfiguration configuration, + ILogger logger, + params string[] namespaceUris) + : base(server, configuration, logger, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + ApplicationConfiguration configuration, + bool useSamplingGroups, + params string[] namespaceUris) + : base(server, configuration, useSamplingGroups, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Initializes the node manager. + /// + protected FluentNodeManagerBase( + IServerInternal server, + ApplicationConfiguration configuration, + bool useSamplingGroups, + ILogger logger, + params string[] namespaceUris) + : base(server, configuration, useSamplingGroups, logger, namespaceUris) + { + m_eventSources = new EventSourceRegistry(this, m_logger); + } + + /// + /// Registry that the fluent Publish surface stores its + /// registered event sources in. Accessed by + /// during + /// Configure and by generated wrappers; not intended for + /// direct subclass use. + /// + internal EventSourceRegistry EventSources => m_eventSources; + + /// + /// Attaches this manager's event-source registry to the supplied + /// fluent builder so that Publish extension methods can + /// resolve it. The generator-emitted CreateAddressSpaceAsync + /// invokes this immediately after constructing the builder; hand- + /// written managers that build their own + /// should call this once before + /// passing the builder into Configure. + /// + /// + /// The fluent builder that the manager's Configure + /// partial(s) will receive. + /// + /// + /// Raised when is null. + /// + public void AttachToBuilder(NodeManagerBuilder builder) + { + if (builder == null) + { + throw new System.ArgumentNullException(nameof(builder)); + } + builder.AttachEventSources(m_eventSources); + } + + /// + /// Signals the registry whenever a notifier's monitored-events + /// ref-count flips so the reconcile loop can start or stop the + /// matching iterator. Subclasses that further override + /// + /// must call base before doing their own work. + /// + protected override ValueTask OnSubscribeToEventsAsync( + ServerSystemContext context, + MonitoredNode2 monitoredNode, + bool unsubscribe, + CancellationToken cancellationToken = default) + { + m_eventSources.SignalReconcile(); + return base.OnSubscribeToEventsAsync(context, monitoredNode, unsubscribe, cancellationToken); + } + + /// + /// Cancels every running iterator and waits (bounded by each + /// source's + /// ) before + /// invoking the base disposer. Subclasses that further override + /// Dispose must call base.Dispose(disposing). + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + m_eventSources.Dispose(); + } + base.Dispose(disposing); + } + + /// + /// Internal trampoline used by + /// to register a notifier as a root notifier from a background + /// worker thread. Wraps + /// + /// so the registry does not have to know its protected + /// signature. + /// + internal Task AddRootNotifierFromFluentAsync( + NodeState notifier, + CancellationToken cancellationToken) + { + return AddRootNotifierAsync(notifier, cancellationToken).AsTask(); + } + + private readonly EventSourceRegistry m_eventSources; + } +} diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs index a872e77f45..52fb2df029 100644 --- a/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/INodeBuilder.cs @@ -93,6 +93,31 @@ public interface INodeBuilder /// INodeBuilder OnWrite(NodeValueSimpleEventHandler handler); + /// + /// Wires . Use this + /// overload when the read source performs I/O — the handler runs + /// without holding lock(this), so the call can await + /// freely. + /// + INodeBuilder OnRead(NodeValueEventHandlerAsync handler); + + /// + /// Wires . + /// The framework still applies index-range / data-encoding / + /// copy-policy post-processing to the returned value. + /// + INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler); + + /// + /// Wires . + /// + INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler); + + /// + /// Wires . + /// + INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler); + /// /// Wires . /// @@ -146,6 +171,41 @@ public interface INodeBuilder /// hook to forward events to . /// INodeBuilder OnEvent(EventNotificationHandler handler); + + /// + /// Resolves a child of the current node by browse name. Used by + /// source-generated typed traversal wrappers to walk one segment + /// at a time without re-resolving from the root. + /// + /// Browse name of the immediate child. + /// + /// Thrown when the child cannot be found. + /// + INodeBuilder Child(QualifiedName browseName); + + /// + /// Strongly-typed sibling of . + /// + /// + /// CLR type the resolved child must be + /// assignable to. + /// + INodeBuilder Child(QualifiedName browseName) + where TState : NodeState; + + /// + /// Resolves a variable child of the current node and returns a + /// typed view. + /// + /// + /// CLR type carried by the child variable's Value attribute. + /// + /// Browse name of the immediate child. + /// + /// Thrown when the child cannot be found, or when it is not a + /// . + /// + IVariableBuilder Variable(QualifiedName browseName); } /// diff --git a/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs index 9b74ecd74b..2fa1afd835 100644 --- a/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs @@ -198,5 +198,43 @@ INodeBuilder NodeFromTypeId(NodeId typeDefinitionId) /// See . INodeBuilder NodeFromTypeId(NodeId typeDefinitionId, QualifiedName browseName) where TState : NodeState; + + /// + /// Resolves a variable node by browse path and returns a typed + /// view that exposes + /// simple Func / Action shaped + /// OnRead/OnWrite overloads. + /// + /// + /// CLR type carried by the variable's Value attribute. + /// + /// See . + /// + /// Thrown if the path does not resolve, or resolves to a node + /// that is not a . + /// + IVariableBuilder Variable(string browsePath); + + /// + /// Resolves a variable node by absolute + /// and returns a typed + /// view. + /// + IVariableBuilder Variable(NodeId nodeId); + + /// + /// Resolves the unique variable instance whose + /// TypeDefinitionId matches + /// and returns a typed + /// view. Same disambiguation semantics as + /// . + /// + IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId); + + /// + /// Like but + /// disambiguates among multiple instances by browse name. + /// + IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId, QualifiedName browseName); } } diff --git a/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs new file mode 100644 index 0000000000..0e44539e6a --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/IVariableBuilder.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Strongly-typed view of a variable node whose value attribute is + /// known at compile time to carry . + /// Surfaces convenience overloads of OnRead/OnWrite + /// that accept simple / + /// shaped lambdas — the framework marshals + /// to and from automatically. + /// + /// + /// + /// Obtained via + /// , + /// or by + /// calling + /// on an existing . + /// + /// + /// The simple convenience overloads do not surface index range, data + /// encoding, or per-call status code information; use the underlying + /// overloads (e.g. + /// ) when + /// that level of control is needed. + /// + /// + /// + /// CLR type that the variable's Value attribute is expected to + /// carry. Reads cast the underlying to + /// this type; writes wrap the supplied value in a new + /// . + /// + public interface IVariableBuilder : INodeBuilder + { + /// + /// Wires a synchronous typed getter. The framework invokes + /// on every read and applies index range + /// / data encoding / copy policy post-processing to the returned + /// value. + /// + IVariableBuilder OnRead(Func getter); + + /// + /// Wires a synchronous typed getter that receives the calling + /// . + /// + IVariableBuilder OnRead(Func getter); + + /// + /// Wires an asynchronous typed getter. The handler runs without + /// holding lock(this); the framework still applies index + /// range / data encoding / copy policy post-processing. + /// + IVariableBuilder OnRead( + Func> getter); + + /// + /// Wires an asynchronous typed getter that receives the calling + /// and a . + /// + IVariableBuilder OnRead( + Func> getter); + + /// + /// Wires a synchronous typed setter. Index-range writes are not + /// supported through this overload (use the + /// overload on + /// ). + /// + IVariableBuilder OnWrite(Action setter); + + /// + /// Wires a synchronous typed setter that receives the calling + /// . + /// + IVariableBuilder OnWrite(Action setter); + + /// + /// Wires an asynchronous typed setter. Runs without holding + /// lock(this). + /// + IVariableBuilder OnWrite( + Func setter); + + /// + /// Wires an asynchronous typed setter that receives the calling + /// . + /// + IVariableBuilder OnWrite( + Func setter); + } + + /// + /// Convenience extensions that bridge a non-generic + /// into the strongly-typed + /// view. + /// + public static class VariableBuilderExtensions + { + /// + /// Returns a view of the + /// resolved node. Throws if the node is not a + /// . + /// + public static IVariableBuilder AsVariable(this INodeBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (builder.Node is not BaseVariableState variable) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidArgument, + "Cannot get a typed variable view of node '{0}': not a BaseVariableState (actual: {1}).", + builder.Node.BrowseName, + builder.Node.GetType().Name); + } + + return new VariableBuilder(builder.Builder, variable); + } + } +} diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs index f9b1da725c..42eb652bba 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeBuilder.cs @@ -104,6 +104,42 @@ public INodeBuilder OnWrite(NodeValueSimpleEventHandler handler) return this; } + /// + public INodeBuilder OnRead(NodeValueEventHandlerAsync handler) + { + BaseVariableState v = RequireVariable("OnReadAsync"); + ThrowIfSlotOccupied(v.OnReadValueAsync, "OnReadAsync"); + v.OnReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + /// + public INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler) + { + BaseVariableState v = RequireVariable("OnSimpleReadAsync"); + ThrowIfSlotOccupied(v.OnSimpleReadValueAsync, "OnSimpleReadAsync"); + v.OnSimpleReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + /// + public INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler) + { + BaseVariableState v = RequireVariable("OnWriteAsync"); + ThrowIfSlotOccupied(v.OnWriteValueAsync, "OnWriteAsync"); + v.OnWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + /// + public INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler) + { + BaseVariableState v = RequireVariable("OnSimpleWriteAsync"); + ThrowIfSlotOccupied(v.OnSimpleWriteValueAsync, "OnSimpleWriteAsync"); + v.OnSimpleWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + /// public INodeBuilder OnCall(GenericMethodCalledEventHandler2 handler) { @@ -204,6 +240,57 @@ public INodeBuilder OnEvent(EventNotificationHandler handler) return this; } + /// + public INodeBuilder Child(QualifiedName browseName) + { + NodeState child = ResolveChild(browseName); + return new NodeBuilder(m_parent, child); + } + + /// + public INodeBuilder Child(QualifiedName browseName) + where TState : NodeState + { + NodeState child = ResolveChild(browseName); + if (child is not TState typed) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Child '{0}' under '{1}' is of type {2}, which is not assignable to {3}.", + browseName, + Node.BrowseName, + child.GetType().Name, + typeof(TState).Name); + } + return new NodeBuilder(m_parent, typed); + } + + /// + public IVariableBuilder Variable(QualifiedName browseName) + { + NodeState child = ResolveChild(browseName); + return m_parent.ToVariableBuilder( + child, + CoreUtils.Format("{0}/{1}", Node.BrowseName, browseName)); + } + + private BaseInstanceState ResolveChild(QualifiedName browseName) + { + if (browseName.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameInvalid, + "Browse name is null or empty."); + } + return Node.FindChild(m_parent.Context, browseName) ?? + throw ServiceResultException.Create( + StatusCodes.BadNodeIdUnknown, + "Child '{0}' not found under '{1}' (id '{2}').", + browseName, + Node.BrowseName, + Node.NodeId); + } + private BaseVariableState RequireVariable(string what) { if (Node is not BaseVariableState v) @@ -257,7 +344,7 @@ private void ThrowIfSlotOccupied(Delegate existing, string what) /// property. /// /// - internal sealed class NodeBuilder : NodeBuilder, INodeBuilder + internal class NodeBuilder : NodeBuilder, INodeBuilder where TState : NodeState { public NodeBuilder(NodeManagerBuilder parent, TState node) diff --git a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index caeca8e769..afc5ef6b85 100644 --- a/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/Libraries/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -237,6 +237,106 @@ public INodeBuilder NodeFromTypeId(NodeId typeDefinitionId, Qual return new NodeBuilder(this, typed); } + /// + public IVariableBuilder Variable(string browsePath) + { + ThrowIfSealed(); + NodeState node = BrowsePathResolver.Resolve( + Context, + browsePath, + m_defaultNamespaceIndex, + m_rootResolver); + return ToVariableBuilder(node, browsePath); + } + + /// + public IVariableBuilder Variable(NodeId nodeId) + { + ThrowIfSealed(); + NodeState node = ResolveNodeId(nodeId); + return ToVariableBuilder(node, FormatNodeId(nodeId)); + } + + /// + public IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId) + { + ThrowIfSealed(); + NodeState node = ResolveByTypeDefinition(typeDefinitionId, (QualifiedName)null); + return ToVariableBuilder(node, FormatNodeId(typeDefinitionId)); + } + + /// + public IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId, QualifiedName browseName) + { + ThrowIfSealed(); + NodeState node = ResolveByTypeDefinition(typeDefinitionId, browseName); + return ToVariableBuilder( + node, + CoreUtils.Format( + "{0} (browse name '{1}')", + FormatNodeId(typeDefinitionId), + browseName)); + } + + internal VariableBuilder ToVariableBuilder(NodeState node, string lookupHint) + { + if (node is not BaseVariableState variable) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Lookup '{0}' resolved to {1}, which is not a BaseVariableState.", + lookupHint, + node.GetType().Name); + } + return new VariableBuilder(this, variable); + } + + /// + /// Event-source registry owned by the + /// ; populated via + /// immediately after the + /// builder is constructed and before Configure runs. + /// + /// + /// Hand-written managers that derive from + /// rather than + /// leave this property + /// null; the Publish extensions surface a + /// targeted error in that case. + /// + internal EventSourceRegistry EventSources { get; private set; } + + /// + /// Wires the supplied registry into this builder so the + /// Publish extensions can route source registrations to + /// the owning manager. Called once by + /// ; subsequent calls throw. + /// + internal void AttachEventSources(EventSourceRegistry registry) + { + if (registry == null) + { + throw new System.ArgumentNullException(nameof(registry)); + } + + if (EventSources != null) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "An EventSourceRegistry is already attached to this builder."); + } + + EventSources = registry; + } + + private static string FormatNodeId(NodeId nodeId) + { + // NodeId is a readonly struct so the caller may pass `default`; + // .IsNull guards both the default-struct case and a constructed + // NodeId with no identifier. + return nodeId.IsNull ? "(null)" : nodeId.ToString(); + } + /// public bool TryHandleHistoryRead( ISystemContext context, diff --git a/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs b/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs new file mode 100644 index 0000000000..64daf1b8b7 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Fluent/VariableBuilder.cs @@ -0,0 +1,214 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.Fluent +{ + /// + /// Default implementation. + /// Wraps each typed convenience overload as the appropriate slot + /// delegate on the underlying and + /// performs marshalling on every read / write. + /// + /// + internal sealed class VariableBuilder : + NodeBuilder, + IVariableBuilder + { + public VariableBuilder(INodeManagerBuilder parent, BaseVariableState variable) + : base((NodeManagerBuilder)parent, variable) + { + } + + /// + public IVariableBuilder OnRead(Func getter) + { + if (getter == null) + { + throw new ArgumentNullException(nameof(getter)); + } + OnRead((ISystemContext _) => getter()); + return this; + } + + /// + public IVariableBuilder OnRead(Func getter) + { + if (getter == null) + { + throw new ArgumentNullException(nameof(getter)); + } + ((INodeBuilder)this).OnRead( + (ISystemContext context, NodeState _, ref Variant value) => + { + value = ToVariant(getter(context)); + return ServiceResult.Good; + }); + return this; + } + + /// + public IVariableBuilder OnRead( + Func> getter) + { + if (getter == null) + { + throw new ArgumentNullException(nameof(getter)); + } + return OnRead( + (ISystemContext _, CancellationToken ct) => getter(ct)); + } + + /// + public IVariableBuilder OnRead( + Func> getter) + { + if (getter == null) + { + throw new ArgumentNullException(nameof(getter)); + } + ((INodeBuilder)this).OnRead( + async (ISystemContext context, NodeState _, CancellationToken ct) => + { + TValue typed = await getter(context, ct).ConfigureAwait(false); + return new AttributeSimpleReadResult( + ServiceResult.Good, + ToVariant(typed)); + }); + return this; + } + + /// + public IVariableBuilder OnWrite(Action setter) + { + if (setter == null) + { + throw new ArgumentNullException(nameof(setter)); + } + OnWrite((ISystemContext _, TValue v) => setter(v)); + return this; + } + + /// + public IVariableBuilder OnWrite(Action setter) + { + if (setter == null) + { + throw new ArgumentNullException(nameof(setter)); + } + ((INodeBuilder)this).OnWrite( + (ISystemContext context, NodeState _, ref Variant value) => + { + setter(context, FromVariant(value)); + return ServiceResult.Good; + }); + return this; + } + + /// + public IVariableBuilder OnWrite( + Func setter) + { + if (setter == null) + { + throw new ArgumentNullException(nameof(setter)); + } + return OnWrite((ISystemContext _, TValue v, CancellationToken ct) => setter(v, ct)); + } + + /// + public IVariableBuilder OnWrite( + Func setter) + { + if (setter == null) + { + throw new ArgumentNullException(nameof(setter)); + } + ((INodeBuilder)this).OnWrite( + async (ISystemContext context, NodeState _, Variant value, CancellationToken ct) => + { + TValue typed = FromVariant(value); + await setter(context, typed, ct).ConfigureAwait(false); + return new AttributeWriteResult(ServiceResult.Good); + }); + return this; + } + + /// + /// Casts the boxed to + /// . Returns the default value when + /// the variant is null so a typed lambda never has to defend + /// against an empty variable. + /// + private static TValue FromVariant(Variant value) + { + object boxed = value.AsBoxedObject(Variant.BoxingBehavior.Legacy); + if (boxed is null) + { + return default; + } + if (boxed is TValue typed) + { + return typed; + } + // Fall through to checked cast — gives a clear InvalidCastException + // (vs. a confusing pattern-match miss) when the model and the user + // disagree about the variable type. + return (TValue)boxed; + } + + // The reflection-based Variant(object) constructor is the only + // generic entry point for an open-ended TValue. AOT users should + // prefer the per-type generated walker (FluentBuilderGenerator) which + // routes through the typed Variant.From overloads instead. The + // suppression is scoped to this single call site so trim/AOT + // analysis still tracks every other path through this assembly. + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")] + private static Variant ToVariant(TValue value) + { + if (value is null) + { + return Variant.Null; + } +#pragma warning disable CS0618 // Variant(object) is obsolete on net472 + return new Variant(value); +#pragma warning restore CS0618 + } + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs index 61ff9aafaa..95a4129465 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs @@ -1962,15 +1962,18 @@ protected virtual async ValueTask ReadAsync( DataValue value = values[handle.Index]; // update the attribute value. - lock (source) - { - errors[handle.Index] = source.ReadAttribute( - context, - nodeToRead.AttributeId, - nodeToRead.ParsedIndexRange, - nodeToRead.DataEncoding, - value); - } + // Async path — the default NodeState.ReadAttributeAsync re-takes + // `lock(this)` around the synchronous fallback when no async + // hook is set, so behaviour is preserved bit-for-bit. When a + // BaseVariableState async hook is set the lock is dropped on + // the await, allowing true async I/O. + errors[handle.Index] = await source.ReadAttributeAsync( + context, + nodeToRead.AttributeId, + nodeToRead.ParsedIndexRange, + nodeToRead.DataEncoding, + value, + cancellationToken).ConfigureAwait(false); } } @@ -2372,15 +2375,13 @@ protected virtual async ValueTask WriteAsync( WriteValue nodeToWrite = nodesToWrite[handle.Index]; - lock (source) - { - // write the attribute value. - errors[handle.Index] = source.WriteAttribute( - context, - nodeToWrite.AttributeId, - nodeToWrite.ParsedIndexRange, - nodeToWrite.Value); - } + // Async path — see ReadAsync for the locking rationale. + errors[handle.Index] = await source.WriteAttributeAsync( + context, + nodeToWrite.AttributeId, + nodeToWrite.ParsedIndexRange, + nodeToWrite.Value, + cancellationToken).ConfigureAwait(false); // updates to source finished - report changes to monitored items. source.ClearChangeMasks(context, false); diff --git a/Stack/Opc.Ua.Types/State/BaseVariableState.cs b/Stack/Opc.Ua.Types/State/BaseVariableState.cs index 6645da2887..5cb43813fa 100644 --- a/Stack/Opc.Ua.Types/State/BaseVariableState.cs +++ b/Stack/Opc.Ua.Types/State/BaseVariableState.cs @@ -33,6 +33,8 @@ using System.Globalization; using System.Runtime.Serialization; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.Types; @@ -561,6 +563,45 @@ public uint AccessLevelEx /// public NodeValueEventHandler OnWriteValue; + /// + /// Asynchronous sibling of . When set, the + /// async path through + /// + /// invokes this delegate without holding the lock(this) + /// taken by the synchronous fallback — the handler owns its own + /// thread-safety. Has the same "I am the read source" + /// semantics as : the framework does + /// not apply post-processing such as + /// on the returned + /// value. + /// + public NodeValueEventHandlerAsync OnReadValueAsync; + + /// + /// Asynchronous sibling of . The + /// returned value is post-processed by the framework + /// ( and copy policy) + /// just like the synchronous path. + /// + public NodeValueSimpleEventHandlerAsync OnSimpleReadValueAsync; + + /// + /// Asynchronous sibling of . When set, + /// the async path through + /// + /// invokes this delegate without holding lock(this); on + /// success the framework updates the cached value, status code + /// and timestamp. + /// + public NodeValueWriteEventHandlerAsync OnWriteValueAsync; + + /// + /// Asynchronous sibling of . Index + /// range writes are not supported through this hook (just like + /// the synchronous one). + /// + public NodeValueSimpleWriteEventHandlerAsync OnSimpleWriteValueAsync; + /// /// Raised when the DataType attribute is read. /// @@ -1737,6 +1778,315 @@ protected override ServiceResult WriteValueAttribute( return ServiceResult.Good; } + /// + /// Asynchronous override of + /// . + /// Dispatches to or + /// when set; otherwise falls + /// through to the synchronous read flow under lock(this) + /// (preserving today's locking semantics for code that has not + /// opted into async hooks). + /// + public override async ValueTask ReadAttributeAsync( + ISystemContext context, + uint attributeId, + NumericRange indexRange, + QualifiedName dataEncoding, + DataValue value, + CancellationToken cancellationToken = default) + { + NodeValueEventHandlerAsync onReadValueAsync = OnReadValueAsync; + NodeValueSimpleEventHandlerAsync onSimpleReadValueAsync = OnSimpleReadValueAsync; + + if (attributeId != Attributes.Value || + (onReadValueAsync == null && onSimpleReadValueAsync == null)) + { + return await base.ReadAttributeAsync( + context, attributeId, indexRange, dataEncoding, value, cancellationToken) + .ConfigureAwait(false); + } + + if (value == null) + { + return ServiceResult.Create( + StatusCodes.BadStructureMissing, + "DataValue missing"); + } + + ServiceResult result; + Variant valueToRead; + DateTimeUtc sourceTimestamp; + + try + { + // snapshot access levels / timestamp / status code under the lock. + uint accessLevel; + byte userAccessLevel; + StatusCode cachedStatusCode; + DateTimeUtc cachedTimestamp; + // TODO: introduce a dedicated private lock object on NodeState + // — today's sync flow synchronises through `lock(source)` taken + // by external callers (e.g. CustomNodeManager2.Read), so the + // async path must lock on the same instance to preserve + // mutual exclusion. Switching to a private lock object + // requires updating every external `lock(source)` site. +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + accessLevel = m_accessLevel; + userAccessLevel = m_userAccessLevel; + cachedStatusCode = m_statusCode; + cachedTimestamp = m_timestamp; + } + + if ((accessLevel & AccessLevels.CurrentRead) == 0) + { + result = StatusCodes.BadNotReadable; + valueToRead = Variant.Null; + sourceTimestamp = DateTimeUtc.MinValue; + } + else + { + OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel); + + if ((userAccessLevel & AccessLevels.CurrentRead) == 0) + { + result = StatusCodes.BadUserAccessDenied; + valueToRead = Variant.Null; + sourceTimestamp = DateTimeUtc.MinValue; + } + else if (onReadValueAsync != null) + { + // full async read — user owns timestamp / status code. + AttributeReadResult readResult = await onReadValueAsync( + context, this, indexRange, dataEncoding, cancellationToken) + .ConfigureAwait(false); + + result = readResult.Result; + valueToRead = readResult.Value; + sourceTimestamp = readResult.SourceTimestamp == DateTimeUtc.MinValue + ? DateTimeUtc.Now + : readResult.SourceTimestamp; + + // mirror sync OnReadValue status-code fixup. + if (ServiceResult.IsGood(result) && + readResult.StatusCode != StatusCodes.Good) + { + result = readResult.StatusCode; + } + } + else + { + // simple async read — framework owns post-processing. + AttributeSimpleReadResult simpleResult = await onSimpleReadValueAsync( + context, this, cancellationToken) + .ConfigureAwait(false); + + result = simpleResult.Result; + valueToRead = simpleResult.Value; + sourceTimestamp = cachedTimestamp == DateTimeUtc.MinValue + ? DateTimeUtc.Now + : cachedTimestamp; + + if (ServiceResult.IsGood(result)) + { + ServiceResult rangeResult = ApplyIndexRangeAndDataEncoding( + context, indexRange, dataEncoding, ref valueToRead); + + if (ServiceResult.IsBad(rangeResult)) + { + result = rangeResult; + } + else + { + if (CopyPolicy is VariableCopyPolicy.CopyOnRead or VariableCopyPolicy.Always) + { + valueToRead = CoreUtils.Clone(valueToRead); + } + + if (cachedStatusCode != StatusCodes.Good) + { + result = cachedStatusCode; + } + } + } + } + } + } + catch (Exception e) + { + result = ServiceResult.Create( + e, + StatusCodes.BadUnexpectedError, + "Failed to read value attribute from node."); + valueToRead = Variant.Null; + sourceTimestamp = DateTimeUtc.MinValue; + } + + // commit to the supplied DataValue, mirroring NodeState.ReadAttribute(DataValue). + value.SourceTimestamp = sourceTimestamp; + value.SourcePicoseconds = 0; + + if (result != null && result != ServiceResult.Good) + { + value.StatusCode = result.StatusCode; + } + else + { + value.StatusCode = StatusCodes.Good; + } + + value.WrappedValue = StatusCode.IsBad(value.StatusCode) ? Variant.Null : valueToRead; + + return result; + } + + /// + /// Asynchronous override of + /// . + /// Dispatches to or + /// when set; otherwise falls + /// through to the synchronous write flow under lock(this). + /// + public override async ValueTask WriteAttributeAsync( + ISystemContext context, + uint attributeId, + NumericRange indexRange, + DataValue value, + CancellationToken cancellationToken = default) + { + NodeValueWriteEventHandlerAsync onWriteValueAsync = OnWriteValueAsync; + NodeValueSimpleWriteEventHandlerAsync onSimpleWriteValueAsync = OnSimpleWriteValueAsync; + + if (attributeId != Attributes.Value || + (onWriteValueAsync == null && onSimpleWriteValueAsync == null)) + { + return await base.WriteAttributeAsync( + context, attributeId, indexRange, value, cancellationToken) + .ConfigureAwait(false); + } + + if (value == null) + { + return ServiceResult.Create( + StatusCodes.BadStructureMissing, + "DataValue missing"); + } + + if (value.ServerTimestamp != DateTimeUtc.MinValue) + { + return ServiceResult.Create( + StatusCodes.BadWriteNotSupported, + "Cannot write to server timestamp"); + } + + try + { + // snapshot access levels under the lock. + uint accessLevel; + byte userAccessLevel; + // TODO: introduce a dedicated private lock object on NodeState + // — see the sibling note in ReadAttributeAsync for the rationale. +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + accessLevel = m_accessLevel; + userAccessLevel = m_userAccessLevel; + } + + if ((accessLevel & AccessLevels.CurrentWrite) == 0) + { + return StatusCodes.BadNotWritable; + } + + OnReadUserAccessLevel?.Invoke(context, this, ref userAccessLevel); + + if ((userAccessLevel & AccessLevels.CurrentWrite) == 0) + { + return StatusCodes.BadUserAccessDenied; + } + + Variant valueToWrite = value.WrappedValue; + StatusCode statusCode = value.StatusCode; + DateTimeUtc sourceTimestamp = value.SourceTimestamp; + + if (onWriteValueAsync != null) + { + AttributeWriteResult writeResult = await onWriteValueAsync( + context, this, indexRange, valueToWrite, cancellationToken) + .ConfigureAwait(false); + + if (ServiceResult.IsBad(writeResult.Result)) + { + return writeResult.Result; + } + + DateTimeUtc effectiveTimestamp = sourceTimestamp == DateTimeUtc.MinValue + ? DateTimeUtc.Now + : sourceTimestamp; + +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + m_value = valueToWrite; + m_statusCode = statusCode; + m_timestamp = effectiveTimestamp; + ChangeMasks |= NodeStateChangeMasks.Value; + } + + return writeResult.Result; + } + + // simple async write path mirrors OnSimpleWriteValue: + // index-range writes are not supported through this hook. + if (!indexRange.IsNull) + { + return StatusCodes.BadIndexRangeInvalid; + } + + if (sourceTimestamp == DateTimeUtc.MinValue) + { + sourceTimestamp = DateTimeUtc.Now; + } + + if (CopyPolicy is VariableCopyPolicy.CopyOnWrite or VariableCopyPolicy.Always) + { + valueToWrite = CoreUtils.Clone(valueToWrite); + } + + AttributeWriteResult simpleResult = await onSimpleWriteValueAsync( + context, this, valueToWrite, cancellationToken) + .ConfigureAwait(false); + + if (ServiceResult.IsBad(simpleResult.Result)) + { + return simpleResult.Result; + } + +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + m_value = valueToWrite; + m_statusCode = statusCode; + m_timestamp = sourceTimestamp; + ChangeMasks |= NodeStateChangeMasks.Value; + } + + return simpleResult.Result; + } + catch (Exception e) + { + return ServiceResult.Create( + e, + StatusCodes.BadUnexpectedError, + "Failed to write value attribute."); + } + } + private Variant m_value; private DateTimeUtc m_timestamp; private bool m_valueTouched; diff --git a/Stack/Opc.Ua.Types/State/NodeState.cs b/Stack/Opc.Ua.Types/State/NodeState.cs index 5405662708..0876dc20c4 100644 --- a/Stack/Opc.Ua.Types/State/NodeState.cs +++ b/Stack/Opc.Ua.Types/State/NodeState.cs @@ -32,6 +32,7 @@ using System.IO; using System.Linq; using System.Threading; +using System.Threading.Tasks; using System.Xml; using Opc.Ua.Types; @@ -3696,6 +3697,50 @@ public virtual ServiceResult ReadAttribute( return result; } + /// + /// Asynchronous sibling of + /// . + /// The default implementation simply wraps the synchronous call + /// inside a lock(this) so behaviour is bit-identical for + /// every that does not override it. Derived + /// types (notably ) can override + /// this method to dispatch to true asynchronous read hooks without + /// blocking a thread. + /// + /// The context for the current operation. + /// The attribute id. + /// The index range. + /// The data encoding. + /// The value to populate. + /// Cancellation token. + /// + /// An instance of the containing the + /// status code and diagnostic info for the operation. + /// + public virtual ValueTask ReadAttributeAsync( + ISystemContext context, + uint attributeId, + NumericRange indexRange, + QualifiedName dataEncoding, + DataValue value, + CancellationToken cancellationToken = default) + { + ServiceResult result; + // TODO: introduce a dedicated private lock object on NodeState — + // today's sync flow synchronises through `lock(source)` taken by + // external callers (e.g. CustomNodeManager2.Read), so the async + // path must lock on the same instance to preserve mutual + // exclusion. Switching to a private lock object requires + // updating every external `lock(source)` site. +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + result = ReadAttribute(context, attributeId, indexRange, dataEncoding, value); + } + return new ValueTask(result); + } + /// /// Reads the value for any non-value attribute. /// @@ -3999,6 +4044,40 @@ public ServiceResult WriteAttribute( } } + /// + /// Asynchronous sibling of + /// . + /// The default implementation wraps the synchronous call inside a + /// lock(this) so behaviour is bit-identical for every + /// that does not override it. Derived + /// types (notably ) can override + /// this method to dispatch to true asynchronous write hooks + /// without blocking a thread. + /// + /// The context for the current operation. + /// The attribute id. + /// The index range. + /// The value to write. + /// Cancellation token. + public virtual ValueTask WriteAttributeAsync( + ISystemContext context, + uint attributeId, + NumericRange indexRange, + DataValue value, + CancellationToken cancellationToken = default) + { + ServiceResult result; + // TODO: introduce a dedicated private lock object on NodeState — + // see the sibling note in ReadAttributeAsync for the rationale. +#pragma warning disable CA2002 // Do not lock on objects with weak identity + lock (this) +#pragma warning restore CA2002 + { + result = WriteAttribute(context, attributeId, indexRange, value); + } + return new ValueTask(result); + } + /// /// Write the value for any non-value attribute. /// @@ -5340,6 +5419,99 @@ public delegate ServiceResult NodeValueEventHandler( ref StatusCode statusCode, ref DateTimeUtc timestamp); + /// + /// Result returned by an asynchronous full Value-attribute read hook + /// (). Carries the value plus + /// its status code and source timestamp; the framework writes all three + /// onto the supplied when the operation + /// succeeds. Allocated on the stack — no per-call allocation. + /// + /// + /// Operation result. + /// short-circuits the rest of the read pipeline. + /// + /// The value to return to the caller. + /// Status code to attach to the value. + /// + /// Source timestamp to attach. + /// causes the framework to substitute the current UTC time, mirroring + /// the synchronous behavior. + /// + public readonly record struct AttributeReadResult( + ServiceResult Result, + Variant Value, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp); + + /// + /// Result returned by an asynchronous simple Value-attribute read hook + /// (). Carries only the + /// value; the framework reuses the variable's cached status code and + /// timestamp and runs the standard index-range / data-encoding + /// post-processing — matching the synchronous + /// pipeline. + /// + /// Operation result. + /// The value to return to the caller. + public readonly record struct AttributeSimpleReadResult( + ServiceResult Result, + Variant Value); + + /// + /// Result returned by an asynchronous Value-attribute write hook + /// ( / + /// ). + /// + /// Operation result. + public readonly record struct AttributeWriteResult( + ServiceResult Result); + + /// + /// Asynchronous sibling of for the + /// read direction. Returns a instead + /// of writing to ref parameters because ref cannot cross + /// an await. + /// + public delegate ValueTask NodeValueEventHandlerAsync( + ISystemContext context, + NodeState node, + NumericRange indexRange, + QualifiedName dataEncoding, + CancellationToken cancellationToken); + + /// + /// Asynchronous sibling of + /// for the read direction. + /// + public delegate ValueTask NodeValueSimpleEventHandlerAsync( + ISystemContext context, + NodeState node, + CancellationToken cancellationToken); + + /// + /// Asynchronous sibling of for the + /// write direction. The hook receives the value being written and + /// returns only a status; on success the framework updates the + /// variable's cached value and timestamp to mirror the synchronous + /// path. + /// + public delegate ValueTask NodeValueWriteEventHandlerAsync( + ISystemContext context, + NodeState node, + NumericRange indexRange, + Variant value, + CancellationToken cancellationToken); + + /// + /// Asynchronous sibling of + /// for the write direction. + /// + public delegate ValueTask NodeValueSimpleWriteEventHandlerAsync( + ISystemContext context, + NodeState node, + Variant value, + CancellationToken cancellationToken); + /// /// Stores a reference from a node in the instance hierarchy. /// diff --git a/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs b/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs index ab3479387f..a4bf625f7c 100644 --- a/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs +++ b/Tests/Opc.Ua.Aot.Tests/BoilerNodeManagerAotTests.cs @@ -96,6 +96,57 @@ public async Task PipeFlowOnReadCallbackProducesValueInRange() await Assert.That(value).IsBetween(75.0 - 1e-9, 125.0 + 1e-9); } + [Test] + public async Task TypedBuilderWiredLevelMeasurementProducesValueInRange() + { + // Wired via Configure(IBoilerNodeManagerBuilder) using + // builder.Boilers.Boiler__1.LCX001.Measurement.OnRead(...) — + // exercises the source-generated typed traversal end-to-end + // through the AOT-compiled binary. + NodeId measurement = await ResolveBoilerVariableAsync( + "LCX001", "Measurement").ConfigureAwait(false); + + DataValue dv = await fixture.Session.ReadValueAsync( + measurement, CancellationToken.None).ConfigureAwait(false); + + await Assert.That(StatusCode.IsGood(dv.StatusCode)).IsTrue(); + double value = dv.GetValue(double.NaN); + // Configure-wired typed OnRead returns 50 + 10*cos(t*0.05). + await Assert.That(value).IsBetween(40.0 - 1e-9, 60.0 + 1e-9); + } + + [Test] + public async Task TypedBuilderWiredHaltMethodCanBeCalled() + { + // Wired via Configure(IBoilerNodeManagerBuilder) using + // builder.Boilers.Boiler__1.Simulation.Halt.OnCall(async ct => + // ...) — exercises the typed-traversal async OnCall overload + // end-to-end through AsyncCustomNodeManager.CallAsync. + NodeId simulationObject = await ResolveBoilerObjectAsync( + "Simulation").ConfigureAwait(false); + NodeId haltMethod = await ResolveBoilerObjectAsync( + "Simulation", "Halt").ConfigureAwait(false); + + // CallAsync (extension on ISessionClient) throws on a bad + // status, so a successful return is itself the assertion that + // the typed async OnCall thunk ran. The Halt method declares + // no input or output arguments. + ArrayOf outputs = await fixture.Session.CallAsync( + simulationObject, + haltMethod, + CancellationToken.None).ConfigureAwait(false); + + await Assert.That(outputs.Count).IsEqualTo(0); + } + + /// + /// Resolves an arbitrary node (object or method) under + /// Boilers/Boiler #1 using the same browse-path technique as + /// . + /// + private Task ResolveBoilerObjectAsync(params string[] tail) + => ResolveBoilerVariableAsync(tail); + /// /// Walks the boiler instance tree starting from the well-known /// Boilers/Boiler #1 root (in the boiler namespace) using diff --git a/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs b/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs new file mode 100644 index 0000000000..5d73067147 --- /dev/null +++ b/Tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs @@ -0,0 +1,361 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +extern alias calcsample; + +using Microsoft.Extensions.Logging; +using Opc.Ua.Client; +using Opc.Ua.Server; +using TUnit.Core.Interfaces; + +namespace Opc.Ua.Aot.Tests +{ + /// + /// AOT smoke tests that verify the source-generated + /// Calc.CalcNodeManagerFactory emitted by the + /// [NodeManager] attribute on + /// (in the MinimalCalcServer + /// sample) loads the calculator address space, registers its + /// namespace, and dispatches each of the three typed fluent + /// OnCall(...) overloads — sync int+int→int, async + /// double+double→double, and sync string+string→string — wired in + /// CalcNodeManager.Configure.cs. Together these tests cover + /// the generator's typed input-unpack (Variant.TryGetValue<T>), + /// output-box (Variant.From<T>), and async dispatch paths + /// against the AOT-compiled binary. + /// + [ClassDataSource(Shared = SharedType.PerTestSession)] + public class CalculatorNodeManagerAotTests(CalculatorAotFixture fixture) + { + private const string kCalcNamespaceUri = + "http://opcfoundation.org/UA/Calc/"; + + [Test] + public async Task CalcNamespaceIsRegistered() + { + DataValue nsArray = await fixture.Session.ReadValueAsync( + VariableIds.Server_NamespaceArray, + CancellationToken.None).ConfigureAwait(false); + + await Assert.That(StatusCode.IsGood(nsArray.StatusCode)).IsTrue(); + string[] uris = nsArray.GetValue(null); + await Assert.That(uris).IsNotNull(); + await Assert.That(uris).Contains(kCalcNamespaceUri); + } + + [Test] + public async Task AddMethodReturnsSum() + { + // Wired via Configure(ICalcNodeManagerBuilder) using + // builder.Calculator.Add.OnCall((int a, int b) => a + b) — + // exercises the typed sync OnCall overload end-to-end with + // primitive value-type inputs and output. + NodeId calculator = await ResolveCalculatorNodeAsync() + .ConfigureAwait(false); + NodeId addMethod = await ResolveCalculatorNodeAsync("Add") + .ConfigureAwait(false); + + ArrayOf outputs = await fixture.Session.CallAsync( + calculator, + addMethod, + CancellationToken.None, + new Variant(2), + new Variant(3)).ConfigureAwait(false); + + await Assert.That(outputs.Count).IsEqualTo(1); + Variant single = outputs.ToList()[0]; + await Assert.That(single.TypeInfo.BuiltInType) + .IsEqualTo(BuiltInType.Int32); + await Assert.That(single.TryGetValue(out int sum)).IsTrue(); + await Assert.That(sum).IsEqualTo(5); + } + + [Test] + public async Task MultiplyMethodReturnsProductAsync() + { + // Wired via Configure(ICalcNodeManagerBuilder) using + // builder.Calculator.Multiply.OnCall(async (double, double, + // CancellationToken) => ...) — exercises the typed async + // OnCall overload end-to-end through + // AsyncCustomNodeManager.CallAsync. + NodeId calculator = await ResolveCalculatorNodeAsync() + .ConfigureAwait(false); + NodeId multiplyMethod = await ResolveCalculatorNodeAsync("Multiply") + .ConfigureAwait(false); + + ArrayOf outputs = await fixture.Session.CallAsync( + calculator, + multiplyMethod, + CancellationToken.None, + new Variant(0.5), + new Variant(4.0)).ConfigureAwait(false); + + await Assert.That(outputs.Count).IsEqualTo(1); + Variant single = outputs.ToList()[0]; + await Assert.That(single.TypeInfo.BuiltInType) + .IsEqualTo(BuiltInType.Double); + await Assert.That(single.TryGetValue(out double product)).IsTrue(); + await Assert.That(product).IsEqualTo(2.0); + } + + [Test] + public async Task ConcatMethodReturnsConcatenation() + { + // Wired via Configure(ICalcNodeManagerBuilder) using + // builder.Calculator.Concat.OnCall((string l, string r) => + // ...) — exercises typed reference-type marshalling on both + // input arguments and the return value. + NodeId calculator = await ResolveCalculatorNodeAsync() + .ConfigureAwait(false); + NodeId concatMethod = await ResolveCalculatorNodeAsync("Concat") + .ConfigureAwait(false); + + ArrayOf outputs = await fixture.Session.CallAsync( + calculator, + concatMethod, + CancellationToken.None, + new Variant("foo"), + new Variant("bar")).ConfigureAwait(false); + + await Assert.That(outputs.Count).IsEqualTo(1); + Variant single = outputs.ToList()[0]; + await Assert.That(single.TypeInfo.BuiltInType) + .IsEqualTo(BuiltInType.String); + await Assert.That(single.TryGetValue(out string concatenated)).IsTrue(); + await Assert.That(concatenated).IsEqualTo("foobar"); + } + + /// + /// Walks the calculator instance tree starting from the + /// well-known Calculator root (in the calc namespace) + /// using TranslateBrowsePathsToNodeIds so the tests do not + /// hard-code any generated NodeId. + /// + private async Task ResolveCalculatorNodeAsync( + params string[] tail) + { + ushort nsIndex = (ushort)fixture.Session.NamespaceUris + .GetIndex(kCalcNamespaceUri); + await Assert.That(nsIndex).IsGreaterThan((ushort)0); + + var elements = new List + { + new() + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName("Calculator", nsIndex) + } + }; + foreach (string segment in tail) + { + elements.Add(new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(segment, nsIndex) + }); + } + + var browsePaths = new List + { + new() + { + StartingNode = ObjectIds.ObjectsFolder, + RelativePath = new RelativePath + { + Elements = elements.ToArrayOf() + } + } + }; + + TranslateBrowsePathsToNodeIdsResponse response = + await fixture.Session.TranslateBrowsePathsToNodeIdsAsync( + null, browsePaths.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + await Assert.That(response.Results.Count).IsEqualTo(1); + BrowsePathResult result = response.Results.ToList()[0]; + await Assert.That(StatusCode.IsGood(result.StatusCode)).IsTrue(); + await Assert.That(result.Targets.Count).IsGreaterThan(0); + + return ExpandedNodeId.ToNodeId( + result.Targets.ToList()[0].TargetId, + fixture.Session.NamespaceUris); + } + } + + /// + /// Per-test-session fixture that boots a NativeAOT-friendly server + /// hosting the source-generated CalcNodeManagerFactory and + /// connects an anonymous client session to it. + /// + public sealed class CalculatorAotFixture : IAsyncInitializer, IAsyncDisposable + { + public AotServerFixture ServerFixture { get; private set; } + public Client.ISession Session { get; private set; } + public string ServerUrl { get; private set; } + public ITelemetryContext Telemetry { get; private set; } + + public async Task InitializeAsync() + { + Telemetry = DefaultTelemetry.Create(builder => + builder.SetMinimumLevel(LogLevel.Warning)); + + ServerFixture = new AotServerFixture( + t => new CalculatorTestServer(t), Telemetry) + { + AutoAccept = true, + SecurityNone = true + }; + await ServerFixture.LoadConfigurationAsync( + Path.Combine(Directory.GetCurrentDirectory(), "calc-pki")) + .ConfigureAwait(false); + await ServerFixture.StartAsync().ConfigureAwait(false); + + ServerUrl = $"opc.tcp://localhost:{ServerFixture.Port}/" + + nameof(CalculatorTestServer); + + m_pkiRoot = Path.Combine( + Path.GetTempPath(), "OpcUaAotTests", "calc-client-pki"); + + m_clientConfiguration = new ApplicationConfiguration(Telemetry) + { + ApplicationName = "CalculatorAotTestClient", + ApplicationUri = "urn:localhost:OPCFoundation:CalculatorAotTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(m_pkiRoot, "own"), + SubjectName = "CN=CalculatorAotTestClient, O=OPC Foundation" + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(m_pkiRoot, "issuer") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(m_pkiRoot, "trusted") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(m_pkiRoot, "rejected") + }, + AutoAcceptUntrustedCertificates = true + }, + TransportQuotas = new TransportQuotas + { + MaxMessageSize = 4 * 1024 * 1024 + }, + ClientConfiguration = new ClientConfiguration(), + ServerConfiguration = new ServerConfiguration() + }; + await m_clientConfiguration.ValidateAsync( + ApplicationType.Client).ConfigureAwait(false); + m_clientConfiguration.CertificateManager ??= CertificateManagerFactory.Create( + m_clientConfiguration.SecurityConfiguration, Telemetry); + m_clientConfiguration.CertificateManager.AcceptError = static (cert, err) => true; + + EndpointDescription endpointDescription = + await CoreClientUtils.SelectEndpointAsync( + m_clientConfiguration, ServerUrl, useSecurity: false, + Telemetry, CancellationToken.None).ConfigureAwait(false); + var configuredEndpoint = new ConfiguredEndpoint( + null, endpointDescription, + EndpointConfiguration.Create(m_clientConfiguration)); + + var sessionFactory = new DefaultSessionFactory(Telemetry); +#pragma warning disable CA2000 // Dispose objects before losing scope + Session = await sessionFactory.CreateAsync( + m_clientConfiguration, + configuredEndpoint, + updateBeforeConnect: false, + sessionName: "CalculatorAotTest", + sessionTimeout: 60000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: default, + ct: CancellationToken.None).ConfigureAwait(false); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + public async ValueTask DisposeAsync() + { + if (Session != null) + { + await Session.CloseAsync(CancellationToken.None) + .ConfigureAwait(false); + await Session.DisposeAsync().ConfigureAwait(false); + Session = null; + } + if (m_clientConfiguration?.CertificateManager is IDisposable disposableManager) + { + disposableManager.Dispose(); + m_clientConfiguration.CertificateManager = null; + } + if (ServerFixture != null) + { + await ServerFixture.StopAsync().ConfigureAwait(false); + ServerFixture = null; + } + GC.SuppressFinalize(this); + } + + private ApplicationConfiguration m_clientConfiguration; + private string m_pkiRoot; + } + + /// + /// Public subclass that registers the + /// source-generated . + /// Mirrors the implicit hosting that AddNodeManager sets up + /// in MinimalCalcServer's Program.cs but is exposed as + /// public so can host it. + /// + public sealed class CalculatorTestServer : StandardServer + { + public CalculatorTestServer(ITelemetryContext telemetry) + : base(telemetry) + { + } + + protected override void OnServerStarting(ApplicationConfiguration configuration) + { + base.OnServerStarting(configuration); + AddNodeManager(new calcsample::Calc.CalcNodeManagerFactory()); + } + } +} diff --git a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj index d1cecf0068..daa60e92cc 100644 --- a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj +++ b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj @@ -24,6 +24,9 @@ boilersample + + calcsample + diff --git a/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs b/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs new file mode 100644 index 0000000000..b1ea0e2256 --- /dev/null +++ b/Tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs @@ -0,0 +1,204 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Opc.Ua.Client; + +namespace Opc.Ua.Aot.Tests +{ + /// + /// AOT integration tests that verify the source-generated typed + /// Publish<TEvent> overload on the boiler's + /// DrumX001 notifier wrapper actually wires an + /// + /// event source through the runtime + /// EventSourceRegistry and dispatches events to + /// monitored items under NativeAOT constraints (no JIT, no + /// reflection). + /// + [ClassDataSource(Shared = SharedType.PerTestSession)] + public class PublishedEventsAotTests(BoilerAotFixture fixture) + { + private const string kBoilerNamespaceUri = + "http://opcfoundation.org/UA/Boiler/"; + + [Test] + public async Task DrumHeartbeatEventsArriveAtMonitoredItem() + { + NodeId drumNodeId = await ResolveBoilerNodeAsync( + "DrumX001").ConfigureAwait(false); + + var eventFilter = new EventFilter(); + eventFilter.AddSelectClause( + ObjectTypeIds.BaseEventType, QualifiedName.From("EventId")); + eventFilter.AddSelectClause( + ObjectTypeIds.BaseEventType, QualifiedName.From("EventType")); + eventFilter.AddSelectClause( + ObjectTypeIds.BaseEventType, QualifiedName.From("SourceName")); + eventFilter.AddSelectClause( + ObjectTypeIds.BaseEventType, QualifiedName.From("Severity")); + eventFilter.AddSelectClause( + ObjectTypeIds.BaseEventType, QualifiedName.From("Message")); + + using var subscription = new Subscription(fixture.Session.DefaultSubscription) + { + DisplayName = "AotDrumHeartbeats", + PublishingEnabled = true, + PublishingInterval = 250, + KeepAliveCount = 10 + }; + + fixture.Session.AddSubscription(subscription); + await subscription.CreateAsync(CancellationToken.None) + .ConfigureAwait(false); + + try + { + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var eventItem = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = drumNodeId, + AttributeId = Attributes.EventNotifier, + DisplayName = "DrumHeartbeats", + Filter = eventFilter, + QueueSize = 16 + }; + + eventItem.Notification += (item, args) => + { + if (args.NotificationValue is EventFieldList fields) + { + received.TrySetResult(fields); + } + }; + + subscription.AddItem(eventItem); + await subscription.ApplyChangesAsync(CancellationToken.None) + .ConfigureAwait(false); + + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(15)); + using (cts.Token.Register( + () => received.TrySetCanceled(cts.Token))) + { + EventFieldList fields = await received.Task + .ConfigureAwait(false); + + await Assert.That(fields.EventFields.Count) + .IsEqualTo(eventFilter.SelectClauses.Count); + + List values = fields.EventFields.ToList(); + string sourceName = values[2].GetString(); + ushort severity = values[3].GetUInt16(); + LocalizedText message = values[4].GetLocalizedText(); + + await Assert.That(sourceName).IsEqualTo("DrumX001"); + await Assert.That((int)severity) + .IsEqualTo((int)EventSeverity.Medium); + await Assert.That(message.IsNull).IsFalse(); + await Assert.That(message.Text) + .StartsWith("Drum heartbeat #"); + } + } + finally + { + await fixture.Session.RemoveSubscriptionAsync(subscription) + .ConfigureAwait(false); + } + } + + /// + /// Walks the boiler instance tree starting from + /// Boilers/Boiler #1 in the boiler namespace using + /// TranslateBrowsePathsToNodeIds. + /// + private async Task ResolveBoilerNodeAsync( + params string[] tail) + { + ushort nsIndex = (ushort)fixture.Session.NamespaceUris + .GetIndex(kBoilerNamespaceUri); + await Assert.That(nsIndex).IsGreaterThan((ushort)0); + + var elements = new List + { + new() + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName("Boilers", nsIndex) + }, + new() + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName("Boiler #1", nsIndex) + } + }; + foreach (string segment in tail) + { + elements.Add(new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(segment, nsIndex) + }); + } + + var browsePaths = new List + { + new() + { + StartingNode = ObjectIds.ObjectsFolder, + RelativePath = new RelativePath + { + Elements = elements.ToArrayOf() + } + } + }; + + TranslateBrowsePathsToNodeIdsResponse response = + await fixture.Session.TranslateBrowsePathsToNodeIdsAsync( + null, browsePaths.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + await Assert.That(response.Results.Count).IsEqualTo(1); + BrowsePathResult result = response.Results.ToList()[0]; + await Assert.That(StatusCode.IsGood(result.StatusCode)).IsTrue(); + await Assert.That(result.Targets.Count).IsGreaterThan(0); + + return ExpandedNodeId.ToNodeId( + result.Targets.ToList()[0].TargetId, + fixture.Session.NamespaceUris); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs index 47cac4d2d3..54913c32a4 100644 --- a/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs +++ b/Tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs @@ -577,5 +577,78 @@ public void NodeFromTypeIdTypedThrowsBadTypeMismatch() () => b.NodeFromTypeId(typeId)); Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); } + + [Test] + public void ChildResolvesByBrowseName() + { + (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph(); + + INodeBuilder rb = b.Node(root.NodeId); + INodeBuilder cb = rb.Child(v.BrowseName); + + Assert.That(cb.Node, Is.SameAs(v)); + Assert.That(cb.Builder, Is.SameAs(b)); + } + + [Test] + public void ChildTypedReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph(); + + INodeBuilder cb = + b.Node(root.NodeId).Child(v.BrowseName); + + Assert.That(cb.Node, Is.SameAs(v)); + } + + [Test] + public void ChildTypedThrowsBadTypeMismatchForWrongType() + { + (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph(); + + ServiceResultException ex = Assert.Throws( + () => b.Node(root.NodeId).Child(v.BrowseName)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); + } + + [Test] + public void ChildThrowsBadNodeIdUnknownForMissingBrowseName() + { + (NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph(); + + ServiceResultException ex = Assert.Throws( + () => b.Node(root.NodeId).Child(new QualifiedName("Missing", kNs))); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void ChildThrowsBadBrowseNameInvalidForNullBrowseName() + { + (NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph(); + + ServiceResultException ex = Assert.Throws( + () => b.Node(root.NodeId).Child(QualifiedName.Null)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameInvalid)); + } + + [Test] + public void VariableByBrowseNameReturnsTypedVariableBuilder() + { + (NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph(); + + IVariableBuilder vb = b.Node(root.NodeId).Variable(v.BrowseName); + + Assert.That(vb.Node, Is.SameAs(v)); + } + + [Test] + public void VariableByBrowseNameThrowsBadTypeMismatchOnNonVariable() + { + (NodeManagerBuilder b, BaseObjectState root, _, MethodState m) = CreateBuilderWithGraph(); + + ServiceResultException ex = Assert.Throws( + () => b.Node(root.NodeId).Variable(m.BrowseName)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); + } } } diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs new file mode 100644 index 0000000000..51405db374 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs @@ -0,0 +1,838 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +// CA2000: per-test fixture lifecycle is managed by NUnit; managers and other +// disposables are explicitly disposed in TearDown or by the using-block. +#pragma warning disable CA2000 +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Server.Tests.Fluent +{ + [TestFixture] + [Category("Fluent")] + [Parallelizable(ParallelScope.None)] + public class PublishTests + { + private const ushort kNs = 2; + private const string kNamespaceUri = "http://test.org/UA/Publish/"; + // Generous timeout: reconcile/worker tasks run on the thread pool which + // can be starved when the broader test suite (e.g. AsyncCustomNodeManager + // tests with [Parallelizable(ParallelScope.All)]) saturates CPU. 15s + // keeps green runs under 1s while eliminating false negatives under load. + private static readonly TimeSpan s_signalTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan s_negativeWindow = TimeSpan.FromMilliseconds(250); + + private Mock m_mockServer; + private ApplicationConfiguration m_configuration; + private Mock m_mockMasterNodeManager; + private NamespaceTable m_namespaceTable; + private MonitoredItemQueueFactory m_queueFactory; + + [SetUp] + public void SetUp() + { + m_mockServer = new Mock(); + m_mockMasterNodeManager = new Mock(); + var mockConfigurationNodeManager = new Mock(); + + m_namespaceTable = new NamespaceTable(); + m_namespaceTable.Append(kNamespaceUri); + + m_mockServer.Setup(s => s.NamespaceUris).Returns(m_namespaceTable); + m_mockServer.Setup(s => s.ServerUris).Returns(new StringTable()); + m_mockServer.Setup(s => s.TypeTree).Returns(new TypeTable(m_namespaceTable)); + m_mockServer.Setup(s => s.Factory).Returns(EncodeableFactory.Create()); + m_mockServer.Setup(s => s.NodeManager).Returns(m_mockMasterNodeManager.Object); + m_mockMasterNodeManager + .Setup(m => m.ConfigurationNodeManager) + .Returns(mockConfigurationNodeManager.Object); + + var mockTelemetry = new Mock(); + m_mockServer.Setup(s => s.Telemetry).Returns(mockTelemetry.Object); + + m_queueFactory = new MonitoredItemQueueFactory(mockTelemetry.Object); + m_mockServer.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory); + + var defaultContext = new ServerSystemContext(m_mockServer.Object); + m_mockServer.Setup(s => s.DefaultSystemContext).Returns(defaultContext); + + m_configuration = new ApplicationConfiguration + { + ServerConfiguration = new ServerConfiguration + { + MaxNotificationQueueSize = 100, + MaxDurableNotificationQueueSize = 200 + } + }; + } + + [TearDown] + public void TearDown() + { + m_queueFactory?.Dispose(); + } + + #region Lazy / eager activation + + [Test] + public async Task Publish_LazyDefault_DoesNotInvokeFactoryUntilEventsAreMonitoredAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "LazyNotifier"); + + var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var stopFactory = new CancellationTokenSource(); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CountingStream(factoryStarted, ct), + options: null); + + // Lazy mode: factory MUST NOT be invoked while no monitor is attached. + await Task.Delay(s_negativeWindow).ConfigureAwait(false); + Assert.That(factoryStarted.Task.IsCompleted, Is.False, + "Factory must not run before AreEventsMonitored flips on."); + + // Flip the flag and signal — registry should activate within s_signalTimeout. + notifier.SetAreEventsMonitored(manager.SystemContext, true, false); + manager.EventSources.SignalReconcile(); + + await WaitForAsync(factoryStarted.Task).ConfigureAwait(false); + stopFactory.Cancel(); + } + + [Test] + public async Task Publish_AlwaysOn_StartsFactoryWithoutSubscribersAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "EagerNotifier"); + + var factoryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CountingStream(factoryStarted, ct), + new EventPublishOptions { AlwaysOn = true }); + + await WaitForAsync(factoryStarted.Task).ConfigureAwait(false); + Assert.That(notifier.AreEventsMonitored, Is.False, + "AlwaysOn must not require AreEventsMonitored to be true."); + } + + [Test] + public async Task Publish_LazyMonitorThenUnmonitor_DeactivatesFactoryAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Toggling"); + + var iteratorEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var iteratorObservedCancel = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct), + options: null); + + notifier.SetAreEventsMonitored(manager.SystemContext, true, false); + manager.EventSources.SignalReconcile(); + + // Wait until the worker is actually inside the iterator before + // unmonitoring — otherwise the unsubscribe could race ahead and the + // cancel would be observed during enumerator setup, not the await. + await WaitForAsync(iteratorEntered.Task).ConfigureAwait(false); + + notifier.SetAreEventsMonitored(manager.SystemContext, false, false); + manager.EventSources.SignalReconcile(); + + await WaitForAsync(iteratorObservedCancel.Task).ConfigureAwait(false); + } + + #endregion + + #region Event delivery + + [Test] + public async Task Publish_ActivatedSource_DeliversEventsThroughOnReportEventAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Delivering"); + + var observed = new ConcurrentQueue(); + var observedCount = new AsyncCountdown(target: 3); + notifier.OnReportEvent = (_, _, e) => + { + if (e is BaseEventState evt) + { + observed.Enqueue(evt); + observedCount.SignalOne(); + } + }; + + Channel channel = Channel.CreateUnbounded(); + manager.EventSources.Register( + notifier, + (_, _, ct) => channel.Reader.ReadAllAsync(ct), + new EventPublishOptions { AlwaysOn = true }); + + for (int i = 0; i < 3; i++) + { + await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false); + } + + await WaitForAsync(observedCount.WaitAsync()).ConfigureAwait(false); + Assert.That(observed, Has.Count.EqualTo(3)); + } + + [Test] + public async Task Publish_DispatchedEvent_PopulatesDefaultsAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Defaults"); + + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + notifier.OnReportEvent = (_, _, e) => + { + if (e is BaseEventState evt) + { + captured.TrySetResult(evt); + } + }; + + Channel channel = Channel.CreateUnbounded(); + manager.EventSources.Register( + notifier, + (_, _, ct) => channel.Reader.ReadAllAsync(ct), + new EventPublishOptions { AlwaysOn = true }); + + await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false); + + BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(seen.EventId, Is.Not.Null); + Assert.That(seen.EventId.Value.IsNull, Is.False); + Assert.That(seen.EventType, Is.Not.Null); + Assert.That(seen.EventType.Value.IsNull, Is.False); + Assert.That(seen.SourceNode, Is.Not.Null); + Assert.That(seen.SourceNode.Value, Is.EqualTo(notifier.NodeId)); + Assert.That(seen.SourceName, Is.Not.Null); + Assert.That(seen.SourceName.Value, Is.EqualTo(notifier.BrowseName.Name)); + Assert.That(seen.Time, Is.Not.Null); + Assert.That(seen.Time.Value.IsNull, Is.False); + Assert.That(seen.ReceiveTime, Is.Not.Null); + Assert.That(seen.Severity, Is.Not.Null); + Assert.That(seen.Severity.Value, Is.EqualTo((ushort)EventSeverity.Medium)); + Assert.That(seen.Message, Is.Not.Null); + }); + } + + [Test] + public async Task Publish_DispatchedEvent_PreservesUserPopulatedFieldsAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Custom"); + + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + notifier.OnReportEvent = (_, _, e) => + { + if (e is BaseEventState evt) + { + captured.TrySetResult(evt); + } + }; + + ByteString customEventId = Uuid.NewUuid().ToByteString(); + var customSource = new NodeId("OtherSource", kNs); + const string kCustomSourceName = "AlternateName"; + const ushort kCustomSeverity = 800; + + BaseEventState authored = new BaseEventState(parent: null); + authored.EventId = PropertyState.With(authored, customEventId); + authored.SourceNode = PropertyState.With(authored, customSource); + authored.SourceName = PropertyState.With(authored, kCustomSourceName); + authored.Severity = PropertyState.With(authored, kCustomSeverity); + + Channel channel = Channel.CreateUnbounded(); + manager.EventSources.Register( + notifier, + (_, _, ct) => channel.Reader.ReadAllAsync(ct), + new EventPublishOptions { AlwaysOn = true }); + + await channel.Writer.WriteAsync(authored).ConfigureAwait(false); + + BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(seen.EventId.Value, Is.EqualTo(customEventId)); + Assert.That(seen.SourceNode.Value, Is.EqualTo(customSource)); + Assert.That(seen.SourceName.Value, Is.EqualTo(kCustomSourceName)); + Assert.That(seen.Severity.Value, Is.EqualTo(kCustomSeverity)); + }); + } + + [Test] + public async Task Publish_SkipDefaultPopulation_LeavesFieldsUntouchedAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "SkipDefaults"); + + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + notifier.OnReportEvent = (_, _, e) => + { + if (e is BaseEventState evt) + { + captured.TrySetResult(evt); + } + }; + + Channel channel = Channel.CreateUnbounded(); + manager.EventSources.Register( + notifier, + (_, _, ct) => channel.Reader.ReadAllAsync(ct), + new EventPublishOptions { AlwaysOn = true, SkipDefaultPopulation = true }); + + await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false); + + BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(seen.EventId, Is.Null); + Assert.That(seen.EventType, Is.Null); + Assert.That(seen.SourceNode, Is.Null); + Assert.That(seen.SourceName, Is.Null); + Assert.That(seen.Time, Is.Null); + Assert.That(seen.ReceiveTime, Is.Null); + Assert.That(seen.Severity, Is.Null); + Assert.That(seen.Message, Is.Null); + }); + } + + #endregion + + #region Errors and validation + + [Test] + public async Task Publish_FactoryThrows_InvokesOnErrorAndStopsSourceAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "FactoryThrows"); + + var thrown = new InvalidOperationException("factory boom"); + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, _) => throw thrown, + new EventPublishOptions + { + AlwaysOn = true, + OnError = ex => captured.TrySetResult(ex) + }); + + Exception observed = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.That(observed, Is.SameAs(thrown)); + } + + [Test] + public async Task Publish_IteratorThrows_InvokesOnErrorAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "IteratorThrows"); + + var thrown = new InvalidOperationException("iterator boom"); + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => ThrowingStream(thrown, ct), + new EventPublishOptions + { + AlwaysOn = true, + OnError = ex => captured.TrySetResult(ex) + }); + + Exception observed = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.That(observed, Is.SameAs(thrown)); + } + + [Test] + public void Publish_DuplicateRegistration_ThrowsBadConfigurationError() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Duplicate"); + + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + options: null); + + ServiceResultException ex = Assert.Throws(() => + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + options: null)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void Publish_NegativeCancellationTimeout_ThrowsArgumentOutOfRange() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "BadTimeout"); + + Assert.Throws(() => + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + new EventPublishOptions { CancellationTimeout = TimeSpan.FromSeconds(-1) })); + } + + [Test] + public void Publish_InfiniteCancellationTimeout_IsAccepted() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "InfiniteTimeout"); + + Assert.DoesNotThrow(() => + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + new EventPublishOptions { CancellationTimeout = Timeout.InfiniteTimeSpan })); + } + + [Test] + public void Publish_NullNotifier_ThrowsArgumentNull() + { + using TestablePublishManager manager = CreateManager(); + + Assert.Throws(() => + manager.EventSources.Register( + notifier: null, + (_, _, ct) => EmptyStream(ct), + options: null)); + } + + [Test] + public void Publish_NullFactory_ThrowsArgumentNull() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "NullFactory"); + + Assert.Throws(() => + manager.EventSources.Register( + notifier, + factory: null, + options: null)); + } + + #endregion + + #region Auto-promote and root-notifier + + [Test] + public void Publish_AutoPromotesEventNotifierBit() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "AutoPromote", eventNotifier: EventNotifiers.None); + Assert.That(notifier.EventNotifier, Is.EqualTo(EventNotifiers.None), + "Sanity: notifier started without SubscribeToEvents."); + + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + options: null); + + Assert.That( + (notifier.EventNotifier & EventNotifiers.SubscribeToEvents), + Is.EqualTo(EventNotifiers.SubscribeToEvents), + "Publish must auto-promote SubscribeToEvents on the notifier."); + } + + [Test] + public void Publish_RegisterAsRootNotifier_AddsToRootNotifierSet() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "Root"); + + manager.EventSources.Register( + notifier, + (_, _, ct) => EmptyStream(ct), + new EventPublishOptions { RegisterAsRootNotifier = true }); + + Assert.That(manager.RootNotifiers, Contains.Key(notifier.NodeId)); + } + + #endregion + + #region Lifecycle / dispose + + [Test] + public async Task Dispose_CancelsActiveIteratorAsync() + { + TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "DisposeCancel"); + + var iteratorEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var iteratorObservedCancel = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + manager.EventSources.Register( + notifier, + (_, _, ct) => CancelObservingStream(iteratorEntered, iteratorObservedCancel, ct), + new EventPublishOptions { AlwaysOn = true }); + + // Wait until the worker is actually inside the iterator before + // disposing the manager so we observe cancel propagation, not setup. + await WaitForAsync(iteratorEntered.Task).ConfigureAwait(false); + + manager.Dispose(); + + await WaitForAsync(iteratorObservedCancel.Task).ConfigureAwait(false); + } + + #endregion + + #region Extension method (Publish on builder) + + [Test] + public void Publish_OnNonFluentManager_ThrowsBadConfigurationErrorWithManagerType() + { + using TestablePublishManager fluent = CreateManager(); + BaseObjectState notifier = MakeNotifier(fluent, "WrongBase"); + + // Build a NodeManagerBuilder backed by a non-fluent (Mock) manager + // and feed in only the resolver for `notifier`. Publish must reject + // it because the registry was never attached. + var roots = new Dictionary { [notifier.BrowseName] = notifier }; + var byId = new Dictionary { [notifier.NodeId] = notifier }; + + var nonFluentManager = new Mock(); + + var nonFluentBuilder = new NodeManagerBuilder( + fluent.SystemContext, + nodeManager: nonFluentManager.Object, + defaultNamespaceIndex: kNs, + rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, + typeIdResolver: _ => []); + + INodeBuilder nodeBuilder = nonFluentBuilder.Node(notifier.BrowseName.Name); + ServiceResultException ex = Assert.Throws(() => + nodeBuilder.Publish( + (_, _, ct) => EmptyStream(ct))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(ex.Message, Does.Contain("FluentNodeManagerBase"), + "Error message must reference the required base class."); + } + + [Test] + public async Task Publish_FactoryOverloadOnAttachedBuilder_RegistersAndDeliversAsync() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "ExtFactory"); + + var roots = new Dictionary { [notifier.BrowseName] = notifier }; + var byId = new Dictionary { [notifier.NodeId] = notifier }; + + var builder = new NodeManagerBuilder( + manager.SystemContext, + nodeManager: Mock.Of(), + defaultNamespaceIndex: kNs, + rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, + typeIdResolver: _ => []); + manager.AttachToBuilder(builder); + + var captured = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + notifier.OnReportEvent = (_, _, e) => + { + if (e is BaseEventState evt) + { + captured.TrySetResult(evt); + } + }; + + Channel channel = Channel.CreateUnbounded(); + builder.Node(notifier.BrowseName.Name) + .Publish( + (_, _, ct) => channel.Reader.ReadAllAsync(ct), + new EventPublishOptions { AlwaysOn = true }); + + await channel.Writer.WriteAsync(new BaseEventState(parent: null)).ConfigureAwait(false); + + BaseEventState seen = await WaitForAsync(captured.Task).ConfigureAwait(false); + Assert.That(seen, Is.Not.Null); + } + + [Test] + public void Publish_NullArgumentsOnExtension_Throw() + { + using TestablePublishManager manager = CreateManager(); + BaseObjectState notifier = MakeNotifier(manager, "NullArgExt"); + + var roots = new Dictionary { [notifier.BrowseName] = notifier }; + var byId = new Dictionary { [notifier.NodeId] = notifier }; + + var builder = new NodeManagerBuilder( + manager.SystemContext, + nodeManager: Mock.Of(), + defaultNamespaceIndex: kNs, + rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, + typeIdResolver: _ => []); + manager.AttachToBuilder(builder); + + INodeBuilder nodeBuilder = builder.Node(notifier.BrowseName.Name); + + Assert.Throws(() => + EventNotifierBuilderExtensions.Publish( + nodeBuilder: null, + factory: (_, _, ct) => EmptyStream(ct))); + + Assert.Throws(() => + nodeBuilder.Publish( + factory: null)); + + Assert.Throws(() => + EventNotifierBuilderExtensions.Publish( + nodeBuilder: null, + source: AsyncEnumerable.Empty())); + + Assert.Throws(() => + nodeBuilder.Publish( + source: (IAsyncEnumerable)null)); + } + + [Test] + public void AttachToBuilder_NullBuilder_ThrowsArgumentNull() + { + using TestablePublishManager manager = CreateManager(); + Assert.Throws(() => manager.AttachToBuilder(null)); + } + + #endregion + + #region Helpers + + private TestablePublishManager CreateManager() + { + var manager = new TestablePublishManager( + m_mockServer.Object, + m_configuration, + logger: null, + kNamespaceUri); + SetupMasterNodeManager(manager); + return manager; + } + + private void SetupMasterNodeManager(TestablePublishManager manager) + { + m_mockMasterNodeManager + .Setup(m => m.GetManagerHandleAsync(It.IsAny(), It.IsAny())) + .Returns((nodeId, _) => + { + NodeState nodeState = manager.Find(nodeId); + if (nodeState == null) + { + return new ValueTask<(object handle, IAsyncNodeManager nodeManager)>((null, null)); + } + var handle = new NodeHandle(nodeId, nodeState); + return new ValueTask<(object handle, IAsyncNodeManager nodeManager)>((handle, manager)); + }); + } + + private static BaseObjectState MakeNotifier( + TestablePublishManager manager, + string browseName, + byte eventNotifier = EventNotifiers.SubscribeToEvents) + { + var notifier = new BaseObjectState(parent: null) + { + NodeId = new NodeId(browseName, kNs), + BrowseName = new QualifiedName(browseName, kNs), + DisplayName = new LocalizedText(browseName), + EventNotifier = eventNotifier + }; + manager.AddPublic(notifier); + return notifier; + } + + private static async Task WaitForAsync(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(s_signalTimeout)).ConfigureAwait(false); + if (completed != task) + { + Assert.Fail($"Operation did not signal within {s_signalTimeout.TotalSeconds:F1}s."); + } + await task.ConfigureAwait(false); + } + + private static async Task WaitForAsync(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(s_signalTimeout)).ConfigureAwait(false); + if (completed != task) + { + Assert.Fail($"Operation did not signal within {s_signalTimeout.TotalSeconds:F1}s."); + } + return await task.ConfigureAwait(false); + } + + private static async IAsyncEnumerable CountingStream( + TaskCompletionSource started, + [EnumeratorCancellation] CancellationToken ct) + { + started.TrySetResult(true); + try + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + yield break; + } + + private static async IAsyncEnumerable CancelObservingStream( + TaskCompletionSource iteratorEntered, + TaskCompletionSource observedCancel, + [EnumeratorCancellation] CancellationToken ct) + { + iteratorEntered.TrySetResult(true); + try + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + observedCancel.TrySetResult(true); + throw; + } + yield break; + } + + private static async IAsyncEnumerable ThrowingStream( + Exception toThrow, + [EnumeratorCancellation] CancellationToken ct) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + throw toThrow; +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + + private static async IAsyncEnumerable EmptyStream( + [EnumeratorCancellation] CancellationToken ct) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + yield break; + } + + private sealed class AsyncCountdown + { + private int m_remaining; + private readonly TaskCompletionSource m_done = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public AsyncCountdown(int target) => m_remaining = target; + + public void SignalOne() + { + if (Interlocked.Decrement(ref m_remaining) == 0) + { + m_done.TrySetResult(true); + } + } + + public Task WaitAsync() => m_done.Task; + } + + private static class AsyncEnumerable + { + public static IAsyncEnumerable Empty() => EmptyImpl(); + + private static async IAsyncEnumerable EmptyImpl( + [EnumeratorCancellation] CancellationToken ct = default) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + yield break; + } + } + + public class TestablePublishManager : FluentNodeManagerBase + { + public TestablePublishManager( + IServerInternal server, + ApplicationConfiguration configuration, + ILogger logger, + params string[] namespaceUris) + : base(server, configuration, logger, namespaceUris) + { + } + + public new NodeIdDictionary RootNotifiers => base.RootNotifiers; + + public new NodeIdDictionary PredefinedNodes => base.PredefinedNodes; + + public ValueTask AddPublic( + BaseInstanceState node, + CancellationToken cancellationToken = default) + { + return AddNodeAsync(SystemContext, default, node, cancellationToken); + } + } + + #endregion + } +} diff --git a/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs new file mode 100644 index 0000000000..4f8269d729 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs @@ -0,0 +1,480 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Tests; + +// CA2000: BaseObjectState/BaseDataVariableState instances created in the test fixture +// are passed to the builder under test which owns them for the test fixture lifetime. +#pragma warning disable CA2000 + +namespace Opc.Ua.Server.Tests.Fluent +{ + /// + /// End-to-end round-trip tests for : + /// each typed convenience overload registers the appropriate hook + /// slot on the underlying , and + /// driving the variable through ReadAttribute / + /// WriteAttribute (sync) and ReadAttributeAsync / + /// WriteAttributeAsync (async) reproduces the typed values + /// the user supplied. + /// + [TestFixture] + [Category("Fluent")] + public class TypedBuilderTests + { + private const ushort kNs = 2; + private static readonly NamespaceTable s_namespaces = new(); + private static readonly TypeTable s_typeTable = new(s_namespaces); + + private static SystemContext CreateContext() + { + return new SystemContext(NUnitTelemetryContext.Create()) + { + NamespaceUris = s_namespaces, + TypeTable = s_typeTable + }; + } + + private static (NodeManagerBuilder Builder, BaseDataVariableState Var) + CreateBuilderForVariable(NodeId dataType) + { + SystemContext ctx = CreateContext(); + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", kNs), + BrowseName = new QualifiedName("Root", kNs), + DisplayName = new LocalizedText("Root") + }; + + var v = new BaseDataVariableState(root) + { + NodeId = new NodeId("Root.Var", kNs), + BrowseName = new QualifiedName("Var", kNs), + DisplayName = new LocalizedText("Var"), + DataType = dataType, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite, + StatusCode = StatusCodes.Good + }; + root.AddChild(v); + + var roots = new Dictionary { [root.BrowseName] = root }; + var byId = new Dictionary + { + [root.NodeId] = root, + [v.NodeId] = v + }; + + var builder = new NodeManagerBuilder( + ctx, + nodeManager: Mock.Of(), + defaultNamespaceIndex: kNs, + rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, + typeIdResolver: _ => []); + + return (builder, v); + } + + // ----------------------------------------------------------------- + // Resolution + // ----------------------------------------------------------------- + + [Test] + public void VariableByPathReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder typed = b.Variable("Root/Var"); + + Assert.That(typed, Is.Not.Null); + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void VariableByNodeIdReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder typed = b.Variable(new NodeId("Root.Var", kNs)); + + Assert.That(typed, Is.Not.Null); + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void AsVariableReturnsTypedBuilder() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + INodeBuilder nb = b.Node("Root/Var"); + IVariableBuilder typed = nb.AsVariable(); + + Assert.That(typed.Node, Is.SameAs(v)); + } + + [Test] + public void AsVariableThrowsWhenNodeIsNotAVariable() + { + SystemContext ctx = CreateContext(); + var folder = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", kNs), + BrowseName = new QualifiedName("Root", kNs), + DisplayName = new LocalizedText("Root") + }; + var roots = new Dictionary { [folder.BrowseName] = folder }; + var byId = new Dictionary { [folder.NodeId] = folder }; + var builder = new NodeManagerBuilder( + ctx, + nodeManager: Mock.Of(), + defaultNamespaceIndex: kNs, + rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, + typeIdResolver: _ => []); + + INodeBuilder nb = builder.Node("Root"); + + ServiceResultException ex = Assert.Throws( + () => nb.AsVariable()); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidArgument)); + } + + // ----------------------------------------------------------------- + // OnRead — sync + // ----------------------------------------------------------------- + + [Test] + public void OnReadFuncTValueIsInvokedOnValueRead() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + int callCount = 0; + b.Variable("Root/Var").OnRead(() => + { + callCount++; + return 42; + }); + + var dv = new DataValue(); + ServiceResult result = v.ReadAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(42)); + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public void OnReadFuncContextTValueReceivesSystemContext() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + ISystemContext seenContext = null; + b.Variable("Root/Var").OnRead((ISystemContext c) => + { + seenContext = c; + return 7; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue(); + v.ReadAttribute(ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(7)); + } + + [Test] + public void OnReadThrowsForNullGetter() + { + (NodeManagerBuilder b, _) = + CreateBuilderForVariable(DataTypeIds.Int32); + + IVariableBuilder tb = b.Variable("Root/Var"); + + Assert.Throws(() => tb.OnRead((Func)null)); + Assert.Throws(() => tb.OnRead((Func)null)); + } + + // ----------------------------------------------------------------- + // OnRead — async + // ----------------------------------------------------------------- + + [Test] + public async Task OnReadAsyncFuncTValueRoutesThroughAsyncSlot() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + int callCount = 0; + b.Variable("Root/Var").OnRead(async (CancellationToken ct) => + { + callCount++; + await Task.Yield(); + return 99; + }); + + // Async-typed OnRead must register OnSimpleReadValueAsync, not the sync slot. + Assert.That(v.OnSimpleReadValueAsync, Is.Not.Null); + Assert.That(v.OnSimpleReadValue, Is.Null); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetInt32(), Is.EqualTo(99)); + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task OnReadAsyncReceivesCancellationToken() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Int32); + + using var cts = new CancellationTokenSource(); + CancellationToken seenToken = default; + + b.Variable("Root/Var").OnRead((CancellationToken ct) => + { + seenToken = ct; + return new ValueTask(33); + }); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + CreateContext(), + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + dv, + cts.Token).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(seenToken, Is.EqualTo(cts.Token)); + } + + // ----------------------------------------------------------------- + // OnWrite — sync + // ----------------------------------------------------------------- + + [Test] + public void OnWriteActionTValueIsInvokedOnValueWrite() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + double captured = double.NaN; + b.Variable("Root/Var").OnWrite((double x) => captured = x); + + // Action overload registers OnSimpleWriteValue (since it + // only needs the Variant, not the full ref-StatusCode/Timestamp + // payload). Confirm wiring before we verify behavior. + Assert.That(v.OnSimpleWriteValue, Is.Not.Null); + + var dv = new DataValue + { + WrappedValue = new Variant(2.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + ServiceResult result = v.WriteAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True, + $"StatusCode=0x{result.StatusCode.Code:X8}; Inner={result.InnerResult}"); + Assert.That(captured, Is.EqualTo(2.5)); + } + + [Test] + public void OnWriteActionContextTValueReceivesSystemContext() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + ISystemContext seenContext = null; + double captured = 0; + b.Variable("Root/Var").OnWrite((ISystemContext c, double x) => + { + seenContext = c; + captured = x; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + v.WriteAttribute(ctx, Attributes.Value, NumericRange.Null, dv); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(captured, Is.EqualTo(11.0)); + } + + [Test] + public void OnWriteThrowsForNullSetter() + { + (NodeManagerBuilder b, _) = + CreateBuilderForVariable(DataTypeIds.Double); + + IVariableBuilder tb = b.Variable("Root/Var"); + + Assert.Throws(() => tb.OnWrite((Action)null)); + Assert.Throws( + () => tb.OnWrite((Action)null)); + } + + // ----------------------------------------------------------------- + // OnWrite — async + // ----------------------------------------------------------------- + + [Test] + public async Task OnWriteAsyncRoutesThroughAsyncSlot() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + double captured = 0; + b.Variable("Root/Var").OnWrite(async (double x, CancellationToken ct) => + { + captured = x; + await Task.Yield(); + }); + + // Async-typed OnWrite must register OnSimpleWriteValueAsync, not the sync slot. + Assert.That(v.OnSimpleWriteValueAsync, Is.Not.Null); + Assert.That(v.OnSimpleWriteValue, Is.Null); + + var dv = new DataValue + { + WrappedValue = new Variant(7.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + ServiceResult result = await v.WriteAttributeAsync( + CreateContext(), Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(captured, Is.EqualTo(7.5)); + } + + [Test] + public async Task OnWriteAsyncReceivesContextAndCancellationToken() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.Double); + + using var cts = new CancellationTokenSource(); + ISystemContext seenContext = null; + CancellationToken seenToken = default; + + b.Variable("Root/Var").OnWrite( + (ISystemContext c, double x, CancellationToken ct) => + { + seenContext = c; + seenToken = ct; + return default; + }); + + SystemContext ctx = CreateContext(); + var dv = new DataValue + { + WrappedValue = new Variant(1.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv, cts.Token).ConfigureAwait(false); + + Assert.That(seenContext, Is.SameAs(ctx)); + Assert.That(seenToken, Is.EqualTo(cts.Token)); + } + + // ----------------------------------------------------------------- + // Type-marshalling + // ----------------------------------------------------------------- + + [Test] + public void OnReadHandlesNullFromVariantForReferenceTypes() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.String); + + // Hook a writer that captures whatever the framework hands us; + // we want to verify ReadValue can yield a default(string) if + // OnRead returns null. + b.Variable("Root/Var").OnRead(() => null); + + var dv = new DataValue(); + ServiceResult result = v.ReadAttribute( + CreateContext(), Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.IsNull, Is.True); + } + + [Test] + public void OnWriteFromVariantUnwrapsTypedValue() + { + (NodeManagerBuilder b, BaseDataVariableState v) = + CreateBuilderForVariable(DataTypeIds.String); + + string captured = null; + b.Variable("Root/Var").OnWrite((string s) => captured = s); + + var dv = new DataValue + { + WrappedValue = new Variant("hello"), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + v.WriteAttribute(CreateContext(), Attributes.Value, NumericRange.Null, dv); + + Assert.That(captured, Is.EqualTo("hello")); + } + } +} diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs new file mode 100644 index 0000000000..cb1b61d17c --- /dev/null +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/FluentBuilderGeneratorTests.cs @@ -0,0 +1,398 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.SourceGeneration.Generator.Tests +{ + /// + /// Snapshot tests for the : verify + /// the opt-in flag, the type-and-instance wrapper shape, the typed + /// IVariableBuilder accessors, and the typed sync/async OnCall + /// overloads on method wrappers. + /// + [TestFixture] + [Category("Generator")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable(ParallelScope.All)] + public class FluentBuilderGeneratorTests + { + [Test] + public void Emit_WithoutOptIn_ProducesNoFluentBuildersFile() + { + Dictionary files = GenerateForTestModel(generateNodeManager: false); + + Assert.That(files.Keys, Has.None.EndsWith(".FluentBuilders.g.cs")); + } + + [Test] + public void Emit_WithOptIn_ProducesFluentBuildersFile() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + + Assert.That(files.Keys, Has.Some.EndsWith(".FluentBuilders.g.cs"), + "Generator should emit a FluentBuilders file when GenerateNodeManager=true"); + } + + [Test] + public void EmittedFluentBuilders_HasTypedManagerInterfaceAndProxy() + { + string fb = GetFluentBuilders(); + + Assert.That(fb, Does.Match( + @"internal\s+interface\s+I\w+NodeManagerBuilder\s*:\s*global::Opc\.Ua\.Server\.Fluent\.INodeManagerBuilder"), + "A typed IBuilder interface extending INodeManagerBuilder must be emitted"); + + Assert.That(fb, Does.Match( + @"internal\s+sealed\s+class\s+\w+NodeManagerTypedBuilder\s*:\s*I\w+NodeManagerBuilder"), + "A typed proxy implementing the IBuilder must be emitted"); + + Assert.That(fb, Does.Contain("private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner"), + "The proxy must wrap an INodeManagerBuilder"); + } + + [Test] + public void EmittedFluentBuilders_AreAutoGenerated_AndUseGlobalQualifiedTypes() + { + string fb = GetFluentBuilders(); + + Assert.That(fb, Does.StartWith("// "), + "FluentBuilders file must start with the auto-generated marker"); + Assert.That(fb, Does.Contain("[global::System.CodeDom.Compiler.GeneratedCodeAttribute("), + "FluentBuilders file must carry the GeneratedCode attribute"); + Assert.That(fb, Does.Contain("#pragma warning disable"), + "FluentBuilders file must suppress warnings to survive strict consumer projects"); + } + + [Test] + public void EmittedFluentBuilders_WrappersAreInternalSealed() + { + string fb = GetFluentBuilders(); + + // Per design decision #8 — wrappers are internal sealed because + // Configure is a private partial. Public exposure would add a + // surface that can never be called outside the assembly. + Assert.That(fb, Does.Match(@"internal\s+sealed\s+class\s+\w+Builder\b"), + "Wrapper classes should be 'internal sealed'"); + Assert.That(fb, Does.Not.Match(@"public\s+(sealed\s+)?class\s+\w+Builder\b"), + "Wrapper classes must not be public"); + } + + [Test] + public void EmittedFluentBuilders_VariableAccessorsReturnTypedIVariableBuilder() + { + string fb = GetFluentBuilders(); + + // TestModel's RestrictedObjectType has a Mandatory variable child + // 'Red' of type RestrictedVariableType (Int32). The wrapper for + // the predefined TestObject instance must surface that variable + // through a typed IVariableBuilder accessor. + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.IVariableBuilder<\w+>\s+\w+\s*\{"), + "Variable children should expose IVariableBuilder accessors"); + + // Each accessor lazily resolves the namespace index to support + // cross-namespace browse names (NamespaceUris.GetIndexOrAppend). + Assert.That(fb, Does.Contain("Context.NamespaceUris.GetIndexOrAppend("), + "Variable accessors should resolve the namespace via GetIndexOrAppend"); + Assert.That(fb, Does.Match( + @"return\s+__node\.Variable<\w+>\(new global::Opc\.Ua\.QualifiedName\("), + "Variable accessors should fall back to INodeBuilder.Variable()"); + } + + [Test] + public void EmittedFluentBuilders_MethodWrappersExposeSyncAndAsyncOnCall() + { + string fb = GetFluentBuilders(); + + // Every method wrapper must offer BOTH sync and async OnCall + // overloads so user code-behind can choose the natural shape. + Assert.That(fb, Does.Match( + @"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*global::System\.Action\s+handler\s*\)"), + "Method wrappers should expose a sync OnCall(Action) overload"); + Assert.That(fb, Does.Match( + @"public\s+\w+MethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Method wrappers should expose an async OnCall(Func) overload"); + } + + [Test] + public void EmittedFluentBuilders_InstanceAccessorsAreLazyAndAllocateChildBuilder() + { + string fb = GetFluentBuilders(); + + // Child accessors should never cache — each get materializes a + // fresh wrapper around an INodeBuilder. Caching + // would be unsafe because INodeBuilder identity is per-resolution. + Assert.That(fb, Does.Not.Match(@"private\s+\w+Builder\s+__child"), + "Child accessors must not cache the wrapper"); + Assert.That(fb, Does.Match( + @"return\s+new\s+\w+Builder\s*\(\s*__node\.Child<\w+"), + "Object child accessors should resolve via INodeBuilder.Child()"); + } + + [Test] + public void EmittedFluentBuilders_TopLevelInstanceWrapperLivesAtNamespaceScope() + { + string fb = GetFluentBuilders(); + + // Top-level wrappers must sit directly under the namespace + // body — i.e. at column 4 (one level into the namespace block + // emitted by FluentBuilderTemplates.File). Use TestObject as + // an exemplar — TestModel.xml declares it as a top-level + // instance. + Assert.That(fb, Does.Match(@"(?m)^ internal sealed class TestObjectBuilder\b"), + "Top-level wrappers must be declared at namespace scope (column 4)"); + } + + [Test] + public void EmittedFluentBuilders_MethodWrapperIsNestedInsideOwningObject() + { + string fb = GetFluentBuilders(); + + // TestObject's RestrictedObjectType has a Mandatory Method + // child 'Blue'. Its wrapper class must be nested one level + // inside TestObjectBuilder — i.e. declared at column 8. + Assert.That(fb, Does.Match( + @"(?m)^ internal sealed class BlueMethodBuilder\b"), + "Method wrappers must be nested inside the owning object's wrapper (column 8)"); + } + + [Test] + public void EmittedFluentBuilders_ChildAccessorReturnsSimpleLeafName() + { + string fb = GetFluentBuilders(); + + // Child accessors should return the simple leaf name only + // (e.g. 'BlueMethodBuilder') because the wrapper is nested + // inside the parent, not the old flat + // 'TestObject_BlueMethodBuilder' identifier. + Assert.That(fb, Does.Not.Match(@"public\s+TestObject_\w+Builder\s+\w+"), + "Child accessors must reference the simple leaf-name wrapper, not a flat dotted identifier"); + Assert.That(fb, Does.Match(@"public\s+BlueMethodBuilder\s+Blue\b"), + "Child accessor for 'Blue' should return the simple-leaf-name 'BlueMethodBuilder'"); + } + + [Test] + public void MethodWithIntInputAndOutputEmitsTypedSyncOverload() + { + string fb = GetFluentBuilders(); + + // TestModel's MathInstance has a Mandatory method 'Compute' + // declared inline with InputArguments(x: Int32) and + // OutputArguments(result: Int32). The generator must surface + // a typed sync OnCall overload that maps Variant<-> int both + // ways without requiring the user to touch ArrayOf. + Assert.That(fb, Does.Match( + @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Compute(Int32 -> Int32) should expose a sync OnCall(Func) overload"); + } + + [Test] + public void MethodWithIntInputAndOutputEmitsTypedAsyncOverload() + { + string fb = GetFluentBuilders(); + + // The async overload threads CancellationToken between the + // unpacked inputs and the ValueTask return type. + Assert.That(fb, Does.Match( + @"public\s+ComputeMethodBuilder\s+OnCall\s*\(\s*global::System\.Func>\s+handler\s*\)"), + "Compute(Int32 -> Int32) should expose an async OnCall(Func>) overload"); + } + + [Test] + public void MethodInputUnpackUsesVariantTryGetValue() + { + string fb = GetFluentBuilders(); + + // Inputs flow through Variant.TryGetValue(out T) so the + // generated lambda short-circuits with BadInvalidArgument when + // the caller sends the wrong wire type. + Assert.That(fb, Does.Match( + @"if\s*\(!__inputs\[0\]\.TryGetValue\(out\s+int\s+__a0\)\)"), + "Input unpack should use Variant.TryGetValue(out T) for primitive inputs"); + Assert.That(fb, Does.Contain( + "return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);"), + "Failed input unpack should return BadInvalidArgument"); + Assert.That(fb, Does.Contain( + "return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);"), + "Missing input arg count should return BadArgumentsMissing"); + } + + [Test] + public void MethodOutputBoxUsesVariantFrom() + { + string fb = GetFluentBuilders(); + + // Outputs are boxed back through Variant.From(value) and + // assigned by index into the __outputs list which the base + // MethodState dispatcher pre-populates with default values + // (one slot per declared output argument). Single-output + // methods bind the user handler's return value to '__r'. + Assert.That(fb, Does.Contain( + "__outputs[0] = global::Opc.Ua.Variant.From(__r);"), + "Single-output methods should box the handler result via __outputs[0] = Variant.From(__r)"); + } + + [Test] + public void MethodWithMultipleInputArgsEmitsCorrectArity() + { + string fb = GetFluentBuilders(); + + // TestModel's MathInstance.Add(a: Int32, b: Int32 -> sum: Int32) + // must produce a sync OnCall overload whose handler arity + // matches the input count (Func) and an async + // overload that appends CancellationToken + ValueTask. + Assert.That(fb, Does.Match( + @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func\s+handler\s*\)"), + "Add(Int32,Int32 -> Int32) should expose a sync OnCall(Func) overload"); + Assert.That(fb, Does.Match( + @"public\s+AddMethodBuilder\s+OnCall\s*\(\s*global::System\.Func>\s+handler\s*\)"), + "Add(Int32,Int32 -> Int32) should expose an async OnCall(Func>) overload"); + // Unpack arity: index 0 and index 1 must both be present. + Assert.That(fb, Does.Match( + @"if\s*\(!__inputs\[1\]\.TryGetValue\(out\s+int\s+__a1\)\)"), + "Multi-input methods should unpack each Variant by index (e.g. __inputs[1] -> __a1)"); + } + + [Test] + public void EmittedNodeManager_InvokesBothPlainAndTypedConfigurePartials() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + string mgr = files.Single(kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value; + + // Both partials must be declared so users can implement either. + Assert.That(mgr, Does.Contain("partial void Configure(global::Opc.Ua.Server.Fluent.INodeManagerBuilder builder);"), + "Plain Configure(INodeManagerBuilder) partial declaration must be emitted"); + Assert.That(mgr, Does.Match(@"partial\s+void\s+Configure\s*\(\s*I\w+NodeManagerBuilder\s+builder\s*\);"), + "Typed Configure(I{Manager}NodeManagerBuilder) partial declaration must be emitted"); + + // Both must also be invoked so a user that only implements one + // gets their wiring run; the runtime treats unimplemented + // partials as no-ops. + Assert.That(mgr, Does.Contain("Configure(__m_builder);"), + "Plain Configure call must be present in CreateAddressSpaceAsync"); + Assert.That(mgr, Does.Match(@"Configure\(\s*new\s+\w+NodeManagerTypedBuilder\(__m_builder\)\s*\)"), + "Typed Configure call must be present in CreateAddressSpaceAsync"); + } + + [Test] + public void EmittedFluentBuilders_PublishOverloadEmittedOnSupportsEventsWrapper() + { + string fb = GetFluentBuilders(); + + // TestModel declares NotifierObject with SupportsEvents="true" + // (the design-XML form of EventNotifier=SubscribeToEvents). Its + // wrapper must expose typed Publish overloads bound to + // the wrapper's underlying state type and constrained to + // BaseEventState. + Assert.That(fb, Does.Match( + @"internal\s+sealed\s+class\s+NotifierObjectBuilder\b"), + "NotifierObject must produce a wrapper class"); + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder\s+Publish\(\s*global::System\.Collections\.Generic\.IAsyncEnumerable\s+source"), + "NotifierObjectBuilder must expose Publish(IAsyncEnumerable, EventPublishOptions)"); + Assert.That(fb, Does.Match( + @"public\s+global::Opc\.Ua\.Server\.Fluent\.INodeBuilder\s+Publish\(\s*global::System\.Func>\s+factory"), + "NotifierObjectBuilder must expose the factory-style Publish overload"); + Assert.That(fb, Does.Contain( + "where TEvent : global::Opc.Ua.BaseEventState"), + "Publish must be constrained to BaseEventState"); + Assert.That(fb, Does.Contain( + "global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish(__node,"), + "Publish overload must forward to EventNotifierBuilderExtensions.Publish with the wrapper's state type as TNotifier"); + } + + [Test] + public void EmittedFluentBuilders_PublishOverloadEmittedOnGeneratesEventWrapper() + { + string fb = GetFluentBuilders(); + + // EventEmittingObject does not set SupportsEvents but declares + // a forward GeneratesEvent reference. The wrapper must still + // expose Publish; the qualification logic walks both + // the SupportsEvents flag and outgoing GeneratesEvent / + // AlwaysGeneratesEvent references. + Assert.That(fb, Does.Match( + @"(?ms)internal\s+sealed\s+class\s+EventEmittingObjectBuilder\b.*?Publish"), + "EventEmittingObjectBuilder must expose Publish because of the GeneratesEvent reference"); + } + + [Test] + public void EmittedFluentBuilders_PublishOverloadAbsentOnPlainObjectWrapper() + { + string fb = GetFluentBuilders(); + + // TestObject (RestrictedObjectType) is a plain object: no + // SupportsEvents, no GeneratesEvent. It must NOT carry a + // typed Publish overload — the surface stays clean and only + // notifier candidates become discoverable through intellisense. + Assert.That(fb, Does.Not.Match( + @"(?ms)internal\s+sealed\s+class\s+TestObjectBuilder\b(?:(?!internal\s+sealed\s+class).)*?Publish"), + "TestObjectBuilder must not expose Publish — it's not a notifier"); + } + + private static string GetFluentBuilders() + { + Dictionary files = GenerateForTestModel(generateNodeManager: true); + return files.Single(kv => kv.Key.EndsWith(".FluentBuilders.g.cs", StringComparison.Ordinal)).Value; + } + + private static Dictionary GenerateForTestModel(bool generateNodeManager) + { + const string designFile = "TestModel.xml"; + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + using var fileSystem = new VirtualFileSystem(); + string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources"); + + Generators.GenerateCode(new DesignFileCollection + { + Targets = [Path.Combine(resources, designFile)], + IdentifierFilePath = Path.Combine( + resources, + Path.GetFileNameWithoutExtension(designFile) + ".csv"), + Options = new DesignFileOptions + { + GenerateNodeManager = generateNodeManager + } + }, fileSystem, string.Empty, telemetry); + + return fileSystem.CreatedFiles + .Where(c => Path.GetExtension(c) == ".cs") + .ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c))); + } + } +} diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs index c47b6319e6..d7706ee2bb 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs @@ -78,7 +78,7 @@ public void EmittedNodeManager_HasRequiredStructuralMembers() string mgr = files.Single(kv => kv.Key.EndsWith(".NodeManager.g.cs", StringComparison.Ordinal)).Value; // Inheritance and partial — required so users can extend. - Assert.That(mgr, Does.Contain(": global::Opc.Ua.Server.AsyncCustomNodeManager")); + Assert.That(mgr, Does.Contain(": global::Opc.Ua.Server.Fluent.FluentNodeManagerBase")); Assert.That(mgr, Does.Match(@"public\s+partial\s+class\s+\w+NodeManager")); // Lifecycle overrides that wire the runtime fluent dispatcher. diff --git a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml index bc8a80784d..0b34428d2a 100644 --- a/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml +++ b/Tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TestModel.xml @@ -324,6 +324,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -445,6 +467,24 @@ + + + + + + + + ua:GeneratesEvent + ua:BaseEventType + + + + diff --git a/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs b/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs new file mode 100644 index 0000000000..2eead128c7 --- /dev/null +++ b/Tests/Opc.Ua.Types.Tests/State/BaseVariableStateAsyncHooksTests.cs @@ -0,0 +1,499 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Types.Tests.State +{ + /// + /// Tests for the asynchronous Value-attribute hooks on + /// — the four On*ValueAsync + /// slots and the + /// / overrides + /// that route through them. + /// + /// + /// Coverage focuses on the contracts that distinguish the async + /// path from the synchronous fallback: + /// + /// The handler runs without holding lock(this). + /// propagates end-to-end. + /// Exceptions from the handler propagate to the caller. + /// The cached value / status / timestamp are updated on + /// successful writes (mirroring the sync flow). + /// When no async slot is set, the override defers to the + /// synchronous flow under the existing lock(this). + /// + /// + [TestFixture] + [Category("NodeState")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class BaseVariableStateAsyncHooksTests + { + private ITelemetryContext m_telemetry; + private ServiceMessageContext m_messageContext; + + [OneTimeSetUp] + protected void OneTimeSetUp() + { + m_telemetry = NUnitTelemetryContext.Create(); + m_messageContext = ServiceMessageContext.CreateEmpty(m_telemetry); + } + + [OneTimeTearDown] + protected void OneTimeTearDown() + { + (m_messageContext as IDisposable)?.Dispose(); + } + + private SystemContext CreateSystemContext() + { + return new SystemContext(m_telemetry) + { + NamespaceUris = m_messageContext.NamespaceUris, + TypeTable = new TypeTable(m_messageContext.NamespaceUris) + }; + } + + private static BaseDataVariableState CreateReadableVariable() + { + return new BaseDataVariableState(null) + { + NodeId = new NodeId("Var", 0), + BrowseName = new QualifiedName("Var", 0), + DisplayName = new LocalizedText("Var"), + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + } + + // ----------------------------------------------------------------- + // OnReadValueAsync (full) + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncRoutesValueReadsToFullAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + DateTimeUtc handlerTimestamp = DateTimeUtc.Now; + + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.That(c, Is.SameAs(ctx)); + Assert.That(n, Is.SameAs(v)); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, + new Variant(42.5), + StatusCodes.Good, + handlerTimestamp)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(42.5)); + Assert.That(dv.SourceTimestamp, Is.EqualTo((DateTime)handlerTimestamp)); + } + + [Test] + public async Task ReadAttributeAsyncFullSlotReleasesNodeStateLockDuringAwait() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + using var inHandlerGate = new ManualResetEventSlim(false); + using var releaseGate = new ManualResetEventSlim(false); + + v.OnReadValueAsync = async (c, n, range, encoding, ct) => + { + inHandlerGate.Set(); + // Block on a worker thread that takes lock(this) to prove + // we are NOT holding it across the await. + releaseGate.Wait(TimeSpan.FromSeconds(5), ct); + await Task.Yield(); + return new AttributeReadResult( + ServiceResult.Good, new Variant(7.0), StatusCodes.Good, DateTimeUtc.Now); + }; + + var dv = new DataValue(); + ValueTask readTask = v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv); + + Assert.That(inHandlerGate.Wait(TimeSpan.FromSeconds(5)), Is.True, + "handler did not start"); + + // Acquire lock(this) on a worker thread - this only succeeds + // if the read path released the lock before awaiting the + // handler. + bool lockAcquired = false; + Task lockTask = Task.Run(() => + { +#pragma warning disable CA2002 + lock (v) +#pragma warning restore CA2002 + { + lockAcquired = true; + } + }); + + Assert.That(lockTask.Wait(TimeSpan.FromSeconds(5)), Is.True, + "lock(v) was not released during the async handler"); + Assert.That(lockAcquired, Is.True); + + releaseGate.Set(); + ServiceResult result = await readTask.ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(result), Is.True); + } + + // ----------------------------------------------------------------- + // OnSimpleReadValueAsync + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncRoutesValueReadsToSimpleAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + // Simple async path reuses the variable's cached StatusCode; + // BaseVariableState defaults that to BadWaitingForInitialData, + // so promote it to Good to validate the success path. + v.StatusCode = StatusCodes.Good; + + v.OnSimpleReadValueAsync = (c, n, ct) => new ValueTask( + new AttributeSimpleReadResult(ServiceResult.Good, new Variant(123.5))); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(123.5)); + } + + [Test] + public async Task ReadAttributeAsyncSimpleSlotPropagatesCachedStatusCode() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + // The simple async path mirrors OnSimpleReadValue and reuses the + // variable's cached StatusCode — verify the framework + // propagates a non-Good cached code to the caller. + Assert.That(v.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadWaitingForInitialData)); + + v.OnSimpleReadValueAsync = (c, n, ct) => new ValueTask( + new AttributeSimpleReadResult(ServiceResult.Good, new Variant(123.5))); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadWaitingForInitialData)); + } + + // ----------------------------------------------------------------- + // Cancellation + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncWrapsCancellationFromFullSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + using var cts = new CancellationTokenSource(); + CancellationToken seenToken = default; + + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + seenToken = ct; + ct.ThrowIfCancellationRequested(); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, new Variant(0.0), StatusCodes.Good, DateTimeUtc.Now)); + }; + + cts.Cancel(); + var dv = new DataValue(); + // The async hook contract mirrors the sync flow: exceptions + // (including OperationCanceledException) are caught and surfaced + // as a Bad ServiceResult, never thrown to the caller. + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv, cts.Token).ConfigureAwait(false); + + Assert.That(seenToken, Is.EqualTo(cts.Token), "Cancellation token must propagate to the hook."); + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + Assert.That(result.InnerResult, Is.Not.Null); + } + + [Test] + public async Task ReadAttributeAsyncWrapsHandlerExceptions() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + + v.OnReadValueAsync = (c, n, range, encoding, ct) => throw new InvalidOperationException("boom"); + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + Assert.That(dv.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadUnexpectedError)); + } + + // ----------------------------------------------------------------- + // OnWriteValueAsync (full) - cache update on success + // ----------------------------------------------------------------- + + [Test] + public async Task WriteAttributeAsyncRoutesValueWritesToFullAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + double observedValue = double.NaN; + + v.OnWriteValueAsync = (c, n, range, value, ct) => + { + observedValue = value.GetDouble(); + return new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + }; + + var dv = new DataValue + { + WrappedValue = new Variant(99.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(observedValue, Is.EqualTo(99.5)); + Assert.That(v.Value.GetDouble(), Is.EqualTo(99.5), + "BaseVariableState should mirror the value into its cache after a successful async write."); + } + + [Test] + public async Task WriteAttributeAsyncSkipsCacheOnHandlerFailure() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 1.0; + + v.OnWriteValueAsync = (c, n, range, value, ct) => + new ValueTask( + new AttributeWriteResult(StatusCodes.BadInvalidArgument)); + + var dv = new DataValue + { + WrappedValue = new Variant(99.5), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadInvalidArgument)); + Assert.That(v.Value.GetDouble(), Is.EqualTo(1.0), + "Cache must NOT advance when the async hook reports a Bad status."); + } + + // ----------------------------------------------------------------- + // OnSimpleWriteValueAsync + // ----------------------------------------------------------------- + + [Test] + public async Task WriteAttributeAsyncRoutesValueWritesToSimpleAsyncSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + + v.OnSimpleWriteValueAsync = (c, n, value, ct) => new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(v.Value.GetDouble(), Is.EqualTo(11.0)); + } + + [Test] + public async Task WriteAttributeAsyncRejectsIndexRangeOnSimpleSlot() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.OnSimpleWriteValueAsync = (c, n, value, ct) => new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + + var range = NumericRange.Parse("0:3"); + var dv = new DataValue + { + WrappedValue = new Variant(11.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, range, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadIndexRangeInvalid), + "The simple async write hook does not support index-range writes."); + } + + // ----------------------------------------------------------------- + // Fallback to sync path when no async slot is set + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncFallsBackToSyncWhenNoAsyncSlotSet() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 5.5; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetDouble(), Is.EqualTo(5.5)); + } + + [Test] + public async Task WriteAttributeAsyncFallsBackToSyncWhenNoAsyncSlotSet() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.Value = 1.0; + + var dv = new DataValue + { + WrappedValue = new Variant(2.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(v.Value.GetDouble(), Is.EqualTo(2.0)); + } + + [Test] + public async Task ReadAttributeAsyncFallsThroughForNonValueAttribute() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.Fail("OnReadValueAsync must not run for non-Value attributes."); + return new ValueTask( + new AttributeReadResult( + ServiceResult.Good, Variant.Null, StatusCodes.Good, DateTimeUtc.Now)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.DisplayName, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(dv.WrappedValue.GetLocalizedText().Text, Is.EqualTo("Var")); + } + + // ----------------------------------------------------------------- + // Access control + // ----------------------------------------------------------------- + + [Test] + public async Task ReadAttributeAsyncReturnsBadNotReadableForNoAccessLevel() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.AccessLevel = AccessLevels.None; + v.OnReadValueAsync = (c, n, range, encoding, ct) => + { + Assert.Fail("Async hook must not run when CurrentRead is denied."); + return new ValueTask(default(AttributeReadResult)); + }; + + var dv = new DataValue(); + ServiceResult result = await v.ReadAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, QualifiedName.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadNotReadable)); + } + + [Test] + public async Task WriteAttributeAsyncReturnsBadNotWritableForNoAccessLevel() + { + SystemContext ctx = CreateSystemContext(); + BaseDataVariableState v = CreateReadableVariable(); + v.AccessLevel = AccessLevels.CurrentRead; + v.OnWriteValueAsync = (c, n, range, value, ct) => + { + Assert.Fail("Async hook must not run when CurrentWrite is denied."); + return new ValueTask(default(AttributeWriteResult)); + }; + + var dv = new DataValue + { + WrappedValue = new Variant(2.0), + StatusCode = StatusCodes.Good, + SourceTimestamp = DateTimeUtc.Now + }; + + ServiceResult result = await v.WriteAttributeAsync( + ctx, Attributes.Value, NumericRange.Null, dv).ConfigureAwait(false); + + Assert.That(result.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadNotWritable)); + } + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs index ffc47416a9..62b051662f 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators.cs @@ -526,6 +526,12 @@ private static void Generate( OverrideClassName = designOptions.NodeManagerClassName, EmitFactory = designOptions.EmitNodeManagerFactory }.Emit(); + + new FluentBuilderGenerator(context) + { + OverrideManagerNamespace = designOptions.NodeManagerNamespace, + OverrideManagerClassName = designOptions.NodeManagerClassName + }.Emit(); } if (context.Options?.OmitObjectTypeProxies != true) diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs new file mode 100644 index 0000000000..52246e6366 --- /dev/null +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -0,0 +1,1614 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Opc.Ua.Schema.Model; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Emits a typed, intellisense-aware fluent builder facade for the + /// model's predefined-instance tree. Drops in alongside the existing + /// output so users can write + /// b.Boilers.Boiler__1.DrumX001.LIX001.Output.OnRead(...) + /// inside their Configure partial. + /// + /// + /// + /// One typed wrapper class is emitted per instance design that lives + /// in the manager's predefined-instance tree. Each wrapper exposes + /// typed accessor properties for its direct children (resolved via + /// the design Hierarchy): + /// + /// + /// variables → IVariableBuilder<T>; + /// methods → a per-method wrapper class with typed + /// OnCall(Func<…>) overloads; + /// objects → the matching child instance wrapper. + /// + /// + /// A top-level I{Manager}NodeManagerBuilder interface (extending + /// Opc.Ua.Server.Fluent.INodeManagerBuilder) carries one + /// accessor per top-level predefined instance. The + /// node-manager generator emits both + /// Configure(INodeManagerBuilder) and + /// Configure(I{Manager}NodeManagerBuilder) partials so existing + /// implementations continue to work unchanged. + /// + /// + internal sealed class FluentBuilderGenerator : IGenerator + { + /// + /// Optional override for the manager class name (matches the value + /// passed to ). + /// Defaults to {Prefix}NodeManager. + /// + public string OverrideManagerClassName { get; init; } + + /// + /// Optional override for the manager namespace (matches the value + /// passed to ). + /// Defaults to the model's target-namespace prefix. + /// + public string OverrideManagerNamespace { get; init; } + + /// + /// Initializes a new . + /// + public FluentBuilderGenerator(IGeneratorContext context) + { + m_context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public IEnumerable Emit() + { + string nsPrefix = m_context.ModelDesign.TargetNamespace.Prefix; + string typeStem = nsPrefix.Replace(".", string.Empty, StringComparison.Ordinal); + string outputNamespace = string.IsNullOrEmpty(OverrideManagerNamespace) + ? nsPrefix + : OverrideManagerNamespace; + string managerClassName = string.IsNullOrEmpty(OverrideManagerClassName) + ? typeStem + "NodeManager" + : OverrideManagerClassName; + string interfaceName = "I" + managerClassName + "Builder"; + string typedBuilderClassName = managerClassName + "TypedBuilder"; + + // Discover the top-level predefined instances. We always emit + // the typed manager interface and proxy class — even for + // models with no predefined instances — because the + // source-generated NodeManager partial unconditionally + // references them. + List roots = GetTopLevelInstances(); + + // Walk every instance under each root and assemble the wrapper + // class metadata. m_wrappers is keyed by SymbolicId path so + // child accessors can resolve their target wrapper by id. + m_wrappers = []; + m_methodWrappers = []; + foreach (InstanceDesign root in roots) + { + CollectInstanceWrappers(root); + } + + // Detect naming collisions per containing wrapper. Fail with a + // diagnostic (mirrored as an InvalidOperationException since we + // are running outside the Roslyn diagnostic pipeline here). + ValidateNoCollisions(); + + // Wire each wrapper to its direct child object/method + // wrappers so the recursive emitter can walk the tree + // depth-first and emit nested type declarations. + LinkChildWrappers(); + + string fileStem = string.IsNullOrEmpty(OverrideManagerClassName) + ? nsPrefix + : OverrideManagerClassName; + string fileName = Path.Combine( + m_context.OutputFolder, + CoreUtils.Format("{0}.FluentBuilders.g.cs", fileStem)); + + using TextWriter writer = m_context.FileSystem.CreateTextWriter(fileName); + using var templateWriter = new TemplateWriter(writer); + var template = new Template(templateWriter, FluentBuilderTemplates.File); + + template.AddReplacement(Tokens.NamespacePrefix, outputNamespace); + + // Render the typed manager interface, the typed manager + // implementation, and one wrapper class per instance / method + // into the body slot. The body is emitted by a single + // load-template callback (the templating engine treats the + // returned null TemplateString as "nothing more to emit"). + object[] bodyTargets = ["body"]; + template.AddReplacement( + Tokens.ListOfTypes, + bodyTargets, + onLoad: ctx => + { + EmitManagerInterface(ctx.Out, interfaceName, roots); + EmitTypedManagerImpl( + ctx.Out, + interfaceName, + typedBuilderClassName, + managerClassName, + roots); + + // Walk top-level instance wrappers depth-first so each + // child object/method wrapper is emitted as a nested + // type inside its parent. Top-level wrappers (those + // whose parent path is empty) live at namespace scope. + foreach (InstanceWrapper top in m_wrappers.Values + .Where(w => w.ParentKey == null) + .OrderBy(w => w.LeafName, StringComparer.Ordinal)) + { + EmitInstanceWrapper(ctx.Out, top, indent: string.Empty); + } + + return null; + }); + + template.Render(); + return [fileName.AsTextFileResource()]; + } + + // ============================================================ + // Discovery + // ============================================================ + + /// + /// Returns the model's top-level instance designs — those whose + /// is null and which sit in the + /// manager's predefined-instance tree (instances, not types). + /// + private List GetTopLevelInstances() + { + var result = new List(); + foreach (NodeDesign node in m_context.ModelDesign.GetNodeDesigns()) + { + if (node is not InstanceDesign instance) + { + continue; + } + if (instance.Parent != null) + { + continue; + } + if (m_context.ModelDesign.IsExcluded(instance)) + { + continue; + } + if (instance.NotInAddressSpace) + { + continue; + } + if (instance.IsDeclaration) + { + continue; + } + if (instance.Hierarchy == null) + { + continue; + } + result.Add(instance); + } + result.Sort(static (a, b) => string.CompareOrdinal( + a.SymbolicId?.Name, + b.SymbolicId?.Name)); + return result; + } + + /// + /// Walks 's hierarchy and registers a + /// wrapper for every non-method instance plus a method wrapper for + /// every method. + /// + private void CollectInstanceWrappers(InstanceDesign root) + { + if (root.Hierarchy == null) + { + return; + } + + // Build a parent-path → list of direct children mapping. The + // hierarchy keys are constructed by joining segments with + // ; segment names themselves + // can contain underscores (e.g. Boiler__1 for the + // browse name Boiler #1) so we cannot naively split on + // the path char. Instead we derive the parent path by stripping + // the child node's own SymbolicName.Name (plus the + // separator) from its RelativePath — the same trick + // used by . + var directChildren = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair entry in root.Hierarchy.Nodes) + { + string path = entry.Key ?? string.Empty; + if (path.Length == 0) + { + continue; + } + HierarchyNode hnode = entry.Value; + if (hnode?.Instance?.SymbolicName?.Name is not string segment || + segment.Length == 0) + { + continue; + } + int trimLen = segment.Length + 1; + string parent; + if (path.Length == segment.Length) + { + // Top-level child of the root (no preceding parent path). + parent = string.Empty; + } + else if (path.Length < trimLen) + { + // Malformed — skip. + continue; + } + else + { + parent = path[..^trimLen]; + } + if (!directChildren.TryGetValue(parent, out List bucket)) + { + bucket = []; + directChildren[parent] = bucket; + } + bucket.Add(hnode); + } + + // Emit one wrapper per non-method instance (root + descendants). + foreach (KeyValuePair entry in root.Hierarchy.Nodes) + { + if (entry.Value?.Instance is not NodeDesign nd) + { + continue; + } + if (m_context.ModelDesign.IsExcluded(nd)) + { + continue; + } + // Skip method instances — methods get their own wrapper + // class, registered separately below. + if (entry.Value.Instance is MethodDesign method) + { + RegisterMethodWrapper(root, entry.Key, method); + continue; + } + // Skip variables — they are surfaced as IVariableBuilder + // accessors on their parent wrapper, not as standalone + // wrapper classes. + if (entry.Value.Instance is VariableDesign) + { + continue; + } + if (entry.Value.Instance is not ObjectDesign) + { + continue; + } + RegisterInstanceWrapper(root, entry.Key, entry.Value, directChildren); + } + } + + /// + /// Registers an InstanceWrapper for the supplied + /// node. Idempotent — if the wrapper has already been registered + /// (e.g. via a sibling root) the existing entry is reused. + /// + private void RegisterInstanceWrapper( + InstanceDesign root, + string relativePath, + HierarchyNode hnode, + Dictionary> directChildren) + { + string key = ComposeKey(root, relativePath); + if (m_wrappers.ContainsKey(key)) + { + return; + } + + string leafName = ResolveLeafName(root, relativePath, hnode.Instance); + string parentKey = ResolveParentKey(root, relativePath, leafName); + string className = ComposeWrapperClassName(leafName, suffix: "Builder"); + string nsUri = ResolveNodeBrowseNamespace(hnode.Instance); + var wrapper = new InstanceWrapper + { + Key = key, + ClassName = className, + LeafName = leafName, + ParentKey = parentKey, + NodeStateType = ResolveStateClrType(hnode.Instance), + BrowseNamespaceUri = nsUri, + SupportsPublish = QualifiesAsEventNotifier(hnode.Instance), + Children = [], + ChildObjectKeys = [], + ChildMethodKeys = [] + }; + + // Children resolved relative to this node. + if (directChildren.TryGetValue(relativePath, out List kids)) + { + foreach (HierarchyNode kid in kids) + { + if (kid?.Instance == null || m_context.ModelDesign.IsExcluded(kid.Instance)) + { + continue; + } + string childKey = ComposeKey(root, kid.RelativePath); + string accessorName = GetAccessorName(kid.Instance); + string browseName = GetBrowseName(kid.Instance); + string browseNsUri = ResolveNodeBrowseNamespace(kid.Instance); + string childLeaf = ResolveLeafName(root, kid.RelativePath, kid.Instance); + var child = new ChildAccessor + { + AccessorName = accessorName, + BrowseName = browseName, + BrowseNamespaceUri = browseNsUri + }; + + switch (kid.Instance) + { + case VariableDesign var: + child.Kind = ChildKind.Variable; + child.ValueClrType = GetVariableValueClrType(var); + break; + case MethodDesign: + child.Kind = ChildKind.Method; + // Lexical scope: methods are emitted as nested + // classes inside the parent wrapper, so the + // simple leaf name resolves correctly here. + child.WrapperClassName = ComposeWrapperClassName( + childLeaf, suffix: "MethodBuilder"); + break; + case ObjectDesign: + child.Kind = ChildKind.Object; + // Lexical scope: object child wrappers are + // nested classes; the simple leaf name resolves + // through the enclosing parent wrapper. + child.WrapperClassName = ComposeWrapperClassName( + childLeaf, suffix: "Builder"); + child.ChildKey = childKey; + child.ChildStateType = ResolveStateClrType(kid.Instance); + break; + default: + // Properties / unknowns: skip for v1. + continue; + } + wrapper.Children.Add(child); + } + } + + m_wrappers[key] = wrapper; + } + + /// + /// Registers a MethodWrapper for the supplied method + /// design. Resolves typed argument shapes from the method's + /// InputArguments/OutputArguments. + /// + private void RegisterMethodWrapper( + InstanceDesign root, + string relativePath, + MethodDesign method) + { + string key = ComposeKey(root, relativePath); + if (m_methodWrappers.ContainsKey(key)) + { + return; + } + + string leafName = ResolveLeafName(root, relativePath, method); + string parentKey = ResolveParentKey(root, relativePath, leafName); + string className = ComposeWrapperClassName(leafName, suffix: "MethodBuilder"); + var wrapper = new MethodWrapper + { + Key = key, + ClassName = className, + LeafName = leafName, + ParentKey = parentKey, + Inputs = method.InputArguments ?? [], + Outputs = method.OutputArguments ?? [] + }; + m_methodWrappers[key] = wrapper; + } + + // ============================================================ + // Validation + // ============================================================ + + /// + /// Wires each wrapper to its direct child object/method wrappers + /// so the recursive emitter can walk the tree depth-first. Sorts + /// siblings by leaf name (ordinal) so generation is deterministic. + /// + private void LinkChildWrappers() + { + foreach (InstanceWrapper child in m_wrappers.Values) + { + if (child.ParentKey == null) + { + continue; + } + if (m_wrappers.TryGetValue(child.ParentKey, out InstanceWrapper parent)) + { + parent.ChildObjectKeys.Add(child.Key); + } + } + foreach (MethodWrapper method in m_methodWrappers.Values) + { + if (method.ParentKey == null) + { + continue; + } + if (m_wrappers.TryGetValue(method.ParentKey, out InstanceWrapper parent)) + { + parent.ChildMethodKeys.Add(method.Key); + } + } + foreach (InstanceWrapper wrapper in m_wrappers.Values) + { + wrapper.ChildObjectKeys.Sort((a, b) => string.CompareOrdinal( + m_wrappers[a].LeafName, + m_wrappers[b].LeafName)); + wrapper.ChildMethodKeys.Sort((a, b) => string.CompareOrdinal( + m_methodWrappers[a].LeafName, + m_methodWrappers[b].LeafName)); + } + } + + /// + /// Verifies that no two children of the same wrapper sanitize to + /// the same C# accessor identifier. + /// + private void ValidateNoCollisions() + { + foreach (InstanceWrapper wrapper in m_wrappers.Values) + { + var seen = new Dictionary(StringComparer.Ordinal); + foreach (ChildAccessor child in wrapper.Children) + { + if (seen.TryGetValue(child.AccessorName, out ChildAccessor existing)) + { + throw new InvalidOperationException(CoreUtils.Format( + "Fluent builder generation: children '{0}' and '{1}' on '{2}' both sanitize to the same C# accessor '{3}'. Rename one of the children in the design.", + existing.BrowseName, + child.BrowseName, + wrapper.ClassName, + child.AccessorName)); + } + seen[child.AccessorName] = child; + } + } + } + + // ============================================================ + // Emission + // ============================================================ + + /// + /// Emits the typed manager interface declaration with one accessor + /// per top-level predefined instance. + /// + private void EmitManagerInterface( + ITemplateWriter writer, + string interfaceName, + IReadOnlyList roots) + { + writer.WriteLine(); + writer.WriteLine("/// "); + writer.WriteLine( + "/// Source-generated typed sibling of" + + " "); + writer.WriteLine( + "/// that surfaces typed accessors for the predefined-instance tree."); + writer.WriteLine("/// "); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal interface {0} : global::Opc.Ua.Server.Fluent.INodeManagerBuilder", interfaceName); + writer.WriteLine("{"); + foreach (InstanceDesign root in roots) + { + string accessor = GetAccessorName(root); + string wrapperKey = ComposeKey(root, string.Empty); + if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper)) + { + continue; + } + writer.WriteLine(" /// Resolves the predefined instance {0}.", + GetBrowseName(root)); + writer.WriteLine(" {0} {1} {{ get; }}", wrapper.ClassName, accessor); + } + writer.WriteLine("}"); + } + + /// + /// Emits the internal sealed implementation that wraps the runtime + /// fluent NodeManagerBuilder and adds typed top-level + /// accessors. + /// + private void EmitTypedManagerImpl( + ITemplateWriter writer, + string interfaceName, + string className, + string managerClassName, + IReadOnlyList roots) + { + writer.WriteLine(); + writer.WriteLine("/// "); + writer.WriteLine( + "/// Internal proxy that wraps the runtime fluent" + + " NodeManagerBuilder"); + writer.WriteLine("/// to surface the typed facade.", interfaceName); + writer.WriteLine("/// "); + writer.WriteLine( + "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]", + ToolName, + ToolVersion); + writer.WriteLine("internal sealed class {0} : {1}", className, interfaceName); + writer.WriteLine("{"); + writer.WriteLine( + " private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner;"); + writer.WriteLine(); + writer.WriteLine( + " internal {0}(global::Opc.Ua.Server.Fluent.INodeManagerBuilder inner)", + className); + writer.WriteLine(" {"); + writer.WriteLine( + " __inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));"); + writer.WriteLine(" }"); + writer.WriteLine(); + + // Pass-through INodeManagerBuilder members. Each line is broken + // explicitly to satisfy the repository line-length analyzer + // (RCS0056, max 120 chars). + EmitPassThroughProperty(writer, "global::Opc.Ua.ISystemContext", "Context"); + EmitPassThroughProperty(writer, "global::Opc.Ua.Server.IAsyncNodeManager", "NodeManager"); + EmitPassThroughProperty(writer, "global::Opc.Ua.Server.Fluent.IFluentDispatcher", "Dispatcher"); + + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "string browsePath", "browsePath"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "string browsePath", "browsePath"); + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "global::Opc.Ua.NodeId nodeId", "nodeId"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "Node", + "global::Opc.Ua.NodeId nodeId", "nodeId"); + + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId"); + EmitPassThroughMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId"); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName"); + + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "Variable", + "string browsePath", "browsePath", typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "Variable", + "global::Opc.Ua.NodeId nodeId", "nodeId", typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "VariableFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId", + typeArg: "TValue", noConstraint: true); + EmitPassThroughGenericMethod(writer, + "global::Opc.Ua.Server.Fluent.IVariableBuilder", "VariableFromTypeId", + "global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName", + "typeDefinitionId, browseName", + typeArg: "TValue", noConstraint: true); + + // Typed top-level accessors. + foreach (InstanceDesign root in roots) + { + string accessor = GetAccessorName(root); + string wrapperKey = ComposeKey(root, string.Empty); + if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper)) + { + continue; + } + string browseName = GetBrowseName(root); + string nsUri = ResolveNodeBrowseNamespace(root); + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}", wrapper.ClassName, accessor); + writer.WriteLine(" {"); + writer.WriteLine(" get"); + writer.WriteLine(" {"); + writer.WriteLine(" ushort __ns = __inner.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");", + EscapeStringLiteral(nsUri)); + writer.WriteLine(" return new {0}(__inner.Node<{1}>(new global::Opc.Ua.NodeId({2}, __ns)));", + wrapper.ClassName, + wrapper.NodeStateType, + EmitNodeIdConstructorArg(root)); + writer.WriteLine(" }"); + writer.WriteLine(" }"); + } + + writer.WriteLine("}"); + } + + /// + /// Emits a {Type} {Name} => __inner.{Name}; property forwarder + /// across two output lines so the result fits within RCS0056's 120 + /// character ceiling for any plausible runtime type name. + /// + private static void EmitPassThroughProperty( + ITemplateWriter writer, + string returnType, + string memberName) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}", returnType, memberName); + writer.WriteLine(" => __inner.{0};", memberName); + } + + /// + /// Emits a non-generic pass-through method forwarder broken across + /// multiple lines (signature, opener, body, closer). + /// + private static void EmitPassThroughMethod( + ITemplateWriter writer, + string returnType, + string memberName, + string parameterList, + string callArguments) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}({2})", returnType, memberName, parameterList); + writer.WriteLine(" => __inner.{0}({1});", memberName, callArguments); + } + + /// + /// Emits a generic pass-through method forwarder broken across + /// multiple lines. Defaults to where TState : NodeState; + /// pass = true to suppress + /// the constraint and to use a custom + /// type-parameter name (e.g. TValue). + /// + private static void EmitPassThroughGenericMethod( + ITemplateWriter writer, + string returnType, + string memberName, + string parameterList, + string callArguments, + string typeArg = "TState", + bool noConstraint = false) + { + writer.WriteLine(); + writer.WriteLine(" /// "); + writer.WriteLine(" public {0} {1}<{2}>({3})", + returnType, memberName, typeArg, parameterList); + if (!noConstraint) + { + writer.WriteLine(" where {0} : global::Opc.Ua.NodeState", typeArg); + } + writer.WriteLine(" => __inner.{0}<{1}>({2});", + memberName, typeArg, callArguments); + } + + /// + /// Emits one wrapper class for an + /// describing a non-method instance. The class is rendered at the + /// indentation depth supplied by ; child + /// object/method wrappers are emitted recursively as nested types + /// one level deeper. + /// + private void EmitInstanceWrapper( + ITemplateWriter writer, + InstanceWrapper wrapper, + string indent) + { + string memberIndent = indent + Indent; + + writer.WriteLine(); + writer.WriteLine("{0}/// Typed wrapper for the predefined instance.", indent); + writer.WriteLine( + "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]", + indent, + ToolName, + ToolVersion); + writer.WriteLine("{0}internal sealed class {1}", indent, wrapper.ClassName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> __node;", + memberIndent, wrapper.NodeStateType); + writer.WriteLine(); + writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder<{2}> node)", + memberIndent, wrapper.ClassName, wrapper.NodeStateType); + writer.WriteLine("{0}{{", memberIndent); + writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));", + memberIndent + Indent); + writer.WriteLine("{0}}}", memberIndent); + writer.WriteLine(); + writer.WriteLine("{0}/// Underlying typed node builder.", memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Builder => __node;", + memberIndent, wrapper.NodeStateType); + writer.WriteLine(); + writer.WriteLine("{0}/// Resolved underlying node.", memberIndent); + writer.WriteLine("{0}public {1} Node => __node.Node;", memberIndent, wrapper.NodeStateType); + + foreach (ChildAccessor child in wrapper.Children) + { + EmitChildAccessor(writer, child, memberIndent); + } + + if (wrapper.SupportsPublish) + { + EmitPublishOverloads(writer, wrapper, memberIndent); + } + + // Emit the nested method wrappers, then the nested object + // wrappers. Sibling order is leaf-name ordinal (set up by + // LinkChildWrappers) so generation is deterministic. + foreach (string methodKey in wrapper.ChildMethodKeys) + { + if (m_methodWrappers.TryGetValue(methodKey, out MethodWrapper nestedMethod)) + { + EmitMethodWrapper(writer, nestedMethod, memberIndent); + } + } + foreach (string childKey in wrapper.ChildObjectKeys) + { + if (m_wrappers.TryGetValue(childKey, out InstanceWrapper nested)) + { + EmitInstanceWrapper(writer, nested, memberIndent); + } + } + + writer.WriteLine("{0}}}", indent); + } + + /// + /// Emits one accessor property on the parent wrapper at the + /// supplied (the parent's member + /// indent). + /// + private void EmitChildAccessor( + ITemplateWriter writer, + ChildAccessor child, + string indent) + { + string bodyIndent = indent + Indent; + string innerIndent = bodyIndent + Indent; + + writer.WriteLine(); + switch (child.Kind) + { + case ChildKind.Variable: + writer.WriteLine("{0}/// Typed accessor for variable child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.IVariableBuilder<{1}> {2}", + indent, child.ValueClrType, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return __node.Variable<{1}>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns));", + innerIndent, + child.ValueClrType, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); + break; + case ChildKind.Method: + writer.WriteLine("{0}/// Typed accessor for method child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return new {1}(__node.Child(new global::Opc.Ua.QualifiedName(\"{2}\", __ns)));", + innerIndent, + child.WrapperClassName, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); + break; + case ChildKind.Object: + writer.WriteLine("{0}/// Typed accessor for object child {1}.", + indent, child.BrowseName); + writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}get", bodyIndent); + writer.WriteLine("{0}{{", bodyIndent); + writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");", + innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri)); + writer.WriteLine("{0}return new {1}(__node.Child<{2}>(new global::Opc.Ua.QualifiedName(\"{3}\", __ns)));", + innerIndent, + child.WrapperClassName, + child.ChildStateType, + EscapeStringLiteral(child.BrowseName)); + writer.WriteLine("{0}}}", bodyIndent); + writer.WriteLine("{0}}}", indent); + break; + } + } + + /// + /// Emits the typed Publish<TEvent> overloads on a + /// notifier-capable wrapper. Both overloads forward to + /// Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions; + /// the wrapper's underlying node-state type is bound as the + /// TNotifier type argument so callers don't need to spell + /// it out. The shape mirrors the extension's two overloads (direct + /// stream + factory). + /// + private static void EmitPublishOverloads( + ITemplateWriter writer, + InstanceWrapper wrapper, + string indent) + { + writer.WriteLine(); + writer.WriteLine( + "{0}/// Registers an event source for this notifier; lazy by default. See for activation tuning.", + indent); + writer.WriteLine( + "{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish(", + indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::System.Collections.Generic.IAsyncEnumerable source,", + indent + Indent); + writer.WriteLine( + "{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)", + indent + Indent); + writer.WriteLine( + "{0}where TEvent : global::Opc.Ua.BaseEventState", + indent + Indent); + writer.WriteLine( + "{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, source, options);", + indent + Indent, wrapper.NodeStateType); + + writer.WriteLine(); + writer.WriteLine( + "{0}/// Registers a factory-based event source for this notifier; the factory runs on each activation. See for activation tuning.", + indent); + writer.WriteLine( + "{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish(", + indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::System.Func<{1}, global::Opc.Ua.ISystemContext, global::System.Threading.CancellationToken, global::System.Collections.Generic.IAsyncEnumerable> factory,", + indent + Indent, wrapper.NodeStateType); + writer.WriteLine( + "{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)", + indent + Indent); + writer.WriteLine( + "{0}where TEvent : global::Opc.Ua.BaseEventState", + indent + Indent); + writer.WriteLine( + "{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, factory, options);", + indent + Indent, wrapper.NodeStateType); + } + + /// + /// Emits one wrapper class for a method instance with typed + /// OnCall overloads at the supplied . + /// + private void EmitMethodWrapper( + ITemplateWriter writer, + MethodWrapper method, + string indent) + { + string memberIndent = indent + Indent; + + writer.WriteLine(); + writer.WriteLine("{0}/// Typed method-call wrapper for the predefined method.", indent); + writer.WriteLine( + "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]", + indent, + ToolName, + ToolVersion); + writer.WriteLine("{0}internal sealed class {1}", indent, method.ClassName); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder __node;", + memberIndent); + writer.WriteLine(); + writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder node)", + memberIndent, method.ClassName); + writer.WriteLine("{0}{{", memberIndent); + writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));", + memberIndent + Indent); + writer.WriteLine("{0}}}", memberIndent); + writer.WriteLine(); + writer.WriteLine("{0}/// Underlying typed node builder. Use to drop into the non-typed fluent surface.", + memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder Builder => __node;", + memberIndent); + writer.WriteLine(); + writer.WriteLine("{0}/// Resolved underlying method state.", memberIndent); + writer.WriteLine("{0}public global::Opc.Ua.MethodState Node => __node.Node;", memberIndent); + + // Sync typed OnCall. + EmitMethodOnCall(writer, method, async: false, indent: memberIndent); + // Async typed OnCall. + EmitMethodOnCall(writer, method, async: true, indent: memberIndent); + + writer.WriteLine("{0}}}", indent); + } + + /// + /// Emits one OnCall overload for a method wrapper. The overload + /// shape is determined by the method's argument signature plus the + /// requested sync/async flavor. is the + /// member indent of the enclosing method wrapper. + /// + private void EmitMethodOnCall( + ITemplateWriter writer, + MethodWrapper method, + bool async, + string indent) + { + string bodyIndent = indent + Indent; + string lambdaIndent = bodyIndent + Indent; + + string targetNamespace = m_context.ModelDesign.TargetNamespace.Value; + Namespace[] namespaces = m_context.ModelDesign.Namespaces; + Parameter[] inputs = method.Inputs; + Parameter[] outputs = method.Outputs; + + string returnTypeAnnotation = GetReturnTypeAnnotation(outputs, targetNamespace, namespaces); + string handlerType; + if (async) + { + if (inputs.Length == 0 && outputs.Length == 0) + { + handlerType = "global::System.Func"; + } + else if (outputs.Length == 0) + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}, global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask>", + FormatInputTypeList(inputs, targetNamespace, namespaces)); + } + else + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}{1}global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask{2}>", + FormatInputTypeList(inputs, targetNamespace, namespaces), + inputs.Length == 0 ? string.Empty : ", ", + returnTypeAnnotation); + } + } + else + { + if (inputs.Length == 0 && outputs.Length == 0) + { + handlerType = "global::System.Action"; + } + else if (outputs.Length == 0) + { + handlerType = CoreUtils.Format( + "global::System.Action<{0}>", + FormatInputTypeList(inputs, targetNamespace, namespaces)); + } + else + { + handlerType = CoreUtils.Format( + "global::System.Func<{0}{1}{2}>", + FormatInputTypeList(inputs, targetNamespace, namespaces), + inputs.Length == 0 ? string.Empty : ", ", + StripAngleBrackets(returnTypeAnnotation, defaultIfEmpty: "void")); + } + } + + writer.WriteLine(); + writer.WriteLine("{0}/// Wires the method-call handler ({1}).", + indent, async ? "async" : "sync"); + writer.WriteLine("{0}public {1} OnCall({2} handler)", indent, method.ClassName, handlerType); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}if (handler == null) throw new global::System.ArgumentNullException(nameof(handler));", + bodyIndent); + if (async) + { + writer.WriteLine("{0}__node.OnCall(async (", bodyIndent); + writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.ArrayOf __inputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Collections.Generic.List __outputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Threading.CancellationToken __ct) =>", lambdaIndent); + writer.WriteLine("{0}{{", bodyIndent); + } + else + { + writer.WriteLine("{0}__node.OnCall((", bodyIndent); + writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent); + writer.WriteLine("{0}global::Opc.Ua.ArrayOf __inputs,", lambdaIndent); + writer.WriteLine("{0}global::System.Collections.Generic.List __outputs) =>", lambdaIndent); + writer.WriteLine("{0}{{", bodyIndent); + } + + // Validate input arg count. + if (inputs.Length > 0) + { + writer.WriteLine("{0}if (__inputs.Count < {1})", lambdaIndent, inputs.Length); + writer.WriteLine("{0}{{", lambdaIndent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);", + lambdaIndent + Indent); + writer.WriteLine("{0}}}", lambdaIndent); + } + + // Unpack inputs. + for (int ii = 0; ii < inputs.Length; ii++) + { + EmitInputUnpack(writer, inputs[ii], ii, targetNamespace, namespaces, lambdaIndent); + } + + // Invoke user handler. + if (async) + { + if (outputs.Length == 0) + { + writer.Write("{0}await handler(", lambdaIndent); + EmitInputArgPassThrough(writer, inputs, withCt: true); + writer.WriteLine(").ConfigureAwait(false);"); + } + else + { + writer.Write("{0}var __r = await handler(", lambdaIndent); + EmitInputArgPassThrough(writer, inputs, withCt: true); + writer.WriteLine(").ConfigureAwait(false);"); + } + } + else + { + if (outputs.Length == 0) + { + writer.Write("{0}handler(", lambdaIndent); + EmitInputArgPassThrough(writer, inputs, withCt: false); + writer.WriteLine(");"); + } + else + { + writer.Write("{0}var __r = handler(", lambdaIndent); + EmitInputArgPassThrough(writer, inputs, withCt: false); + writer.WriteLine(");"); + } + } + + // Marshal outputs. + for (int ii = 0; ii < outputs.Length; ii++) + { + EmitOutputBox(writer, outputs[ii], ii, outputs.Length, lambdaIndent); + } + + writer.WriteLine("{0}return global::Opc.Ua.ServiceResult.Good;", lambdaIndent); + writer.WriteLine("{0}}});", bodyIndent); + writer.WriteLine("{0}return this;", bodyIndent); + writer.WriteLine("{0}}}", indent); + } + + /// + /// Emits the typed unpack code for a single input argument. Mirrors + /// the logic in ObjectTypeProxyGenerator. + /// is the lambda-body indent of the surrounding OnCall. + /// + private static void EmitInputUnpack( + ITemplateWriter writer, + Parameter input, + int index, + string targetNamespace, + Namespace[] namespaces, + string indent) + { + string innerIndent = indent + Indent; + string typeName = input.DataTypeNode.GetMethodArgumentTypeAsCode( + input.ValueRank, + targetNamespace, + namespaces, + input.IsOptional); + string local = "__a" + index; + switch (input.DataTypeNode.BasicDataType) + { + case BasicDataType.UserDefined: + writer.WriteLine("{0}if (!__inputs[{1}].TryGetStructure(out {2} {3}))", + indent, index, typeName, local); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);", + innerIndent); + writer.WriteLine("{0}}}", indent); + break; + case BasicDataType.BaseDataType when input.ValueRank == ValueRank.Scalar: + writer.WriteLine("{0}{1} {2} = __inputs[{3}];", indent, typeName, local, index); + break; + default: + writer.WriteLine("{0}if (!__inputs[{1}].TryGetValue(out {2} {3}))", + indent, index, typeName, local); + writer.WriteLine("{0}{{", indent); + writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);", + innerIndent); + writer.WriteLine("{0}}}", indent); + break; + } + } + + /// + /// Emits the comma-separated list of unpacked input locals (plus + /// optional CancellationToken) for the user-handler invocation. + /// + private static void EmitInputArgPassThrough( + ITemplateWriter writer, + Parameter[] inputs, + bool withCt) + { + for (int ii = 0; ii < inputs.Length; ii++) + { + if (ii > 0) + { + writer.Write(", "); + } + writer.Write("__a" + ii); + } + if (withCt) + { + if (inputs.Length > 0) + { + writer.Write(", "); + } + writer.Write("__ct"); + } + } + + /// + /// Emits the boxing code for a single output argument. For multi- + /// output methods the user returns a ValueTuple and we + /// destructure by field name (Item1, Item2, …). + /// is the lambda-body indent of the + /// surrounding OnCall. + /// + /// + /// The base dispatcher + /// pre-populates the outputs list with one default-valued + /// per declared output argument + /// before invoking the user handler, so the wrapper assigns + /// boxed values by index rather than appending to avoid + /// double-counting outputs at the wire. + /// + private static void EmitOutputBox( + ITemplateWriter writer, + Parameter output, + int index, + int totalOutputs, + string indent) + { + string source; + if (totalOutputs == 1) + { + source = "__r"; + } + else + { + source = "__r.Item" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + string indexLiteral = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + + switch (output.DataTypeNode.BasicDataType) + { + case BasicDataType.UserDefined: + writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.FromStructure({2});", indent, indexLiteral, source); + break; + case BasicDataType.BaseDataType when output.ValueRank == ValueRank.Scalar: + writer.WriteLine("{0}__outputs[{1}] = {2};", indent, indexLiteral, source); + break; + default: + writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.From({2});", indent, indexLiteral, source); + break; + } + } + + /// + /// Returns the ValueTask<…> suffix used in the method's + /// async return type. Mirrors + /// verbatim. + /// + private static string GetReturnTypeAnnotation( + Parameter[] outputs, + string targetNamespace, + Namespace[] namespaces) + { + if (outputs.Length == 0) + { + return string.Empty; + } + if (outputs.Length == 1) + { + return CoreUtils.Format( + "<{0}>", + outputs[0].DataTypeNode.GetMethodArgumentTypeAsCode( + outputs[0].ValueRank, + targetNamespace, + namespaces, + outputs[0].IsOptional)); + } + var sb = new System.Text.StringBuilder(); + sb.Append("<("); + for (int ii = 0; ii < outputs.Length; ii++) + { + if (ii > 0) + { + sb.Append(", "); + } + sb.Append(outputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode( + outputs[ii].ValueRank, + targetNamespace, + namespaces, + outputs[ii].IsOptional)); + sb.Append(" Item" + (ii + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + sb.Append(")>"); + return sb.ToString(); + } + + private static string StripAngleBrackets(string returnTypeAnnotation, string defaultIfEmpty) + { + if (string.IsNullOrEmpty(returnTypeAnnotation)) + { + return defaultIfEmpty; + } + // Strip leading '<' and trailing '>'. + return returnTypeAnnotation[1..^1]; + } + + private static string FormatInputTypeList( + Parameter[] inputs, + string targetNamespace, + Namespace[] namespaces) + { + var sb = new System.Text.StringBuilder(); + for (int ii = 0; ii < inputs.Length; ii++) + { + if (ii > 0) + { + sb.Append(", "); + } + sb.Append(inputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode( + inputs[ii].ValueRank, + targetNamespace, + namespaces, + inputs[ii].IsOptional)); + } + return sb.ToString(); + } + + // ============================================================ + // Helpers + // ============================================================ + + /// + /// Returns the wrapper-key string used to deduplicate wrappers and + /// to compose CLR class names. Combines the root's symbolic id + /// with the relative path from the root. + /// + private static string ComposeKey(InstanceDesign root, string relativePath) + { + string rootId = root?.SymbolicId?.Name ?? string.Empty; + if (string.IsNullOrEmpty(relativePath)) + { + return rootId; + } + return rootId + "_" + relativePath; + } + + /// + /// Returns the simple leaf name (the last segment of the relative + /// path) used as the C# class name's stem. Honors the convention + /// that segment names themselves can contain underscores so we + /// rely on the instance's SymbolicName.Name rather than + /// splitting on NodeDesign.PathChar. For the root + /// instance (empty ) the leaf is + /// the root's own SymbolicId.Name. + /// + private static string ResolveLeafName( + InstanceDesign root, + string relativePath, + NodeDesign instance) + { + if (string.IsNullOrEmpty(relativePath)) + { + return root?.SymbolicId?.Name ?? string.Empty; + } + string symbolicName = instance?.SymbolicName?.Name; + if (string.IsNullOrEmpty(symbolicName)) + { + return relativePath; + } + return symbolicName; + } + + /// + /// Returns the wrapper key of the lexical parent for the wrapper + /// at under . + /// Returns null for the root itself (lives at namespace + /// scope), the root's key for direct children, and the parent + /// path's key for deeper nesting. + /// + private static string ResolveParentKey( + InstanceDesign root, + string relativePath, + string leafName) + { + if (string.IsNullOrEmpty(relativePath)) + { + return null; + } + if (relativePath.Length == leafName.Length) + { + return ComposeKey(root, string.Empty); + } + int trim = leafName.Length + 1; + if (relativePath.Length <= trim) + { + return ComposeKey(root, string.Empty); + } + string parentPath = relativePath[..^trim]; + return ComposeKey(root, parentPath); + } + + /// + /// Returns the wrapper's CLR class name. Wrappers are emitted as + /// nested types so the simple leaf name is sufficient — full + /// dotted access is composed by the consumer through the chain + /// of typed accessor properties. + /// + private static string ComposeWrapperClassName(string leafName, string suffix) + { + return (leafName ?? string.Empty) + suffix; + } + + private static string GetAccessorName(NodeDesign node) + { + string name = node?.SymbolicName?.Name; + if (string.IsNullOrEmpty(name)) + { + return "Item"; + } + return name.ToSafeSymbolName(toLowerCamelCase: false); + } + + private static string GetBrowseName(NodeDesign node) + { + // Per the design schema the BrowseName is identical to the + // SymbolicName when it isn't explicitly overridden. Use the + // BrowseName when present so source-generated identifiers and + // the on-the-wire OPC UA browse name stay aligned. + if (!string.IsNullOrEmpty(node?.BrowseName)) + { + return node.BrowseName; + } + return node?.SymbolicName?.Name ?? string.Empty; + } + + private string ResolveNodeBrowseNamespace(NodeDesign node) + { + string ns = node?.SymbolicName?.Namespace; + if (!string.IsNullOrEmpty(ns)) + { + return ns; + } + return m_context.ModelDesign.TargetNamespace?.Value ?? string.Empty; + } + + /// + /// Returns true when the supplied node should expose typed + /// Publish<TEvent> overloads on its wrapper. A node + /// qualifies if it carries the EventNotifier=SubscribeToEvents + /// attribute (modeled as ; + /// the model validator auto-promotes nodes with forward + /// HasEventSource/HasNotifier references) or has a + /// forward GeneratesEvent/AlwaysGeneratesEvent + /// reference. Per the locked decision the typed overload is + /// emitted only on spec-accurate notifier candidates so call sites + /// don't drift from the model intent. + /// + private static bool QualifiesAsEventNotifier(NodeDesign node) + { + if (node is ObjectDesign od && od.SupportsEvents) + { + return true; + } + + Reference[] references = node?.References; + if (references == null || references.Length == 0) + { + return false; + } + + foreach (Reference reference in references) + { + if (reference == null || reference.IsInverse) + { + continue; + } + string refName = reference.ReferenceType?.Name; + if (refName == "GeneratesEvent" || + refName == "AlwaysGeneratesEvent") + { + return true; + } + } + + return false; + } + + /// + /// Returns the C# type name of the runtime + /// derivative for the supplied instance. + /// + private string ResolveStateClrType(NodeDesign node) + { + // For object instances, use BaseObjectState as the lowest common + // denominator. The user can call .Builder.As<TConcrete>() to + // narrow. + if (node is ObjectDesign) + { + return "global::Opc.Ua.BaseObjectState"; + } + if (node is MethodDesign) + { + return "global::Opc.Ua.MethodState"; + } + if (node is VariableDesign) + { + return "global::Opc.Ua.BaseDataVariableState"; + } + return "global::Opc.Ua.NodeState"; + } + + /// + /// Returns the CLR type name for a variable's value attribute, + /// inferred from the variable's DataType and ValueRank. + /// + private string GetVariableValueClrType(VariableDesign variable) + { + string targetNamespace = m_context.ModelDesign.TargetNamespace.Value; + Namespace[] namespaces = m_context.ModelDesign.Namespaces; + return variable.DataTypeNode.GetMethodArgumentTypeAsCode( + variable.ValueRank, + targetNamespace, + namespaces, + isOptional: false); + } + + /// + /// Emits the constructor argument used to materialize the + /// top-level instance's . Numeric ids preferred + /// when present; otherwise falls back to the SymbolicId string. + /// + private static string EmitNodeIdConstructorArg(InstanceDesign node) + { + if (node.NumericIdSpecified && node.NumericId != 0u) + { + return CoreUtils.Format("{0}u", + node.NumericId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrEmpty(node.StringId)) + { + return CoreUtils.Format("\"{0}\"", EscapeStringLiteral(node.StringId)); + } + // No id assigned — fall back to the SymbolicId.Name as a string id. + return CoreUtils.Format("\"{0}\"", + EscapeStringLiteral(node.SymbolicId?.Name ?? string.Empty)); + } + + private static string EscapeStringLiteral(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal); + } + + // ============================================================ + // State + // ============================================================ + + private readonly IGeneratorContext m_context; + private Dictionary m_wrappers = []; + private Dictionary m_methodWrappers = []; + + // Single nesting step. Wrappers are emitted inside the body of + // the file template at column 4; each additional nesting level + // adds one Indent. + private const string Indent = " "; + + private static string ToolName + => System.Reflection.Assembly.GetExecutingAssembly().GetName().Name; + private static string ToolVersion + => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(); + + private enum ChildKind + { + Variable, + Method, + Object + } + + private sealed class InstanceWrapper + { + public string Key; + public string ClassName; + public string LeafName; + public string ParentKey; + public string NodeStateType; + public string BrowseNamespaceUri; + public bool SupportsPublish; + public List Children; + public List ChildObjectKeys = []; + public List ChildMethodKeys = []; + } + + private sealed class ChildAccessor + { + public string AccessorName; + public string BrowseName; + public string BrowseNamespaceUri; + public ChildKind Kind; + public string ValueClrType; // Variable + public string WrapperClassName; // Method or Object + public string ChildKey; // Object — key into m_wrappers + public string ChildStateType; // Object — node state type + } + + private sealed class MethodWrapper + { + public string Key; + public string ClassName; + public string LeafName; + public string ParentKey; + public Parameter[] Inputs; + public Parameter[] Outputs; + } + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs new file mode 100644 index 0000000000..611366e13d --- /dev/null +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Top-level templates for the . + /// The bulk of the generated code is emitted directly via + /// calls from the generator (per-class, + /// per-accessor, per-method-overload bodies); only the file shell and + /// the boilerplate per-class skeleton live as template strings. + /// + internal static class FluentBuilderTemplates + { + /// + /// Single output file template. Hosts the typed manager interface + /// plus every per-type, per-instance and per-method wrapper class + /// generated for the model. + /// + public static readonly TemplateString File = TemplateString.Parse( + $$""" + {{Tokens.CodeHeader}} + + #pragma warning disable CS0419 // Ambiguous reference in cref attribute + #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + #pragma warning disable CA1707 // Identifiers should not contain underscores + #pragma warning disable CA1822 // Mark members as static + #pragma warning disable IDE0008 // Use explicit type + #pragma warning disable IDE1006 // Naming rule violation + + #nullable enable + + namespace {{Tokens.NamespacePrefix}} + { + {{Tokens.ListOfTypes}} + } + + """); + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index d3a85f2790..e692ac160c 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -58,7 +58,7 @@ namespace {{Tokens.NamespacePrefix}} /// fluent API in Opc.Ua.Server.Fluent. /// [global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")] - public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.AsyncCustomNodeManager + public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Fluent.FluentNodeManagerBase { private global::Opc.Ua.Server.Fluent.NodeManagerBuilder __m_builder; @@ -79,6 +79,14 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy /// partial void Configure(global::Opc.Ua.Server.Fluent.INodeManagerBuilder builder); + /// + /// Source-generated typed counterpart of + /// . + /// Implement in a sibling partial to wire callbacks via the + /// strongly-typed model-traversal surface generated for this manager. + /// + partial void Configure(I{{Tokens.NodeManagerClassName}}Builder builder); + /// protected override global::System.Threading.Tasks.ValueTask LoadPredefinedNodesAsync( global::Opc.Ua.ISystemContext context, @@ -106,7 +114,13 @@ public partial class {{Tokens.NodeManagerClassName}} : global::Opc.Ua.Server.Asy __FindRootByNodeId, __FindByTypeDefinitionId); + // Attach the FluentNodeManagerBase event-source registry + // to the builder so Publish(...) extensions can resolve + // it before the user's Configure partial(s) run. + AttachToBuilder(__m_builder); + Configure(__m_builder); + Configure(new {{Tokens.NodeManagerClassName}}TypedBuilder(__m_builder)); __m_builder.Seal(); foreach (global::Opc.Ua.NodeState __node in PredefinedNodes.Values) diff --git a/UA.slnx b/UA.slnx index 190138b925..d46e4ae515 100644 --- a/UA.slnx +++ b/UA.slnx @@ -4,6 +4,7 @@ +