/* ======================================================================== * 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.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Opc.Ua.Di.Server.Builders; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; namespace Opc.Ua.Di.Server { /// /// OPC UA node manager that exposes the Device Integration (DI) model /// (OPC 10000-100) predefined nodes plus the fluent /// surface for materialising new /// devices at runtime. /// /// /// /// The static model nodes — all type definitions, reference types, and /// well-known instances defined by the DI companion specification — are /// loaded from the source-generated AddOpcUaDi extension. /// Subclasses can override /// /// to attach live behaviour to specific predefined nodes. /// /// /// Programmatic device creation goes through /// /// (and its generic overload). The returned /// writes nameplate properties, /// adds functional groups, and stamps topology references; the manager /// itself owns the registration through AddPredefinedNodeAsync /// so the new device participates fully in browse, read, subscribe, /// and event delivery. /// /// /// DiNodeManager inherits from /// (rather than directly from /// ) so DI users gain the /// fluent event-source and simulation registries out of the box. /// Subclasses overriding /// or /// /// must still call base. /// /// public class DiNodeManager : FluentNodeManagerBase, INodeIdFactory { /// /// The DI namespace URI (http://opcfoundation.org/UA/DI/). /// public const string DiNamespaceUri = Opc.Ua.Di.Namespaces.OpcUaDi; private NodeManagerBuilder? m_builder; /// /// Initialises a new without DI- /// hosting integration. Use this constructor for manual /// (non-AddOpcUaDi) wiring. /// public DiNodeManager( IServerInternal server, ApplicationConfiguration configuration) : this(server, configuration, postSetupRunner: null) { } /// /// Initialises a new with an /// optional post-setup runner. The runner is invoked /// automatically at the end of /// (after /// returns), for both /// the base and every subclass. /// public DiNodeManager( IServerInternal server, ApplicationConfiguration configuration, Opc.Ua.Di.Server.Hosting.IDiPostSetupRunner? postSetupRunner) : base( server, configuration, server.Telemetry.CreateLogger(), DiNamespaceUri) { SystemContext.NodeIdFactory = this; PostSetupRunner = postSetupRunner; } /// /// Initialises a new that registers /// additional namespaces in addition to DI. Used by subclasses /// (e.g. machinery/pump managers) that own the same namespace /// set as the DI manager. /// /// The hosting server. /// The application configuration. /// /// Additional namespace URIs to register before DI. The DI /// namespace is always appended last. /// protected DiNodeManager( IServerInternal server, ApplicationConfiguration configuration, params string[] additionalNamespaceUris) : this(server, configuration, postSetupRunner: null, additionalNamespaceUris) { } /// /// Initialises a new that registers /// additional namespaces in addition to DI and accepts an /// optional post-setup runner. Subclasses pass the runner /// injected through their factory. /// protected DiNodeManager( IServerInternal server, ApplicationConfiguration configuration, Opc.Ua.Di.Server.Hosting.IDiPostSetupRunner? postSetupRunner, params string[] additionalNamespaceUris) : base( server, configuration, server.Telemetry.CreateLogger(), CombineNamespaces(additionalNamespaceUris)) { SystemContext.NodeIdFactory = this; PostSetupRunner = postSetupRunner; } /// /// Optional post-setup runner injected through the /// Device Integration (DI) hosting pipeline. The base class /// auto-invokes it from /// after returns, so /// subclasses do not need to (and should not) invoke it /// manually. Read-only on subclasses; exposed mostly for /// diagnostic / test inspection. /// protected Opc.Ua.Di.Server.Hosting.IDiPostSetupRunner? PostSetupRunner { get; } private static string[] CombineNamespaces(string[] additional) { if (additional == null || additional.Length == 0) { return new[] { DiNamespaceUri }; } var combined = new string[additional.Length + 1]; additional.CopyTo(combined, 0); combined[^1] = DiNamespaceUri; return combined; } /// /// The namespace index of the DI model. /// public ushort DiNamespaceIndex => (ushort)Server.NamespaceUris.GetIndex(DiNamespaceUri); /// 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; } /// /// Adds an instance node under the Machinery Machines folder /// if it is present in the address space. Returns /// when the folder was found and the reference was added. /// protected bool TryAddToMachinesFolder(BaseInstanceState instance) { // The well-known Machines folder BrowseName defined by the // Machinery companion specification (OPC 40001). const string machinesFolderBrowseName = "Machines"; foreach (NodeState root in PredefinedNodes.Values) { if (root.BrowseName.Name == machinesFolderBrowseName && root is BaseObjectState folder) { folder.AddChild(instance); return true; } } return false; } /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) { return new ValueTask( new NodeStateCollection().AddOpcUaDi(context)); } /// /// Constructs the address space and runs the /// IDiPostSetupRunner-based hosting hooks. The flow is: /// /// /// The framework's base.CreateAddressSpaceAsync loads /// predefined nodes and wires the type tree. /// /// /// runs (subclasses /// override this to materialise additional instances and /// drive the fluent INodeManagerBuilder). /// /// /// The DI hosting , if any, is /// invoked with this; configurators registered via /// ConfigureDevicesFor<TNodeManager> see the /// fully wired manager. /// /// /// Subclasses should override /// rather than CreateAddressSpaceAsync so the post-setup /// runner fires automatically. /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, CancellationToken cancellationToken = default) { await base.CreateAddressSpaceAsync( externalReferences, cancellationToken).ConfigureAwait(false); await OnAddressSpaceReadyAsync(cancellationToken).ConfigureAwait(false); if (PostSetupRunner != null) { await PostSetupRunner.RunAsync(this, cancellationToken) .ConfigureAwait(false); } } /// /// Extension point invoked after /// /// has populated PredefinedNodes but before the /// fires. Subclasses materialise /// additional instances (e.g. companion-spec device factories) /// and drive the fluent INodeManagerBuilder from here. /// /// /// The default implementation is a no-op. Implementers do not /// need to invoke base.OnAddressSpaceReadyAsync. /// protected virtual ValueTask OnAddressSpaceReadyAsync( CancellationToken cancellationToken) { return default; } /// /// Returns the well-known parent under which /// /// attaches newly created devices when no explicit parent is /// supplied. The default implementation returns the DI /// DeviceSet object; subclasses such as machinery-aware /// managers may override this to return the Machinery /// Machines folder (or any other container). /// /// /// The default parent , or /// if no DI DeviceSet instance is /// present in the address space. /// protected virtual NodeState? ResolveDefaultDeviceParent() { NodeId deviceSetId = NodeId.Create( Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris); return PredefinedNodes.TryGetValue(deviceSetId, out NodeState? parent) ? parent : null; } /// /// Creates a new instance under the /// resolved (or the manager's default /// device parent when is /// ) and registers it with the manager. /// /// Browse name of the new device. /// /// Optional explicit parent; when , the /// manager's is used. /// /// Cancellation token. /// A fluent builder for further configuration. public ValueTask> CreateDeviceAsync( QualifiedName browseName, NodeState? parent = null, CancellationToken cancellationToken = default) { return CreateDeviceAsync( browseName, NodeId.Create( Opc.Ua.Di.ObjectTypes.DeviceType, DiNamespaceUri, Server.NamespaceUris), static p => new DeviceState(p), parent, cancellationToken); } /// /// Creates a new device of type /// under the resolved parent and registers it with the manager. /// /// Concrete device state class. /// Browse name of the new device. /// /// Spec-defined TypeDefinitionId stamped onto the new /// instance. Required so clients see the correct object type /// (e.g. PumpType) rather than a generic /// DeviceType. /// /// /// Factory that constructs the state instance. Typically a /// generator-emitted new PumpTypeState(parent)-style /// lambda. /// /// /// Optional explicit parent; when , the /// manager's is used. /// /// Cancellation token. public async ValueTask> CreateDeviceAsync( QualifiedName browseName, NodeId typeDefinitionId, System.Func factory, NodeState? parent = null, CancellationToken cancellationToken = default) where TDevice : ComponentState { if (browseName.IsNull) { throw new System.ArgumentNullException(nameof(browseName)); } if (typeDefinitionId.IsNull) { throw new System.ArgumentNullException(nameof(typeDefinitionId)); } if (factory == null) { throw new System.ArgumentNullException(nameof(factory)); } NodeState effectiveParent = parent ?? ResolveDefaultDeviceParent() ?? throw ServiceResultException.Create( StatusCodes.BadConfigurationError, "No default device parent could be resolved. Override DiNodeManager.ResolveDefaultDeviceParent or supply an explicit parent."); // Fail fast on duplicate browse names under the parent. NodeState? existing = effectiveParent.FindChild(SystemContext, browseName); if (existing != null) { throw ServiceResultException.Create( StatusCodes.BadBrowseNameDuplicated, "Parent '{0}' already has a child '{1}'.", effectiveParent.BrowseName, browseName); } TDevice device = factory(effectiveParent) ?? throw ServiceResultException.Create( StatusCodes.BadInvalidArgument, "Factory returned null for device '{0}'.", browseName); device.SymbolicName = browseName.Name ?? string.Empty; device.BrowseName = browseName; device.DisplayName = new LocalizedText(browseName.Name); device.TypeDefinitionId = typeDefinitionId; device.ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasComponent; device.NodeId = SystemContext.NodeIdFactory.New(SystemContext, device); effectiveParent.AddChild(device); await AddPredefinedNodeAsync(SystemContext, device, cancellationToken) .ConfigureAwait(false); return new DeviceBuilder(this, device, GetOrCreateBuilder()); } /// /// Wraps an existing device (e.g. one loaded from a NodeSet2 /// XML) with the fluent /// surface so that the /// same configuration code can run against loaded and /// programmatically created devices alike. /// /// Concrete device state type. public IDeviceBuilder Device(TDevice device) where TDevice : ComponentState { if (device == null) { throw new System.ArgumentNullException(nameof(device)); } return new DeviceBuilder(this, device, GetOrCreateBuilder()); } /// /// Returns a fluent builder for the device with the supplied /// . Throws if the node is not present /// in the manager's predefined nodes or is not assignable to /// . /// /// Concrete device state type. public IDeviceBuilder Device(NodeId nodeId) where TDevice : ComponentState { if (nodeId.IsNull) { throw new System.ArgumentNullException(nameof(nodeId)); } if (!PredefinedNodes.TryGetValue(nodeId, out NodeState? state)) { throw ServiceResultException.Create( StatusCodes.BadNodeIdUnknown, "No predefined node with NodeId '{0}'.", nodeId); } if (state is not TDevice typed) { throw ServiceResultException.Create( StatusCodes.BadTypeMismatch, "Node '{0}' is of type {1}, which is not assignable to {2}.", nodeId, state.GetType().Name, typeof(TDevice).Name); } return new DeviceBuilder(this, typed, GetOrCreateBuilder()); } /// /// Resolves an existing device by browse name under a parent /// (defaulting to ) and /// returns a fluent builder over it. /// /// Concrete device state type. public IDeviceBuilder DeviceByBrowseName( QualifiedName browseName, NodeState? parent = null) where TDevice : ComponentState { if (browseName.IsNull) { throw new System.ArgumentNullException(nameof(browseName)); } NodeState effectiveParent = parent ?? ResolveDefaultDeviceParent() ?? throw ServiceResultException.Create( StatusCodes.BadConfigurationError, "No default device parent could be resolved."); NodeState? child = effectiveParent.FindChild(SystemContext, browseName); if (child is not TDevice typed) { throw ServiceResultException.Create( StatusCodes.BadNodeIdUnknown, "No child '{0}' of type {1} on '{2}'.", browseName, typeof(TDevice).Name, effectiveParent.BrowseName); } return new DeviceBuilder(this, typed, GetOrCreateBuilder()); } /// /// Public lookup over the manager's predefined-node dictionary. /// Returns when no node with /// is registered. Subclasses already /// have direct access via PredefinedNodes; this method /// exists so co-resident helpers (topology accessors, /// software-update adapters) can resolve well-known DI nodes /// without making the dictionary public. /// public NodeState? FindPredefinedNode(NodeId nodeId) { if (nodeId.IsNull) { return null; } return PredefinedNodes.TryGetValue(nodeId, out NodeState? node) ? node : null; } /// /// Returns (and lazily creates) the fluent /// used internally by the /// device-builder pipeline to construct /// views. /// internal NodeManagerBuilder GetOrCreateBuilder() { if (m_builder != null) { return m_builder; } m_builder = new NodeManagerBuilder( SystemContext, this, DiNamespaceIndex, browseName => { foreach (NodeState root in PredefinedNodes.Values) { if (root.BrowseName == browseName) { return root; } } return null!; }, nodeId => PredefinedNodes.TryGetValue(nodeId, out NodeState? node) ? node : null!, typeId => { var results = new List(); foreach (NodeState node in PredefinedNodes.Values) { if (node is BaseInstanceState instance && instance.TypeDefinitionId == typeId) { results.Add(node); } } return results; }); AttachToBuilder(m_builder); return m_builder; } } }