/* ======================================================================== * 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 Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Di; using Opc.Ua.Di.Server; using Opc.Ua.Di.Server.Hosting; using Opc.Ua.Machinery; using Opc.Ua.Pumps; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.NodeManager; namespace Pumps { /// /// Hand-written node manager partial that provides the infrastructure /// (constructor, address-space load, fluent builder wiring) for the /// OPC 40223 Pumps companion specification server. /// public partial class PumpNodeManager : DiNodeManager { /// /// Initialises a new without /// DI-hosting integration. /// public PumpNodeManager( IServerInternal server, ApplicationConfiguration configuration) : this(server, configuration, postSetupRunner: null) { } /// /// Initialises a new that /// participates in the DI hosting post-setup pipeline. /// public PumpNodeManager( IServerInternal server, ApplicationConfiguration configuration, IDiPostSetupRunner? postSetupRunner) : base( server, configuration, postSetupRunner, Opc.Ua.Pumps.Namespaces.Pumps, Opc.Ua.Machinery.Namespaces.Machinery) { // Base class constructor sets SystemContext.NodeIdFactory to // itself; our New() override takes over. SystemContext.NodeIdFactory = this; } /// public override NodeId New(ISystemContext context, NodeState node) { if (node is BaseInstanceState instance && instance.Parent != null) { string parentId = instance.Parent.NodeId.IdentifierAsString; return new NodeId( $"{parentId}_{instance.SymbolicName}", instance.Parent.NodeId.NamespaceIndex); } return node.NodeId; } /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) { // Compose the predefined-node tree from three source-generated // models, in dependency order: // - Opc.Ua.Di (referenced library) // - Opc.Ua.Machinery (source-generated inside this assembly) // - Opc.Ua.Pumps (source-generated inside this assembly) // No runtime XML loading — the NodeSet2 XMLs ship only as // for the source generator. The generated // AddOpcUa* extension methods are idempotent and pull in // their declared dependencies via [ModelDependencyAttribute], // so a direct chain in dependency order is sufficient. var nodes = new NodeStateCollection(); nodes.AddOpcUaDi(context); nodes.AddOpcUaMachinery(context); nodes.AddOpcUaPumps(context); return new ValueTask(nodes); } /// protected override async ValueTask OnAddressSpaceReadyAsync( CancellationToken cancellationToken) { // Configuration phase 1 (async): materialise the // predefined instances that Configure(builder) will wire. // Mirrors the synchronous fluent Configure(builder) but // runs first so the builder has typed nodes available. await ConfigureInstancesAsync(cancellationToken) .ConfigureAwait(false); // Configuration phase 2 (sync): wire fluent callbacks // against the predefined nodes. ushort nsIndex = (ushort)Server.NamespaceUris.GetIndex( Opc.Ua.Pumps.Namespaces.Pumps); this.CreateFluentBuilder(nsIndex) .Configure(Configure) .Seal(); m_logger.LogInformation( "PumpNodeManager: address space ready ({NodeCount} predefined nodes).", PredefinedNodes.Count); // PostSetupRunner is invoked automatically by the base // DiNodeManager.CreateAddressSpaceAsync after this method // returns; no manual invocation needed here. } /// /// Materialises the predefined instances that the fluent /// wiring expects to find. Runs as /// the async phase of /// before the synchronous fluent builder pass. /// /// /// Cannot use /// /// here because PumpType in OPC 40223 derives from the /// Machinery MachineType, not from the DI /// ComponentType hierarchy that /// CreateDeviceAsync requires /// (where TDevice : ComponentState). The materialisation /// therefore goes through /// /// which composes the same primitives /// ( + /// ) /// directly. /// private async ValueTask ConfigureInstancesAsync( CancellationToken cancellationToken) { ushort pumpsNs = (ushort)Server.NamespaceUris .GetIndex(Opc.Ua.Pumps.Namespaces.Pumps); var pumpBrowseName = new QualifiedName("Pump #1", pumpsNs); await MaterialisePumpInstanceAsync(pumpBrowseName, cancellationToken) .ConfigureAwait(false); } /// /// Creates a instance with the supplied /// browse name as a child of the DI DeviceSet object and /// registers it as a predefined node. The instance carries /// PumpType as its TypeDefinitionId so clients see the /// full OPC 40223 pump surface; the source-generated factory /// materialises mandatory children (Identification) automatically. /// Optional children that the fluent simulation wires /// (Operational/Measurements/{analog states}, Events with the /// SupervisionProcessFluid + SupervisionPumpOperation subtrees, /// Maintenance) are materialised here via the generator-emitted /// AddXxx(context) helpers; each new node gets a /// per-instance NodeId via /// before AddPredefinedNodeAsync recursively registers /// the entire subtree. /// private async ValueTask MaterialisePumpInstanceAsync( QualifiedName pumpBrowseName, CancellationToken cancellationToken) { NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); if (deviceSet == null) { m_logger.LogWarning( "DI DeviceSet not found — '{Name}' will not be created.", pumpBrowseName.Name); return; } // Fail-fast on duplicate. if (deviceSet.FindChild(SystemContext, pumpBrowseName) != null) { m_logger.LogDebug( "DeviceSet already contains '{Name}' — skipping recreation.", pumpBrowseName.Name); return; } Opc.Ua.Pumps.PumpState pump = SystemContext .CreateInstanceOfPumpType(deviceSet, pumpBrowseName); pump.NodeId = SystemContext.NodeIdFactory.New(SystemContext, pump); MaterialisePumpOptionalChildren(pump); // AddChild defaults ReferenceTypeId to HasComponent when null // (NodeState.AddChild line 4511-4514); ModellingRuleId defaults // to NodeId.Null on every fresh NodeState — no explicit set needed. deviceSet.AddChild(pump); // Walk the whole pump subtree assigning per-instance NodeIds // BEFORE AddPredefinedNodeAsync uses them as the PredefinedNodes // dictionary key. The generator's AddXxx helpers stamp the // TYPE NodeId on every new child; without this walk every // instance of PumpType would collide on those NodeIds. AssignChildNodeIds(pump); await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken) .ConfigureAwait(false); m_pump1 = pump; m_logger.LogInformation( "Materialised '{Name}' (PumpType) under DeviceSet, NodeId={NodeId}.", pumpBrowseName.Name, pump.NodeId); } /// /// Materialises the optional PumpType children that the fluent /// simulation in wires. Each call to a /// generator-emitted AddXxx(context) helper creates the /// child and assigns it to the parent's typed property; the /// parent.AddChild bookkeeping happens inside the helpers /// transparently. /// private void MaterialisePumpOptionalChildren( Opc.Ua.Pumps.PumpState pump) { Opc.Ua.Pumps.OperationalGroupState operational = pump.AddOperational(SystemContext); Opc.Ua.Pumps.MeasurementsState measurements = operational.AddMeasurements(SystemContext); // Analog measurements wired by Configure.WithMeasurements. measurements.AddDifferentialPressure(SystemContext); measurements.AddFluidTemperature(SystemContext); measurements.AddBearingTemperature(SystemContext); measurements.AddPumpPowerInput(SystemContext); measurements.AddMassFlow(SystemContext); measurements.AddPumpEfficiency(SystemContext); measurements.AddLevel(SystemContext); // Discrete count exposed via Configure.WithMaintenance. measurements.AddNumberOfStarts(SystemContext); // Supervision subtree wired by Configure.WithSupervision — // Cavitation under SupervisionProcessFluid, MotorOverheat // under SupervisionPumpOperation. Opc.Ua.Pumps.SupervisionState events = pump.AddEvents(SystemContext); Opc.Ua.Pumps.SupervisionProcessFluidState processFluid = events.AddSupervisionProcessFluid(SystemContext); processFluid.AddCavitation(SystemContext); Opc.Ua.Pumps.SupervisionPumpOperationState pumpOperation = events.AddSupervisionPumpOperation(SystemContext); pumpOperation.AddMotorOverheat(SystemContext); // Maintenance container — leaf wiring deferred until the // typed-accessor generator (FB-3 phase 3) ships materialisable // leaves for ConditionBasedMaintenance / BreakdownMaintenance. pump.AddMaintenance(SystemContext); } /// /// Recursively walks the children of /// and assigns per-instance NodeIds via the active /// . Required after /// calling generator-emitted AddXxx(context) helpers /// which stamp the TYPE NodeId on every new child. /// private void AssignChildNodeIds(NodeState parent) { var children = new List(); parent.GetChildren(SystemContext, children); foreach (BaseInstanceState child in children) { child.NodeId = SystemContext.NodeIdFactory.New( SystemContext, child); AssignChildNodeIds(child); } } /// /// Registers a DI DeviceHealth variable that the /// supervision simulation loop should toggle in response to /// the simulated cavitation / motor-overheat flags. The /// companion-spec PumpType does not itself expose /// DeviceHealth (it inherits from /// , not /// ); callers can /// attach DeviceHealth to a sibling /// (e.g. the /// declarative Pump #2 created in Program.cs) /// and register it here to participate in the simulation loop. /// /// /// The variable to drive; pass to detach. /// public void RegisterSupervisedDeviceHealth( BaseDataVariableState? health) { m_supervisedDeviceHealth = health; } /// /// Partial wired by the Configure.cs sibling. /// partial void Configure(INodeManagerBuilder builder); } /// /// Factory that produces instances. /// When constructed by the DI container via /// AddNodeManager<PumpNodeManagerFactory>(), the /// post-setup runner is injected and forwarded to every manager /// the factory produces, enabling /// ConfigureDevicesFor<PumpNodeManager>(...). /// public sealed class PumpNodeManagerFactory : IAsyncNodeManagerFactory { private readonly IDiPostSetupRunner? m_runner; /// /// Creates a factory without DI-hosting integration. /// public PumpNodeManagerFactory() : this(null) { } /// /// Creates a factory that injects the post-setup runner into /// every manager it produces. /// public PumpNodeManagerFactory(IDiPostSetupRunner? runner) { m_runner = runner; } /// public ArrayOf NamespacesUris => new string[] { Opc.Ua.Pumps.Namespaces.Pumps, Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.Di.Namespaces.OpcUaDi }; /// public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { #pragma warning disable CA2000 // ownership transferred to server IAsyncNodeManager nm = new PumpNodeManager(server, configuration, m_runner); #pragma warning restore CA2000 return new ValueTask(nm); } } }