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