/* ======================================================================== * 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.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Server.Fluent; namespace Boiler { /// /// Sibling partial that wires per-node callbacks for the /// source-generated using the fluent /// builder. /// /// /// The [NodeManager] attribute opts this partial class in to /// source generation: the generator emits a sibling partial that /// derives from CustomNodeManager2, owns the predefined-node /// load, and calls back into below. No /// MSBuild flag is required; the attribute alone selects the class /// and (since this project carries a single design) the single /// matching design file. /// Configure runs once, /// 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) { // (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); // (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); // (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)) .OnNodeAdded((context, node) => Server.Telemetry.CreateLogger() .LogInformation( "Boiler instance materialized: {NodeId} ({BrowseName})", node.NodeId, 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. // The trailing .Historize() opts this variable in to Part 11 // historical access; with no prior UseHistorian() call the // fluent surface lazily installs an in-memory engine and // registers it as the server-wide default. Subsequent // .Historize() calls in this manager would reuse the same // binding. builder.Boilers.Boiler__1.LCX001.Measurement .OnRead(GenerateLevelControlMeasurement) .Historize(); // (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) { var 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, NumericRange indexRange, QualifiedName dataEncoding, ref Variant value, ref StatusCode statusCode, ref DateTimeUtc timestamp) { // Slow saw-tooth between 40% and 60% to give clients something // to plot without needing a background timer in this single // file. Each Read advances the wave; suitable for a quickstart. long t = Interlocked.Increment(ref m_drumLevelTicks); value = new Variant(50.0 + (10.0 * Math.Sin(t * 0.05))); statusCode = StatusCodes.Good; timestamp = DateTimeUtc.Now; return ServiceResult.Good; } private ServiceResult GeneratePipeFlow( ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding, ref Variant value, ref StatusCode statusCode, ref DateTimeUtc timestamp) { long t = Interlocked.Increment(ref m_pipeFlowTicks); value = new Variant(100.0 + (25.0 * Math.Cos(t * 0.07))); statusCode = StatusCodes.Good; 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)); } } }