/* ========================================================================
* 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);
}
}