diff --git a/Stack/Opc.Ua.Types/State/BaseInstanceState.cs b/Stack/Opc.Ua.Types/State/BaseInstanceState.cs
index 830ed6bf81..2bd2779763 100644
--- a/Stack/Opc.Ua.Types/State/BaseInstanceState.cs
+++ b/Stack/Opc.Ua.Types/State/BaseInstanceState.cs
@@ -47,6 +47,9 @@ protected BaseInstanceState(NodeClass nodeClass, NodeState parent)
Parent = parent;
}
+ ///
+ protected override bool RemovePlaceholderChildrenOnCreate => true;
+
///
/// Initializes the instance from another instance.
///
diff --git a/Stack/Opc.Ua.Types/State/NodeState.cs b/Stack/Opc.Ua.Types/State/NodeState.cs
index 27935f2283..915b9d1f5d 100644
--- a/Stack/Opc.Ua.Types/State/NodeState.cs
+++ b/Stack/Opc.Ua.Types/State/NodeState.cs
@@ -2710,6 +2710,11 @@ public virtual void Create(
{
Initialize(context);
+ if (RemovePlaceholderChildrenOnCreate)
+ {
+ RemovePlaceholderChildren(context);
+ }
+
// Call OnBeforeCreate on all children.
CallOnBeforeCreate(context);
@@ -2819,6 +2824,11 @@ public virtual void Create(ISystemContext context, NodeState source)
{
Initialize(context, source);
+ if (RemovePlaceholderChildrenOnCreate)
+ {
+ RemovePlaceholderChildren(context);
+ }
+
CallOnBeforeCreate(context);
CallOnAfterCreate(context, null);
@@ -2826,6 +2836,76 @@ public virtual void Create(ISystemContext context, NodeState source)
ClearChangeMasks(context, true);
}
+ ///
+ /// Whether placeholder declarations should be removed during runtime creation.
+ ///
+ protected virtual bool RemovePlaceholderChildrenOnCreate => false;
+
+ ///
+ /// Removes instantiated placeholder declarations from runtime instances.
+ ///
+ private void RemovePlaceholderChildren(ISystemContext context)
+ {
+ var children = new List();
+ GetChildren(context, children);
+
+ for (int ii = 0; ii < children.Count; ii++)
+ {
+ BaseInstanceState child = children[ii];
+
+ if (IsPlaceholderChild(child))
+ {
+ RemoveChild(child);
+
+ if (ReferenceEquals(child.Parent, this))
+ {
+ child.Parent = null;
+ }
+
+ continue;
+ }
+
+ child.RemovePlaceholderChildren(context);
+ }
+ }
+
+ ///
+ /// Returns true if the child represents a placeholder declaration.
+ ///
+ private static bool IsPlaceholderChild(BaseInstanceState child)
+ {
+ if (child.ModellingRuleId == ObjectIds.ModellingRule_OptionalPlaceholder ||
+ child.ModellingRuleId == ObjectIds.ModellingRule_MandatoryPlaceholder)
+ {
+ return true;
+ }
+
+ string browseName = child.BrowseName?.Name;
+
+ // Some generated placeholder declarations reach runtime without their
+ // placeholder modelling rule; the fallback is limited to model-defined
+ // standard nodes that still carry the generated
+ // browse-name form.
+ return IsGeneratedStandardNumericNode(child) &&
+ browseName != null &&
+ browseName.Length > 1 &&
+ browseName[0] == '<' &&
+ browseName[browseName.Length - 1] == '>';
+ }
+
+ ///
+ /// Returns true if the child still has its generated standard numeric node identity.
+ ///
+ private static bool IsGeneratedStandardNumericNode(BaseInstanceState child)
+ {
+ return child.NumericId != 0 &&
+ child.NodeId != null &&
+ child.NodeId.NamespaceIndex == 0 &&
+ child.NodeId.IdType == IdType.Numeric &&
+ child.NodeId.Identifier is uint identifier &&
+ identifier == child.NumericId;
+ }
+
///
/// Deletes an instance and its children (calls OnStateChange callback for each node).
///
diff --git a/Tests/Opc.Ua.Core.Tests/Stack/State/NodeStateTests.cs b/Tests/Opc.Ua.Core.Tests/Stack/State/NodeStateTests.cs
index 3821189699..58be20636b 100644
--- a/Tests/Opc.Ua.Core.Tests/Stack/State/NodeStateTests.cs
+++ b/Tests/Opc.Ua.Core.Tests/Stack/State/NodeStateTests.cs
@@ -28,6 +28,7 @@
* ======================================================================*/
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
@@ -84,10 +85,87 @@ public void ActivateNodeStateType(Type systemType)
Assert.NotNull(testObject);
var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
Assert.AreEqual(0, context.NamespaceUris.GetIndexOrAppend(OpcUa));
+
testObject.Create(context, new NodeId(1000), "Name", "DisplayName", true);
testObject.Dispose();
}
+ ///
+ /// Instantiate NodeState types across Opc.Ua assemblies and fail on placeholder children.
+ ///
+ [Test]
+ public void NodeStateTypesAcrossOpcUaAssemblies_ShouldNotInstantiatePlaceholderChildren()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
+ var placeholders = new List();
+ uint nodeId = 200000;
+ Type[] nodeStateTypesToScan = [.. GetOpcUaNodeStateTypes().OrderBy(type => type.FullName)];
+
+ foreach (Type systemType in nodeStateTypesToScan)
+ {
+ var testObject = CreateDefaultNodeStateType(systemType) as NodeState;
+
+ if (testObject == null)
+ {
+ continue;
+ }
+
+ if (testObject is not BaseInstanceState)
+ {
+ testObject.Dispose();
+ continue;
+ }
+
+ try
+ {
+ testObject.Create(context, new NodeId(nodeId++), "Name", "DisplayName", true);
+ CollectInstantiatedPlaceholders(
+ context,
+ testObject,
+ systemType.Assembly.GetName().Name,
+ systemType.FullName,
+ placeholders);
+ }
+ finally
+ {
+ testObject.Dispose();
+ }
+ }
+
+ Assert.That(
+ placeholders,
+ Is.Empty,
+ "Instantiated placeholder children were found:" + Environment.NewLine +
+ string.Join(Environment.NewLine, placeholders));
+ }
+
+ ///
+ /// Verify placeholder declarations remain available on type definitions.
+ ///
+ [Test]
+ public void TypeDefinitions_ShouldKeepPlaceholderChildren()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var context = new SystemContext(telemetry) { NamespaceUris = Context.NamespaceUris };
+ var typeDefinition = new BaseObjectTypeState();
+ var placeholder = new BaseObjectState(typeDefinition)
+ {
+ BrowseName = new QualifiedName("", 0),
+ DisplayName = "",
+ ModellingRuleId = ObjectIds.ModellingRule_OptionalPlaceholder
+ };
+
+ typeDefinition.AddChild(placeholder);
+
+ typeDefinition.Create(context, new NodeId(1000), "TypeName", "TypeName", true);
+
+ var children = new List();
+ typeDefinition.GetChildren(context, children);
+
+ Assert.That(children, Has.Member(placeholder));
+ }
+
///
/// Create an instance of a NodeState type with default values.
///
@@ -149,6 +227,132 @@ private static bool IsNodeStateType(Type systemType)
return CreateDefaultNodeStateType(systemType) is NodeState;
}
+
+ ///
+ /// Recursively collect instantiated placeholder children for diagnostics.
+ ///
+ private static void CollectInstantiatedPlaceholders(
+ ISystemContext context,
+ NodeState nodeState,
+ string ownerAssembly,
+ string ownerType,
+ List placeholders)
+ {
+ var children = new List();
+ nodeState.GetChildren(context, children);
+
+ foreach (BaseInstanceState child in children)
+ {
+ string browseName = child.BrowseName?.Name ?? string.Empty;
+ bool hasPlaceholderName =
+ browseName.Length > 1 &&
+ browseName[0] == '<' &&
+ browseName[browseName.Length - 1] == '>';
+
+ bool hasPlaceholderModellingRule =
+ child.ModellingRuleId == ObjectIds.ModellingRule_OptionalPlaceholder ||
+ child.ModellingRuleId == ObjectIds.ModellingRule_MandatoryPlaceholder;
+
+ if (hasPlaceholderName || hasPlaceholderModellingRule)
+ {
+ string modellingRule = child.ModellingRuleId?.ToString() ?? "";
+ placeholders.Add(
+ $"{ownerAssembly}: {ownerType}: {child.GetDisplayPath()} (BrowseName='{browseName}', ModellingRuleId='{modellingRule}')");
+ }
+
+ CollectInstantiatedPlaceholders(
+ context,
+ child,
+ ownerAssembly,
+ ownerType,
+ placeholders);
+ }
+ }
+
+ ///
+ /// Discover loadable public NodeState types from reachable Opc.Ua assemblies.
+ ///
+ private static IEnumerable GetOpcUaNodeStateTypes()
+ {
+ var assemblies = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var toScan = new Queue();
+
+ void TryEnqueueAssembly(Assembly assembly)
+ {
+ if (assembly == null || assembly.IsDynamic)
+ {
+ return;
+ }
+
+ AssemblyName assemblyName = assembly.GetName();
+
+ if (!assemblyName.Name.StartsWith("Opc.Ua", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (assemblies.ContainsKey(assembly.FullName))
+ {
+ return;
+ }
+
+ assemblies[assembly.FullName] = assembly;
+ toScan.Enqueue(assembly);
+ }
+
+ TryEnqueueAssembly(typeof(NodeState).Assembly);
+ TryEnqueueAssembly(typeof(OrderedListState).Assembly);
+ TryEnqueueAssembly(typeof(StateTypesTests).Assembly);
+
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ TryEnqueueAssembly(assembly);
+ }
+
+ while (toScan.Count > 0)
+ {
+ Assembly assembly = toScan.Dequeue();
+
+ foreach (AssemblyName reference in assembly.GetReferencedAssemblies())
+ {
+ if (!reference.Name.StartsWith("Opc.Ua", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ try
+ {
+ TryEnqueueAssembly(Assembly.Load(reference));
+ }
+ catch
+ {
+ // Nothing we can do if an assembly fails to load, just skip it.
+ }
+ }
+ }
+
+ return assemblies.Values
+ .SelectMany(GetExportedTypesSafe)
+ .Where(IsNodeStateType)
+ .GroupBy(type => type.AssemblyQualifiedName)
+ .Select(group => group.First());
+ }
+
+ ///
+ /// Return exported types while tolerating partial type-load failures.
+ ///
+ private static IEnumerable GetExportedTypesSafe(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetExportedTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ // Continue with loadable public types if some types in the assembly fail to load.
+ return e.Types.Where(type => type != null && type.IsPublic);
+ }
+ }
}
///