/* ======================================================================== * 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.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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.OpenUsd; using Opc.Ua.Pumps; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.NodeManager; namespace Pumps { /// /// Runtime options for the pump device-integration sample. /// public sealed class PumpDeviceIntegrationOptions { /// /// Gets or sets how many simulated pump instances are materialised. /// public int PumpCount { get; set; } = 2; /// /// Gets or sets the interval used by the live simulation loop. /// public TimeSpan SimulationInterval { get; set; } = TimeSpan.FromMilliseconds(250); } /// /// 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 { private readonly PumpDeviceIntegrationOptions m_options; private readonly List m_pumpStates = []; /// /// Initialises a new without /// DI-hosting integration. /// public PumpNodeManager( IServerInternal server, ApplicationConfiguration configuration) : this(server, configuration, postSetupRunner: null, options: null) { } /// /// Initialises a new that /// participates in the DI hosting post-setup pipeline. /// public PumpNodeManager( IServerInternal server, ApplicationConfiguration configuration, IDiPostSetupRunner? postSetupRunner, IOptions? options = null) : base( server, configuration, postSetupRunner, Opc.Ua.Pumps.Namespaces.Pumps, Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { m_options = options?.Value ?? new PumpDeviceIntegrationOptions(); if (m_options.PumpCount is < 1 or > 100) { throw new ArgumentOutOfRangeException( $"{nameof(options)}.{nameof(PumpDeviceIntegrationOptions.PumpCount)}", m_options.PumpCount, "Pump count must be between 1 and 100."); } if (m_options.SimulationInterval <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException( $"{nameof(options)}.{nameof(PumpDeviceIntegrationOptions.SimulationInterval)}", m_options.SimulationInterval, "Simulation interval must be positive."); } } /// /// Gets the registered pump instance NodeIds. /// public ArrayOf PumpNodeIds { get { var nodeIds = new List(m_pumpStates.Count); foreach (PumpState pump in m_pumpStates) { nodeIds.Add(pump.NodeId); } return nodeIds.ToArrayOf(); } } internal TimeSpan SimulationInterval => m_options.SimulationInterval; /// /// Creates and registers a generated /// instance organized by the DI DeviceSet. /// /// Browse name for the pump instance. /// Cancellation token. /// The registered generated pump state. /// public ValueTask CreatePumpAsync( QualifiedName pumpBrowseName, CancellationToken cancellationToken = default) { return MaterialisePumpInstanceAsync( pumpBrowseName, cancellationToken, RegisterPumpSimulationAsync); } /// 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); nodes.AddOpcUaOpenUsd(context); return new ValueTask(nodes); } /// protected override async ValueTask ConfigureAsync( INodeManagerBuilder builder, 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(builder, cancellationToken) .ConfigureAwait(false); // Configuration phase 2 (sync): wire fluent callbacks // against the predefined nodes. Configure(builder); PreservePumpHistoryReadAccessLevels(); m_logger.PumpAddressSpaceReady(PredefinedNodes.Count); // The builder is sealed and the PostSetupRunner invoked by the // base DiNodeManager.CreateAddressSpaceAsync once 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( INodeManagerBuilder builder, CancellationToken cancellationToken) { // OpenUSD facility first so the pump representation can reference the stage. await MaterialiseOpenUsdFacilityAsync(cancellationToken) .ConfigureAwait(false); PumpState? firstPump = null; for (int pumpNumber = 1; pumpNumber <= m_options.PumpCount; pumpNumber++) { var pumpBrowseName = new QualifiedName( GetPumpBrowseName(pumpNumber), InstanceNamespaceIndex); PumpState pump = await MaterialisePumpInstanceAsync( pumpBrowseName, cancellationToken).ConfigureAwait(false); firstPump ??= pump; } // Every pump is a twin in its own right, so every representation has to // be discoverable — a connector finds them through this registry alone. OrganiseRepresentations(); // Plant-level aggregation: composes one full-fidelity pump prim per // configured pump, so the rendered scene scales with --pumps N. MaterialisePlantAggregation(builder); // Composition demo: a ProductionLine aggregating 1..n pumps (Many), with a // dynamically added/removed pump (model-change events) and a cross-server // component (federation). See OpenUsdComposition.cs. MaterialiseProductionLine(builder); } /// /// Creates a instance with the supplied /// browse name, registers it as a predefined node, and links it /// from the DI DeviceSet with Organizes. 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 /// and - because a browse name is supplied - rebases the whole /// subtree onto per-instance NodeIds minted by . /// 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, which assign per-instance /// NodeIds to every node they add before /// AddPredefinedNodeAsync recursively registers the /// entire subtree. /// /// private async ValueTask MaterialisePumpInstanceAsync( QualifiedName pumpBrowseName, CancellationToken cancellationToken, Func? onRegistered = null) { NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); if (deviceSet == null) { m_logger.DiDeviceSetNotFound(pumpBrowseName.Name); throw ServiceResultException.Create( StatusCodes.BadConfigurationError, "The DI DeviceSet is not available."); } // The duplicate is looked up by browse name rather than by // predicting the identifier the factory would mint. A prediction // only holds while the factory derives identifiers from the browse // path: under Counter mode minting one consumes a counter value // and returns an identifier no node can already have, so the check // would pass and let a second pump of the same name through. var existingDevices = new List(); deviceSet.GetChildren(SystemContext, existingDevices); if (existingDevices.Any(device => device.BrowseName == pumpBrowseName)) { m_logger.DeviceSetAlreadyContains(pumpBrowseName.Name); throw ServiceResultException.Create( StatusCodes.BadBrowseNameDuplicated, "DeviceSet already contains '{0}'.", pumpBrowseName); } // The DeviceSet is passed as the parent so New() can derive the // per-instance NodeIds the factory stamps on the pump and its // mandatory children from the parent chain. PumpState pump = SystemContext .CreateInstanceOfPumpType(deviceSet, pumpBrowseName); int pumpNumber = m_pumpStates.Count + 1; pump.DisplayName = new LocalizedText(GetPumpDisplayName(pumpNumber)); pump.ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.Organizes; deviceSet.AddChild(pump); MaterialisePumpOptionalChildren(pump); // Attach the OpenUSD representation + live bindings before // registration. Per-instance NodeIds are already assigned by the // generated CreateOrReplace/AddXxx helpers, so the binding source // NodeIds captured here are the instance ones. AttachOpenUsdRepresentation(pump, pumpNumber); await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken) .ConfigureAwait(false); if (onRegistered != null) { await onRegistered(pump, cancellationToken).ConfigureAwait(false); } // Variables hand-built onto the pump (rather than materialised by the // generated factory) are reachable by browse and read, but a monitored // item never samples them unless they are registered in their own // right -- register them explicitly so the OpenUSD bindings that use // them are live. await RegisterOpenUsdSignalsAsync(pump, cancellationToken).ConfigureAwait(false); TryAddToMachinesFolder(pump); m_pumpStates.Add(pump); m_logger.MaterialisedPump(pumpBrowseName.Name, pump.NodeId); return pump; } /// /// Materialises the optional PumpType children that the fluent /// simulation in wires. Each call to a /// generator-emitted AddXxx(context) helper creates the /// child, assigns it to the parent's typed property and stamps a /// per-instance NodeId on the new node and its descendants; the /// parent.AddChild bookkeeping happens inside the helpers /// transparently. /// private void MaterialisePumpOptionalChildren( PumpState pump) { MaterialiseNameplate(pump.Identification!); pump.AddOperational(SystemContext); OperationalGroupState operational = pump.Operational!; operational.AddMeasurements(SystemContext); MeasurementsState measurements = operational.Measurements!; // Analog measurements wired by Configure.WithMeasurements. measurements .AddDifferentialPressure(SystemContext) .AddFluidTemperature(SystemContext) .AddBearingTemperature(SystemContext) .AddPumpPowerInput(SystemContext) .AddMassFlow(SystemContext) .AddPumpEfficiency(SystemContext) .AddLevel(SystemContext) // Discrete count exposed via Configure.WithMaintenance. .AddNumberOfStarts(SystemContext); // Supervision subtree wired by Configure.WithSupervision — // Cavitation under SupervisionProcessFluid, MotorOverheat // under SupervisionPumpOperation. pump.AddEvents(SystemContext); SupervisionState events = pump.Events!; pump.EventNotifier |= EventNotifiers.SubscribeToEvents; events.EventNotifier |= EventNotifiers.SubscribeToEvents; pump.AddReference( Opc.Ua.Types.ReferenceTypeIds.HasNotifier, isInverse: false, events.NodeId); events.AddReference( Opc.Ua.Types.ReferenceTypeIds.HasNotifier, isInverse: true, pump.NodeId); events.AddSupervisionProcessFluid(SystemContext); events.SupervisionProcessFluid!.AddCavitation(SystemContext); PreserveHistoryRead(events.SupervisionProcessFluid.Cavitation); events.AddSupervisionPumpOperation(SystemContext); events.SupervisionPumpOperation!.AddMotorOverheat(SystemContext); PreserveHistoryRead(events.SupervisionPumpOperation.MotorOverheat); // Maintenance container — leaf wiring deferred until the // typed-accessor generator (FB-3 phase 3) ships materialisable // leaves for ConditionBasedMaintenance / BreakdownMaintenance. pump.AddMaintenance(SystemContext); } internal static string GetPumpBrowseName(int pumpNumber) { return "Pump_" + pumpNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); } internal static string GetPumpDisplayName(int pumpNumber) { return "Pump #" + pumpNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); } private static void PreserveHistoryRead(BaseVariableState? variable) { if (variable == null) { return; } variable.AccessLevel |= AccessLevels.HistoryRead; variable.UserAccessLevel |= AccessLevels.HistoryRead; variable.AccessLevelEx |= AccessLevels.HistoryRead; variable.OnReadAccessLevel = ( context, node, ref value) => { value = (byte)(variable.AccessLevel | AccessLevels.HistoryRead); return ServiceResult.Good; }; } private void PreservePumpHistoryReadAccessLevels() { foreach (NodeState node in PredefinedNodes.Values) { if (node is BaseVariableState variable && node.BrowseName.Name is "Cavitation" or "MotorOverheat") { PreserveHistoryRead(variable); } } } /// /// Materialises the optional nameplate properties that carry the /// PumpX-2000 datasheet identification data. Manufacturer /// and SerialNumber are mandatory on /// PumpIdentificationType and are already created by the /// generated factory; every other field is optional and is added /// here through the generator-emitted AddXxx(context) /// helpers so each property keeps the browse name, namespace and /// DataType declared by the DI, Machinery and Pumps models. The /// values themselves are assigned by the fluent /// WithProperty wiring (Pump #1) and by the topology-element /// builder (Pump #2). /// private void MaterialiseNameplate(PumpIdentificationState identification) { // OPC 10000-100 (DI) nameplate. identification.AddManufacturerUri(SystemContext) .AddModel(SystemContext) .AddProductCode(SystemContext) .AddDeviceClass(SystemContext) .AddHardwareRevision(SystemContext) .AddSoftwareRevision(SystemContext) .AddProductInstanceUri(SystemContext) .AddAssetId(SystemContext) .AddComponentName(SystemContext); // OPC 40001-1 (Machinery) nameplate. identification.AddLocation(SystemContext) .AddYearOfConstruction(SystemContext) .AddMonthOfConstruction(SystemContext); // OPC 40223 (Pumps) nameplate. identification.AddDayOfConstruction(SystemContext) .AddArticleNumber(SystemContext) .AddOrderProductCode(SystemContext) .AddTypeOfProduct(SystemContext) .AddSupplier(SystemContext) .AddCountryOfOrigin(SystemContext) .AddFabricationNumber(SystemContext); } /// /// 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; private readonly IOptions? m_options; /// /// Creates a factory without DI-hosting integration. /// public PumpNodeManagerFactory() : this(null, null) { } /// /// Creates a factory that injects the post-setup runner into /// every manager it produces. /// public PumpNodeManagerFactory( IDiPostSetupRunner? runner, IOptions? options = null) { m_runner = runner; m_options = options; } /// public ArrayOf NamespacesUris => new string[] { Opc.Ua.Pumps.Namespaces.Pumps, Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.Di.Namespaces.OpcUaDi, Opc.Ua.OpenUsd.Namespaces.OpenUSD }; /// 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, m_options); #pragma warning restore CA2000 return new ValueTask(nm); } } internal static partial class PumpNodeManagerLog { [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.PumpNodeManager + 1, Level = LogLevel.Information, Message = "PumpNodeManager: address space ready ({NodeCount} predefined nodes).")] public static partial void PumpAddressSpaceReady(this ILogger logger, int nodeCount); [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.PumpNodeManager + 2, Level = LogLevel.Warning, Message = "DI DeviceSet not found — '{Name}' will not be created.")] public static partial void DiDeviceSetNotFound(this ILogger logger, string? name); [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.PumpNodeManager + 3, Level = LogLevel.Debug, Message = "DeviceSet already contains '{Name}' — skipping recreation.")] public static partial void DeviceSetAlreadyContains(this ILogger logger, string? name); [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.PumpNodeManager + 4, Level = LogLevel.Information, Message = "Materialised '{Name}' (PumpType) under DeviceSet, NodeId={NodeId}.")] public static partial void MaterialisedPump(this ILogger logger, string? name, NodeId nodeId); } }