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