/* ======================================================================== * 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.Globalization; using System.IO; using System.Linq; using System.Text; using System.Xml; using System.Xml.Serialization; using Microsoft.Extensions.Logging; using Opc.Ua.Export; using Opc.Ua.SourceGeneration; using Opc.Ua.Types; namespace Opc.Ua.Schema.Model { /// /// Node set reader settings /// public class NodeSetReaderSettings { /// /// Create node set reader settings /// public NodeSetReaderSettings() { NamespaceUris = new NamespaceTable(); DesignFilePaths = new Dictionary(); NodesByQName = new Dictionary(); NodesById = new Dictionary(); NamespaceTables = new Dictionary(); } /// /// Namespace uris. /// public NamespaceTable NamespaceUris { get; } /// /// Design file paths by namespace uri. /// public IDictionary DesignFilePaths { get; set; } /// /// Nodes by qualified name. /// public IDictionary? NodesByQName { get; set; } /// /// Namespace tables by model uri. /// public IDictionary? NamespaceTables { get; set; } /// /// Nodes by node id. /// public IDictionary NodesById { get; set; } } /// /// A set of nodes in an address space. /// public class NodeSetToModelDesign { /// /// Create converter /// /// /// /// /// /// /// public NodeSetToModelDesign( IFileSystem fileSystem, string filePath, NodeSetReaderSettings settings, ITelemetryContext telemetry) { if (filePath == null) { throw new ArgumentNullException(nameof(filePath)); } m_settings = settings ?? throw new ArgumentNullException(nameof(settings)); m_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); m_index = []; m_symbolicIds = []; using Stream istrm = m_fileSystem.OpenRead(filePath); m_nodeset = UANodeSet.Read(istrm) ?? throw new InvalidOperationException($"The file {filePath} does not contain a UANodeSet."); if (m_nodeset.NamespaceUris != null) { foreach (string ns in m_nodeset.NamespaceUris) { m_settings.NamespaceUris.GetIndexOrAppend(ns); } } if (m_nodeset.Models == null || m_nodeset.Models.Length == 0 || string.IsNullOrEmpty(m_nodeset.Models[0].ModelUri)) { throw new InvalidDataException( $"NodeSet ({filePath}) does not declare a entry with a ModelUri."); } m_settings.NamespaceTables![m_nodeset.Models[0].ModelUri!] = m_nodeset.NamespaceUris!; if (m_nodeset.Aliases != null) { foreach (NodeIdAlias ii in m_nodeset.Aliases) { m_aliases[ii.Alias!] = ImportNodeId(ii.Value, false); if (m_aliases[ii.Alias!].IsNull) { throw new InvalidDataException( $"Alias ({ii.Alias}) is not valid."); } } } // A NodeSet without nodes deserializes with a null Items array; // treat it as empty so every pass below imports nothing. m_nodeset.Items ??= []; foreach (UANode node in m_nodeset.Items) { NodeId nodeId = ImportNodeId(node.NodeId, false); if (nodeId.IsNull) { throw new InvalidDataException( $"NodeId ({node.BrowseName}) is not valid."); } m_index.Add(nodeId, node); } } /// /// Test whether the file is a node set file. /// /// public static bool IsNodeSet(IFileSystem fileSystem, string filePath) { if (fileSystem == null) { throw new ArgumentNullException(nameof(fileSystem)); } if (filePath == null) { throw new ArgumentNullException(nameof(filePath)); } // Only the root element decides. Reading it with an XmlReader copes // with a root on the same line as the XML declaration (minified // output) and with comments spanning several lines, which a line // based scan misread. using TextReader reader = fileSystem.CreateTextReader(filePath); var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null, IgnoreComments = true, IgnoreProcessingInstructions = true, IgnoreWhitespace = true }; try { using var xmlReader = XmlReader.Create(reader, settings); return xmlReader.MoveToContent() == XmlNodeType.Element && xmlReader.LocalName == "UANodeSet"; } catch (XmlException) { // A NodeSet that is not well formed is still a NodeSet: it // has to fail as one, in isolation, rather than fall through // to the ModelDesign pass and abort every model with it. return HasNodeSetRootName(fileSystem, filePath); } } /// /// Scans the raw text of a document that is not well formed for the /// name of its first element (skipping the declaration, processing /// instructions, comments and a DOCTYPE) and checks whether that name /// is UANodeSet, with or without a namespace prefix. /// private static bool HasNodeSetRootName(IFileSystem fileSystem, string filePath) { string text; using (TextReader reader = fileSystem.CreateTextReader(filePath)) { text = reader.ReadToEnd(); } int index = 0; while ((index = text.IndexOf('<', index)) >= 0) { if (string.CompareOrdinal(text, index, "", index + 4, StringComparison.Ordinal); index = end < 0 ? text.Length : end + 3; continue; } if (index + 1 < text.Length && text[index + 1] is '?' or '!') { int end = text.IndexOf('>', index + 1); index = end < 0 ? text.Length : end + 1; continue; } int start = index + 1; int stop = start; while (stop < text.Length && !char.IsWhiteSpace(text[stop]) && text[stop] is not '>' and not '/') { stop++; } string name = text[start..stop]; int colon = name.IndexOf(':', StringComparison.Ordinal); return (colon < 0 ? name : name[(colon + 1)..]) == "UANodeSet"; } return false; } private static T? Load(IFileSystem fileSystem, string path) { var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit }; using Stream stream = fileSystem.OpenRead(path); var serializer = new XmlSerializer(typeof(T)); using var reader = XmlReader.Create(stream, settings); return (T?)serializer.Deserialize(reader); } private static void CollectModels(ModelTableEntry model, List models) { if (model.RequiredModel != null) { foreach (ModelTableEntry dependency in model.RequiredModel) { CollectModels(dependency, models); } } for (int ii = 0; ii < models.Count; ii++) { if (models[ii].ModelUri == model.ModelUri) { if (model.PublicationDate > models[ii].PublicationDate) { models[ii] = model; } return; } } models.Add(model); } private static Namespace CreateNamespace(ModelTableEntry model) { Namespace ns; if (model.ModelUri!.StartsWith(Namespaces.OpcUa, StringComparison.Ordinal)) { ns = new Namespace { Name = model.ModelUri[Namespaces.OpcUa.Length..] .Replace("/", " ", StringComparison.Ordinal) .Trim() .Replace(" ", ".", StringComparison.Ordinal), Value = model.ModelUri, XmlNamespace = model.XmlSchemaUri, PublicationDate = model.PublicationDate .ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture), Version = model.Version }; ns.XmlPrefix = ns.Prefix = "Opc.Ua." + ns.Name; ns.Name = ns.Name .Replace(".", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal) .Replace(",", string.Empty, StringComparison.Ordinal) .Replace(":", string.Empty, StringComparison.Ordinal); } else { ns = new Namespace { Name = model.ModelUri .Replace("http://", string.Empty, StringComparison.Ordinal) .Replace("/", " ", StringComparison.Ordinal) .Trim() .Replace(" ", ".", StringComparison.Ordinal), Value = model.ModelUri, XmlNamespace = model.XmlSchemaUri, PublicationDate = model.PublicationDate .ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture), Version = model.Version }; ns.XmlPrefix = ns.Prefix = ns.Name; ns.Name = ns.Name .Replace(".", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal) .Replace(",", string.Empty, StringComparison.Ordinal) .Replace(":", string.Empty, StringComparison.Ordinal); } if (ns.Value == Namespaces.OpcUa) { ns.Name = "OpcUa"; ns.XmlPrefix = ns.Prefix = "Opc.Ua"; } return ns; } /// /// Load namespaces /// /// /// /// /// public static List LoadNamespaces(IFileSystem fileSystem, string filePath) { if (fileSystem == null) { throw new ArgumentNullException(nameof(fileSystem)); } if (filePath == null) { throw new ArgumentNullException(nameof(filePath)); } UANodeSet? nodeset = Load(fileSystem, filePath); List models = []; if (nodeset!.Models != null) { foreach (ModelTableEntry model in nodeset.Models) { CollectModels(model, models); } } List namespaces = []; foreach (ModelTableEntry model in models) { Namespace ns = CreateNamespace(model); ModelTableEntry? topLevelModel = nodeset.Models! .FirstOrDefault(x => x.ModelUri == model.ModelUri); if (topLevelModel != null) { ns.FilePath = filePath; } namespaces.Add(ns); } return namespaces; } private static void ImportModel(IList namespaces, ModelTableEntry model) { Namespace? ns = namespaces.FirstOrDefault(x => x.Value == model.ModelUri); if (ns != null) { return; } ns = CreateNamespace(model); namespaces.Add(ns); if (model.RequiredModel != null) { foreach (ModelTableEntry ii in model.RequiredModel) { ImportModel(namespaces, ii); } } } private bool IsTypeOf(NodeId subTypeId, NodeId superTypeId) { if (!m_settings.NodesById.TryGetValue(subTypeId, out NodeDesign? node1)) { return false; } if (!m_settings.NodesById.TryGetValue(superTypeId, out NodeDesign? node2)) { return false; } if (node1 is TypeDesign td1 && node2 is TypeDesign td2) { XmlQualifiedName parentId = td1.BaseType; // A NodeSet can declare a type as its own subtype (the root // reference types routinely carry an inverse HasSubtype to // themselves). Without a visited set that walk never ends and // the generator hangs instead of reporting bad input. var visited = new HashSet(); while (parentId != null && visited.Add(parentId)) { if (parentId == td2.SymbolicId) { return true; } if (!m_settings.NodesByQName!.TryGetValue(parentId, out NodeDesign? parent)) { return false; } td1 = (parent as TypeDesign)!; if (td1?.BaseType == null) { return false; } parentId = td1.BaseType; } } return false; } private XmlQualifiedName ImportSymbolicName(UANode input) { XmlQualifiedName? browseName = ImportQualifiedName(input.BrowseName); if (!string.IsNullOrEmpty(input.SymbolicName)) { if (input.BrowseName!.Contains(":<", StringComparison.Ordinal) && !input.SymbolicName.EndsWith("_Placeholder", StringComparison.Ordinal)) { return new XmlQualifiedName(input.SymbolicName + "_Placeholder", browseName!.Namespace); } return new XmlQualifiedName(input.SymbolicName, browseName!.Namespace); } // A placeholder browse name "" (no explicit SymbolicName in the // NodeSet) follows the ModelCompiler convention of mapping to // "Name_Placeholder" so generated identifiers match those produced // from the equivalent ModelDesign source (e.g. a combined NodeSet // that incorporates a model authored as ModelDesign). The raw browse // name is used because ImportQualifiedName rewrites '<' and '>' to '_'. string? rawName = QualifiedName.Parse(input.BrowseName!).Name; if (rawName != null && rawName.Length > 2 && rawName[0] == '<' && rawName[^1] == '>') { return new XmlQualifiedName( ToSymbolicName(rawName[1..^1]) + "_Placeholder", browseName!.Namespace); } // ImportQualifiedName replaces a leading digit with '_', which // ToSymbolicName then turns into 'x'. Where that lossy name clashes // (see CollectDigitPreservingNames) keep the digit, following the // ToSymbolicName convention for a leading digit ("1Axis" -> // "n1Axis"). if (m_digitPreservingNames.Contains(input.NodeId!) && browseName!.Name.Length > 0 && rawName is { Length: > 0 }) { return new XmlQualifiedName( ToSymbolicName(rawName[0] + browseName.Name[1..]), browseName.Namespace); } return new XmlQualifiedName(ToSymbolicName(browseName!.Name), browseName.Namespace); } private static LocalizedText? ImportLocalizedText(Export.LocalizedText[]? input) { if (input == null || input.Length == 0) { return null; } // The design schema carries a single, locale-less default text. A // NodeSet may list several translations in any order, so prefer the // invariant or English entry over whichever happens to come first, // and never let an empty first entry hide a later translation. Export.LocalizedText? selected = Array.Find(input, x => !string.IsNullOrWhiteSpace(x?.Value) && string.IsNullOrEmpty(x.Locale)) ?? Array.Find(input, x => !string.IsNullOrWhiteSpace(x?.Value) && IsEnglishLocale(x.Locale)) ?? Array.Find(input, x => !string.IsNullOrWhiteSpace(x?.Value)); if (selected == null) { return null; } return new LocalizedText { Value = selected.Value, IsAutogenerated = false }; } private static bool IsEnglishLocale(string? locale) { return locale != null && (string.Equals(locale, "en", StringComparison.OrdinalIgnoreCase) || locale.StartsWith("en-", StringComparison.OrdinalIgnoreCase)); } private ReferenceTypeDesign? FindReferenceType(ExpandedNodeId targetId) { if (m_settings.NodesById.TryGetValue((NodeId)targetId, out NodeDesign? design)) { return design as ReferenceTypeDesign; } if (m_index.TryGetValue((NodeId)targetId, out UANode? node) && node is UAReferenceType rt) { return ImportReferenceType(rt); } return null; } private T? FindNode(ExpandedNodeId targetId) where T : NodeDesign { if (targetId.IsNull) { return default; } if (m_settings.NodesById.TryGetValue((NodeId)targetId, out NodeDesign? design)) { return design as T; } if (m_index.TryGetValue((NodeId)targetId, out UANode? node)) { return ImportNode(node) as T; } return null; } private T? FindSuperType(UAType source) where T : TypeDesign { if (source.References != null) { foreach (Export.Reference reference in source.References) { NodeId rtid = ImportNodeId(reference.ReferenceType); if (rtid.IsNull || ReferenceTypeIds.HasSubtype != rtid || reference.IsForward) { continue; } NodeId targetId = ImportNodeId(reference.Value); if (targetId.IsNull) { return default; } return FindNode(targetId); } } return null; } private ReferenceTypeDesign ImportReferenceType(UAReferenceType input) { NodeId nodeId = ImportNodeId(input.NodeId); NodeDesign? existing = null; if (nodeId.IsNull || m_settings.NodesById.TryGetValue(nodeId, out existing)) { if (existing is ReferenceTypeDesign rt) { return rt; } throw new InvalidDataException( $"Node exists and it is not a ReferenceType: {existing!.SymbolicId}'."); } var output = new ReferenceTypeDesign { SymbolicName = ImportSymbolicName(input), BrowseName = QualifiedName.Parse(input.BrowseName!).Name, Description = ImportLocalizedText(input.Description), DisplayName = ImportLocalizedText(input.DisplayName), WriteAccess = input.WriteMask, IsAbstract = input.IsAbstract, InverseName = ImportLocalizedText(input.InverseName), Symmetric = input.Symmetric, SymmetricSpecified = true, ReleaseStatus = ImportReleaseStatus(input.ReleaseStatus), Category = ImportCategories(input.Category) }; output.SymbolicId = output.SymbolicName; output.SetIdentifier(ImportIdentifier(nodeId)); // is optional in the schema. foreach (Export.Reference ii in input.References ?? []) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId == ReferenceTypes.HasSubtype && reference.IsInverse) { ReferenceTypeDesign? superType = FindReferenceType(reference.TargetId); if (superType != null) { output.BaseType = superType.SymbolicId; output.BaseTypeNode = superType; } break; } } if (output.BaseType == null) { throw new InvalidDataException( $"Could not find supertype for '{input.BrowseName}'."); } m_settings.NodesByQName![output.SymbolicId] = output; m_settings.NodesById[nodeId] = output; return output; } private static NodeDesign CreateNodeDesign(UANode input) { if (input is UAObjectType) { return new ObjectTypeDesign(); } if (input is UAVariableType) { return new VariableTypeDesign(); } if (input is UADataType) { return new DataTypeDesign(); } if (input is UAReferenceType) { return new ReferenceTypeDesign(); } if (input is UAObject) { return new ObjectDesign(); } if (input is UAVariable) { return new VariableDesign(); } if (input is UAMethod) { return new MethodDesign(); } if (input is UAView) { return new ViewDesign(); } throw new InvalidDataException( $"Object is not a valid NodeClass: '{input.BrowseName}/{input.GetType().Name}'."); } private void UpdateTypeDesign(UAType input, TypeDesign output) { if (input == null || output == null) { return; } output.ClassName = output.SymbolicName.Name; if (output.ClassName.EndsWith("Type", StringComparison.Ordinal)) { output.ClassName = output.ClassName[..^"Type".Length]; } output.IsAbstract = input.IsAbstract; // is optional in the schema. A root type declares no // base type and can legitimately arrive without one. foreach (Export.Reference ii in input.References ?? []) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId == ReferenceTypes.HasSubtype && reference.IsInverse) { TypeDesign? superType = FindNode(reference.TargetId); if (superType != null) { output.BaseType = superType.SymbolicId; output.BaseTypeNode = superType; } break; } } if (output.BaseType == null) { throw new InvalidDataException( $"Could not find supertype for '{input.BrowseName}'."); } } private void UpdateVariableTypeDesign(UAVariableType? input, VariableTypeDesign output) { if (input == null || output == null) { return; } UpdateTypeDesign(input, output); output.DefaultValue = input.Value; output.ValueRankSpecified = true; output.ValueRank = ImportValueRank(input.ValueRank); output.ValueRankSpecified = true; output.ArrayDimensions = ImportArrayDimensions(input.ValueRank, input.ArrayDimensions); if (input.Value != null) { XmlDecoder decoder = CreateValueDecoder(input.Value, out NamespaceTable valueNamespaceUris); output.DecodedValue = decoder .ReadVariantValue(null, default) .AsBoxedObject(Variant.BoxingBehavior.Legacy); // The value keeps the NodeSet's own indexes; record their table. output.DecodedValueNamespaceUris = valueNamespaceUris; decoder.Close(); } DataTypeDesign dataType = FindNode(ImportNodeId(input.DataType)) ?? throw new InvalidDataException( $"Could not find DataType Node for '{input.BrowseName}/{input.DataType}'."); output.DataType = dataType.SymbolicId; output.DataTypeNode = dataType; } private void UpdateObjectTypeDesign(UAObjectType? input, ObjectTypeDesign output) { if (input == null || output == null) { return; } UpdateTypeDesign(input, output); } private void UpdateReferenceTypeDesign(UAReferenceType? input, ReferenceTypeDesign output) { if (input == null || output == null) { return; } UpdateTypeDesign(input, output); output.InverseName = ImportLocalizedText(input.InverseName); output.Symmetric = input.Symmetric; output.SymmetricSpecified = true; if (!output.Symmetric && output.InverseName == null) { output.InverseName = new LocalizedText { Value = output.BrowseName ?? output.SymbolicName.Name, IsAutogenerated = false }; } } private bool IsTypeOf(UAType subtype, NodeId superTypeId) { NodeId nodeId = ImportNodeId(subtype.NodeId); if (nodeId.IsNull) { return false; } if (nodeId == superTypeId) { return true; } TypeDesign? parent = FindSuperType(subtype); // A root type may declare itself as its own supertype (see the // other IsTypeOf overload); stop instead of walking that forever. var visited = new HashSet(); while (parent != null && visited.Add(parent.SymbolicId)) { var parentId = new NodeId( parent.NumericId, (ushort)m_settings.NamespaceUris.GetIndex( parent.SymbolicId.Namespace)); if (parentId == superTypeId) { return true; } if (parent.BaseTypeNode == null) { return false; } parent = parent.BaseTypeNode; } return false; } private static bool IsLetterOrDigit(char ch) { if (ch is >= '0' and <= '9') { return true; } if (ch is >= 'A' and <= 'Z') { return true; } if (ch is >= 'a' and <= 'z') { return true; } return false; } /// /// The number of bits a numeric OptionSet can use: the width of the /// integer type it derives from (OPC 10000-3 5.8.2). A type that only /// derives from the abstract UInteger/Integer is allowed the 64 bits of /// the widest one. /// private int GetNumericOptionSetBitCount(UADataType dataType) { if (IsTypeOf(dataType, DataTypeIds.Byte) || IsTypeOf(dataType, DataTypeIds.SByte)) { return 8; } if (IsTypeOf(dataType, DataTypeIds.UInt16) || IsTypeOf(dataType, DataTypeIds.Int16)) { return 16; } if (IsTypeOf(dataType, DataTypeIds.UInt32) || IsTypeOf(dataType, DataTypeIds.Int32)) { return 32; } return 64; } private void UpdateDataTypeDesign(UADataType? input, DataTypeDesign output) { if (input == null || output == null) { return; } UpdateTypeDesign(input, output); output.IsStructure = false; output.IsUnion = false; output.IsEnumeration = input.Definition?.IsOptionSet ?? false; output.IsOptionSet = input.Definition?.IsOptionSet ?? false; output.BasicDataType = output.DetermineBasicDataType(); output.HasFields = false; output.Fields = null; output.HasEncodings = false; if (output.BasicDataType == BasicDataType.UserDefined && input.Definition == null) { output.IsStructure = true; } if (input.Definition != null) { bool isStructureOptionSet = IsTypeOf(input, DataTypeIds.OptionSet); if (isStructureOptionSet) { output.IsEnumeration = true; output.IsStructure = false; output.IsUnion = false; output.IsOptionSet = true; } else if (IsTypeOf(input, DataTypeIds.Structure)) { output.IsStructure = true; output.IsUnion = input.Definition.IsUnion; output.IsEnumeration = false; output.IsOptionSet = false; output.HasEncodings = true; } else if (IsTypeOf(input, DataTypeIds.Enumeration)) { output.IsEnumeration = true; output.IsStructure = false; output.IsUnion = false; output.IsOptionSet = false; } var fields = new List(); if (input.Definition.Field != null) { foreach (DataTypeField ii in input.Definition.Field) { string? symbolicName = ii.SymbolicName; if (string.IsNullOrEmpty(symbolicName)) { symbolicName = ToSymbolicName(ii.Name!); } var field = new Parameter { Name = symbolicName, Description = ImportLocalizedText(ii.Description), ArrayDimensions = ImportArrayDimensions(ii.ValueRank, ii.ArrayDimensions), ValueRank = ImportValueRank(ii.ValueRank), Parent = output }; if (ii.Name != field.Name) { field.DisplayName = new LocalizedText { Value = ii.Name, IsAutogenerated = false }; } DataTypeDesign dataType = FindNode(ImportNodeId(ii.DataType)) ?? throw new InvalidDataException( "Could not find DataType Node for Field " + $"'{input.BrowseName}/{ii.Name}/{ii.DataType}'."); field.DataType = dataType.SymbolicId; field.DataTypeNode = dataType; field.AllowSubTypes = ii.AllowSubTypes; field.IsOptional = ii.IsOptional; if (output.IsOptionSet) { // The Value of an OptionSet field is its bit // position (OPC 10000-3 8.52). It defaults to -1 // when absent. A numeric OptionSet can only use the // bits of its base integer type (OPC 10000-3 5.8.2: // 8 for a Byte, 32 for a UInt32, ...), while a // subtype of the OptionSet structure (8.40) carries // its bits in a ByteString with no upper bound, so // the position is kept in OptionSetBit (the decimal // Identifier mask only reaches bit 95). A field // without a usable position is reported and left // out, so one bad field does not abort the import // of every model. int maxBits = isStructureOptionSet ? int.MaxValue : GetNumericOptionSetBitCount(input); if (ii.Value < 0 || ii.Value >= maxBits) { m_logger.LogError( "OptionSet field '{DataType}/{Field}' has an invalid bit position ({Value}); " + "expected a Value between 0 and {MaxBit}. The field is ignored.", input.BrowseName, ii.Name, ii.Value, maxBits - 1); continue; } field.OptionSetBit = ii.Value; field.BitMask = ModelDesignExtensions.GetOptionSetBitMask(ii.Value); field.Identifier = ModelDesignExtensions.GetOptionSetMask(ii.Value); field.IdentifierSpecified = true; } else if (output.IsEnumeration) { field.BitMask = null; field.Identifier = ii.Value; field.IdentifierSpecified = true; } fields.Add(field); } } output.HasFields = fields.Count > 0; output.Fields = [.. fields]; } } private void UpdateInstanceDesign(UAInstance input, InstanceDesign output) { if (input == null || output == null) { return; } output.ModellingRule = ModellingRule.None; output.ModellingRuleSpecified = false; // is optional in the schema. An instance without one // has no type definition either, which the check below reports. foreach (Export.Reference ii in input.References ?? []) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId == ReferenceTypes.HasTypeDefinition && !reference.IsInverse) { TypeDesign? typeDefinition = FindNode(reference.TargetId); if (typeDefinition != null) { output.TypeDefinition = typeDefinition.SymbolicId; output.TypeDefinitionNode = typeDefinition; } } if (reference.ReferenceTypeId == ReferenceTypes.HasModellingRule && !reference.IsInverse) { output.ModellingRule = ImportModellingRule(reference.TargetId); output.ModellingRuleSpecified = true; } } if (output.TypeDefinition == null) { throw new InvalidDataException($"Could not find TypeDefinition for '{input.BrowseName}'."); } } private void UpdateObjectDesign(UAObject? input, ObjectDesign output) { if (input == null || output == null) { return; } UpdateInstanceDesign(input, output); output.SupportsEvents = (input.EventNotifier & EventNotifiers.SubscribeToEvents) != 0; output.SupportsEventsSpecified = true; } private static void UpdateViewDesign(UAView? input, ViewDesign output) { if (input == null || output == null) { return; } output.SupportsEvents = (input.EventNotifier & EventNotifiers.SubscribeToEvents) != 0; output.ContainsNoLoops = input.ContainsNoLoops; } private void UpdateVariableDesign(UAVariable? input, VariableDesign output) { if (input == null || output == null) { return; } UpdateInstanceDesign(input, output); output.DefaultValue = input.Value; output.ValueRank = ImportValueRank(input.ValueRank); output.ValueRankSpecified = true; output.ArrayDimensions = ImportArrayDimensions(input.ValueRank, input.ArrayDimensions); output.MinimumSamplingInterval = ImportMinimumSamplingInterval( input.MinimumSamplingInterval, input.BrowseName!); output.MinimumSamplingIntervalSpecified = true; output.Historizing = input.Historizing; output.HistorizingSpecified = input.Historizing; output.AccessLevel = ImportAccessLevel(input.AccessLevel); output.AccessLevelSpecified = true; // The ModelDesign AccessLevel enumeration cannot express // combinations such as CurrentRead | HistoryRead (5), so the // verbatim bitmask is carried alongside it and preferred by // code generation. See GetAccessLevelAsCode. output.RawAccessLevel = input.AccessLevel; // Carry the explicit UserAccessLevel when the NodeSet2 model sets it. // When the attribute is absent the UserAccessLevel is derived from // AccessLevel by code generation, matching the runtime importer. output.RawUserAccessLevel = input.UserAccessLevelSpecified ? input.UserAccessLevel : (uint?)null; if (input.Value != null) { XmlDecoder decoder = CreateValueDecoder(input.Value, out NamespaceTable valueNamespaceUris); output.DecodedValue = decoder .ReadVariantValue(null, default) .AsBoxedObject(Variant.BoxingBehavior.Legacy); // The value keeps the NodeSet's own indexes; record their table. output.DecodedValueNamespaceUris = valueNamespaceUris; decoder.Close(); } DataTypeDesign dataType = FindNode(ImportNodeId(input.DataType)) ?? throw new InvalidDataException( $"Could not find DataType Node for '{input.BrowseName}/{input.DataType}'."); output.DataType = dataType.SymbolicId; output.DataTypeNode = dataType; } private List ImportArguments( MethodDesign method, string sourceNodeSetUri, System.Xml.XmlElement input) { var output = new List(); if (input == null) { return output; } XmlDecoder decoder = CreateDecoder(input, sourceNodeSetUri); Variant value = decoder.ReadVariantValue(null, default); decoder.Close(); foreach (Argument argument in value.GetStructureArray()) { DataTypeDesign dataType = FindNode(argument.DataType) ?? throw new InvalidDataException( $"Method ({method.SymbolicId.Name}) argument ({argument.Name}) " + $"datatype ({argument.DataType}) not found."); var parameter = new Parameter { Name = argument.Name, ArrayDimensions = ImportArrayDimensions( argument.ValueRank, ImportArrayDimensions(argument.ArrayDimensions)), ValueRank = ImportValueRank(argument.ValueRank), Parent = method, DataType = dataType.SymbolicId, DataTypeNode = dataType, Description = null }; if (!string.IsNullOrEmpty(argument.Description.Text)) { parameter.Description = new LocalizedText { Value = argument.Description.Text, IsAutogenerated = false }; } output.Add(parameter); } if (output.Count == 0) { output.AddRange(ImportArgumentsFromXml(method, input)); } return output; } private List ImportArgumentsFromXml( MethodDesign method, System.Xml.XmlElement input) { var output = new List(); foreach (System.Xml.XmlElement argument in FindElements(input, "Argument")) { string? name = GetElementValue(argument, "Name"); string? dataTypeText = GetElementValue(argument, "DataType", "Identifier"); NodeId dataTypeId = ImportNodeId(dataTypeText); DataTypeDesign dataType = FindNode(dataTypeId) ?? throw new InvalidDataException( $"Method ({method.SymbolicId.Name}) argument ({name}) datatype ({dataTypeText}) not found."); string? valueRankText = GetElementValue(argument, "ValueRank"); int valueRank = int.TryParse( valueRankText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedValueRank) ? parsedValueRank : -1; string? description = GetElementValue(argument, "Description", "Text"); output.Add(new Parameter { Name = name, ArrayDimensions = ImportArrayDimensions( valueRank, GetListElementValue(argument, "ArrayDimensions")), ValueRank = ImportValueRank(valueRank), Parent = method, DataType = dataType.SymbolicId, DataTypeNode = dataType, Description = string.IsNullOrEmpty(description) ? null : new LocalizedText { Value = description, IsAutogenerated = false } }); } return output; } private static IEnumerable FindElements( System.Xml.XmlElement element, string localName) { foreach (XmlNode child in element.ChildNodes) { if (child is not System.Xml.XmlElement childElement) { continue; } if (childElement.LocalName == localName) { yield return childElement; } foreach (System.Xml.XmlElement descendant in FindElements(childElement, localName)) { yield return descendant; } } } private static string? GetElementValue( System.Xml.XmlElement element, params string[] path) { System.Xml.XmlElement? current = element; foreach (string localName in path) { current = current?.ChildNodes .OfType() .FirstOrDefault(child => child.LocalName == localName); } return current?.InnerText; } /// /// Returns the child element texts of a list element (e.g. a /// ListOfUInt32) joined with ','. InnerText would concatenate them /// without a separator, turning dimensions 2 and 3 into "23". /// private static string? GetListElementValue( System.Xml.XmlElement element, string localName) { System.Xml.XmlElement? list = element?.ChildNodes .OfType() .FirstOrDefault(child => child.LocalName == localName); if (list == null) { return null; } string[] items = [ .. list.ChildNodes .OfType() .Select(child => child.InnerText.Trim()) ]; if (items.Length == 0) { string text = list.InnerText.Trim(); return text.Length == 0 ? null : text; } return string.Join(",", items); } private void UpdateMethodDesign(UAMethod? input, MethodDesign output) { if (input == null || output == null) { return; } output.ModellingRule = ModellingRule.None; output.ModellingRuleSpecified = false; if (input.References != null) { foreach (Export.Reference ii in input.References) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId == ReferenceTypes.HasModellingRule && !reference.IsInverse) { output.ModellingRule = ImportModellingRule(reference.TargetId); output.ModellingRuleSpecified = true; break; } } } output.NonExecutable = !input.Executable; output.NonExecutableSpecified = !input.Executable; if (input.MethodDeclarationId != null) { NodeId methodId = ImportNodeId(input.MethodDeclarationId); output.MethodDeclarationNode = FindNode(methodId); } UpdateMethodArguments(input, output); } /// /// Resolves explicit method declarations after parents are linked. Dependency payloads can carry /// a method's signature and CLR state identity without its instance NodeId; the NodeSet's explicit /// declaration identifier supplies that wire identity without changing the dependency model. /// private void ResolveInheritedMethodDeclarations() { foreach (UAMethod input in m_nodeset.Items!.OfType()) { if (string.IsNullOrEmpty(input.MethodDeclarationId) || !m_settings.NodesById.TryGetValue(ImportNodeId(input.NodeId), out NodeDesign? node) || node is not MethodDesign method || method.MethodDeclarationNode != null) { continue; } NodeId declarationId = ImportNodeId(input.MethodDeclarationId); if (declarationId.IsNull) { continue; } string? declarationNamespace = m_settings.NamespaceUris.GetString(declarationId.NamespaceIndex); TypeDesign? parentType = method.Parent switch { TypeDesign type => type, InstanceDesign instance => instance.TypeDefinitionNode, _ => null }; for (TypeDesign? type = parentType; type != null; type = type.BaseTypeNode) { MethodDesign? inherited = type.Children?.Items?.OfType().FirstOrDefault(candidate => candidate.SymbolicName == method.SymbolicName && candidate.SymbolicId.Namespace == declarationNamespace); if (inherited == null) { continue; } var declaration = (MethodDesign)inherited.Copy(); declaration.SetIdentifier(ImportIdentifier(declarationId)); method.MethodDeclarationNode = declaration; m_settings.NodesById[declarationId] = declaration; break; } } } private void UpdateMethodArguments(UAMethod input, MethodDesign output) { var propertyIds = new List(); if (input.References != null) { foreach (Export.Reference reference in input.References) { ReferenceNode? importedReference = ImportReference(reference); if (importedReference!.ReferenceTypeId == ReferenceTypes.HasProperty && !importedReference.IsInverse) { propertyIds.Add(importedReference.TargetId); } } } NodeId methodId = ImportNodeId(input.NodeId); foreach (UAVariable property in GetVariablesByParent(methodId)) { if (property.References == null) { continue; } if (property.References.Any(reference => { ReferenceNode? importedReference = ImportReference(reference); return importedReference!.ReferenceTypeId == ReferenceTypes.HasProperty && importedReference.IsInverse && importedReference.TargetId == methodId; })) { propertyIds.Add(ImportNodeId(property.NodeId)); } } foreach (ExpandedNodeId propertyId in propertyIds.Distinct()) { VariableDesign? property = FindNode(propertyId); if (property?.DefaultValue == null) { continue; } if (property.BrowseName == BrowseNames.InputArguments) { output.InputArguments = [.. ImportArguments(output, property.SymbolicId.Namespace, property.DefaultValue)]; output.HasArguments = true; } else if (property.BrowseName == BrowseNames.OutputArguments) { output.OutputArguments = [.. ImportArguments(output, property.SymbolicId.Namespace, property.DefaultValue)]; output.HasArguments = true; } } output.AssignMethodArgumentCodeNames(); } /// /// Returns the variables whose ParentNodeId is , /// in NodeSet order. The index is built once on first use (after the /// ParentNodeIds were normalized) instead of scanning every variable /// for each method, which was quadratic for large NodeSets. /// private List GetVariablesByParent(NodeId parentId) { if (m_variablesByParent == null) { m_variablesByParent = []; foreach (UAVariable variable in m_nodeset.Items!.OfType()) { NodeId key = ImportNodeId(variable.ParentNodeId); if (key.IsNull) { continue; } if (!m_variablesByParent.TryGetValue(key, out List? list)) { m_variablesByParent.Add(key, list = []); } list.Add(variable); } } return m_variablesByParent.TryGetValue(parentId, out List? variables) ? variables : []; } private void LinkChildToParent(UAInstance input) { NodeId nodeId = ImportNodeId(input.NodeId); NodeId parentId = ImportNodeId(input.ParentNodeId); if (nodeId.NamespaceIndex != parentId.NamespaceIndex) { return; } NodeDesign? referenceType = null; bool nonHierarchical = false; UANode parentNode = FindNode(parentId) ?? throw new InvalidDataException( $"ParentNode ({input.ParentNodeId}) not found for node " + $"{input.NodeId} ({input.BrowseName})."); if (!m_settings.NodesById.TryGetValue(parentId, out NodeDesign? parent)) { parent = ImportNode(parentNode); } // A hierarchical reference on either side is what makes the child a // child, so look for one on both sides before settling for a // non-hierarchical one. Taking the parent's non-hierarchical // reference first would shadow a perfectly good inverse HasComponent // on the child and drop the node out of the parent's Children. referenceType = FindHierarchicalReference(parentNode.References!, input.NodeId!, forward: true) ?? FindHierarchicalReference(input.References!, input.ParentNodeId!, forward: false); if (referenceType == null) { // The reference to look at on the parent side is its forward // reference to this child - scanning the child for a reference // to itself never matches anything. referenceType = FindAnyReference(parentNode.References!, input.NodeId!, forward: true) ?? FindAnyReference(input.References!, input.ParentNodeId!, forward: false); nonHierarchical = referenceType != null; } if (referenceType == null) { throw new InvalidDataException( $"HierarchicalReference from ParentNode ({input.ParentNodeId}) to Node " + $"{input.NodeId} ({input.BrowseName}) not found."); } if (nonHierarchical) { // Only a hierarchical reference makes the node a child. Keeping // the ParentNodeId without linking would leave the node neither // in its parent's Children nor among the top-level items, so it // (and its subtree) vanished from the model. Import it as a // top-level node instead; ImportReferences keeps the // non-hierarchical reference to the parent as an explicit one. input.ParentNodeId = null; return; } if (m_settings.NodesById.TryGetValue(nodeId, out NodeDesign? child)) { LinkChildToParent(parent, referenceType.SymbolicId, (child as InstanceDesign)!); } } /// /// Returns the reference type of the last hierarchical reference in /// that points at /// in the requested direction, or null when there is none. /// private NodeDesign? FindHierarchicalReference( Export.Reference[] references, string targetId, bool forward) { return FindReference(references, targetId, forward, hierarchicalOnly: true); } /// /// Same as but accepting any /// reference type, hierarchical or not. /// private NodeDesign? FindAnyReference( Export.Reference[] references, string targetId, bool forward) { return FindReference(references, targetId, forward, hierarchicalOnly: false); } private NodeDesign? FindReference( Export.Reference[] references, string targetId, bool forward, bool hierarchicalOnly) { NodeDesign? referenceType = null; foreach (Export.Reference reference in references ?? []) { if (reference.Value != targetId || reference.IsForward != forward) { continue; } NodeId referenceTypeId = ImportNodeId(reference.ReferenceType); if (hierarchicalOnly && !IsTypeOf(referenceTypeId, ReferenceTypeIds.HierarchicalReferences)) { continue; } referenceType = FindReferenceType(referenceTypeId); } return referenceType; } private void LinkChildToParent( NodeDesign parent, XmlQualifiedName referenceTypeId, InstanceDesign child) { // The SymbolicId is kept exactly as the symbolic id pass assigned // it. A child of a de-duplicated parent (e.g. "Parameters_5002") // was renamed here to "_" after it had been // registered under its assigned id, which left NodesByQName keyed // by a name the generator never emitted, made the name depend on // the order in which children and grand-children were linked, and // disagreed with the ids GetImportedSymbols reports to identifier // (NodeIds.csv) sidecars. List children = []; if (parent.Children?.Items != null) { children.AddRange((parent.Children?.Items)!); } child.Parent = parent; child.ReferenceType = referenceTypeId; children.Add(child); parent.Children = new ListOfChildren { Items = [.. children] }; parent.HasChildren = true; } private NodeDesign ImportNode(UANode input) { NodeId nodeId = ImportNodeId(input.NodeId); if (m_settings.NodesById.TryGetValue(nodeId, out NodeDesign? existing)) { return existing; } NodeDesign output = CreateNodeDesign(input); output.SymbolicId = m_symbolicIds[input.NodeId!]; output.SymbolicName = ImportSymbolicName(input); output.Extensions = input.Extensions; output.SymbolicName = NormalizeSymbolicNameNamespace( input, output.SymbolicId, output.SymbolicName); if (HasCollisionSuffix(input)) { output.SymbolicName = new XmlQualifiedName( $"{output.SymbolicName!.Name}_{GetCollisionSuffix(input.NodeId!)}", output.SymbolicName.Namespace); } output.BrowseName = QualifiedName.Parse(input.BrowseName!).Name; output.Description = ImportLocalizedText(input.Description); output.DisplayName = ImportLocalizedText(input.DisplayName); output.WriteAccess = input.WriteMask; output.ReleaseStatus = ImportReleaseStatus(input.ReleaseStatus); output.Category = ImportCategories(input.Category); // All four identifier types of OPC 10000-3 5.2.2 are carried over. output.SetIdentifier(ImportIdentifier(nodeId)); m_settings.NodesByQName![output.SymbolicId] = output; m_settings.NodesById[nodeId] = output; switch (output) { case ObjectTypeDesign ot: UpdateObjectTypeDesign(input as UAObjectType, ot); break; case VariableTypeDesign vt: UpdateVariableTypeDesign(input as UAVariableType, vt); break; case ReferenceTypeDesign rt: UpdateReferenceTypeDesign(input as UAReferenceType, rt); break; case DataTypeDesign dt: UpdateDataTypeDesign(input as UADataType, dt); break; case ObjectDesign oi: UpdateObjectDesign(input as UAObject, oi); break; case VariableDesign vi: UpdateVariableDesign(input as UAVariable, vi); break; case MethodDesign mi: UpdateMethodDesign(input as UAMethod, mi); break; case ViewDesign wi: UpdateViewDesign(input as UAView, wi); break; } return output; } /// /// Whether the symbolic id pass had to de-duplicate the SymbolicId of /// a type. Such a type also gets the suffix on its SymbolicName (and /// hence its class name). The clash is recorded when it happens: a /// name that merely ends in "_<identifier>" ("Point_7" with i=7) /// was not de-duplicated. /// private bool HasCollisionSuffix(UANode node) { return node is UAType && m_collisionSuffixed.Contains(node.NodeId!); } /// /// The suffix appended to a clashing SymbolicId: the node's identifier, /// with every character that cannot appear in a C# identifier replaced /// by '_'. Numeric ids and identifier-safe string ids are used as is; /// a Guid ("6f1c...-..."), opaque (base64 '+', '/', '=') or string id /// such as "Line1.Motor" used to be inserted verbatim and produced a /// constant/class name that did not compile. /// private static string GetCollisionSuffix(string nodeId) { string identifier = NodeId.Parse(nodeId).IdentifierAsString; var builder = new StringBuilder(identifier.Length); foreach (char ch in identifier) { builder.Append(IsLetterOrDigit(ch) || ch == '_' ? ch : '_'); } return builder.ToString(); } internal static XmlQualifiedName? NormalizeSymbolicNameNamespace( UANode input, XmlQualifiedName symbolicId, XmlQualifiedName symbolicName) { if (input is UAType && symbolicId != null && symbolicName != null && symbolicId.Namespace != symbolicName.Namespace) { return new XmlQualifiedName(symbolicName.Name, symbolicId.Namespace); } return symbolicName; } /// /// Looks up a node of this NodeSet by its (imported) NodeId. Uses the /// index built by the constructor; a linear scan parsing every NodeId /// per lookup made large instance NodeSets quadratic. /// private UANode? FindNode(NodeId targetId) { if (targetId.IsNull) { return null; } return m_index.TryGetValue(targetId, out UANode? node) ? node : null; } private NodeId FindTarget( UANode source, NodeId referenceTypeId, bool isInverse, ExpandedNodeId targetId = default) { if (source.References == null) { return default; } foreach (Export.Reference ii in source.References) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId == referenceTypeId && reference.IsInverse == isInverse) { if (!targetId.IsNull && reference.TargetId != targetId) { continue; } return ExpandedNodeId.ToNodeId(reference.TargetId, m_settings.NamespaceUris); } } return default; } private XmlQualifiedName ImportAndFixSymbolicName(UANode input) { XmlQualifiedName name = ImportSymbolicName(input); NodeId typeDefinitionId = FindTarget(input, ReferenceTypeIds.HasTypeDefinition, false); if (typeDefinitionId == ObjectTypeIds.DataTypeEncodingType) { if (!string.IsNullOrEmpty(input.SymbolicName) && input.SymbolicName.Contains("Default", StringComparison.Ordinal) && input.SymbolicName.Contains("XML", StringComparison.OrdinalIgnoreCase) && input.SymbolicName != "DefaultXml") { throw new InvalidDataException( $"{input.SymbolicName} is not a valid symbolic name for a " + "DataTypeEncoding node (should be 'DefaultXml')."); } NodeId dataTypeId = FindTarget(input, ReferenceTypeIds.HasEncoding, true); UANode? dataType = dataTypeId.IsNull ? FindDataTypeByEncoding(ImportNodeId(input.NodeId)) : FindNode(dataTypeId); if (dataType != null) { dataTypeId = ImportNodeId(dataType.NodeId); return new XmlQualifiedName( $"{GetDataTypeSymbolicName(dataType)}_Encoding_{name.Name}", m_settings.NamespaceUris.GetString(dataTypeId.NamespaceIndex)); } } return name; } /// /// Returns the first DataType of this NodeSet that declares a forward /// HasEncoding reference to . Used for an /// encoding without the inverse reference; the index is built once /// instead of scanning all DataTypes per encoding. /// private UANode? FindDataTypeByEncoding(NodeId encodingId) { if (m_dataTypesByEncoding == null) { m_dataTypesByEncoding = []; foreach (UANode dataType in m_nodeset.Items!.Where(x => x is UADataType)) { foreach (Export.Reference ii in dataType.References ?? []) { ReferenceNode? reference = ImportReference(ii); if (reference!.ReferenceTypeId != ReferenceTypeIds.HasEncoding || reference.IsInverse) { continue; } NodeId targetId = ExpandedNodeId.ToNodeId( reference.TargetId, m_settings.NamespaceUris); if (!targetId.IsNull) { m_dataTypesByEncoding.TryAdd(targetId, dataType); } } } } return m_dataTypesByEncoding.TryGetValue(encodingId, out UANode? node) ? node : null; } /// /// Returns the SymbolicName the DataType node ends up with, including the /// collision suffix appends to a type whose /// SymbolicId was de-duplicated. Its encodings must be named after that /// final name, which is what code generation looks them up by; the bare /// name left a de-duplicated structure with null encoding ids. /// private string GetDataTypeSymbolicName(UANode dataType) { string name = ImportSymbolicName(dataType).Name; if (HasCollisionSuffix(dataType)) { name += "_" + GetCollisionSuffix(dataType.NodeId!); } return name; } private void ImportReferences(UANode input) { NodeId nodeId = ImportNodeId(input.NodeId); if (!m_settings.NodesById.TryGetValue(nodeId, out NodeDesign? existing)) { return; } var references = new List(); if (existing.References != null) { references.AddRange(existing.References); } if (input.References != null) { foreach (Export.Reference ii in input.References) { NodeId referenceTypeId = ImportNodeId(ii.ReferenceType); NodeDesign? referenceType = FindNode(referenceTypeId); if (referenceType == null) { continue; } NodeId targetId = ImportNodeId(ii.Value); NodeDesign? target = FindNode(targetId) ?? FindOrCreateExternalTarget(targetId, nodeId); if (target == null) { continue; } if (referenceTypeId == ReferenceTypeIds.HasTypeDefinition || referenceTypeId == ReferenceTypeIds.HasSubtype || referenceTypeId == ReferenceTypeIds.HasModellingRule) { continue; } bool explicitReference = (ii.IsForward && referenceTypeId == ReferenceTypeIds.Organizes && target is ViewDesign) || targetId.NamespaceIndex != nodeId.NamespaceIndex || IsTypeOf(referenceTypeId, ReferenceTypeIds.NonHierarchicalReferences); if (!explicitReference) { if (ii.IsForward && !IsTypeOf(referenceTypeId, ReferenceTypeIds.HierarchicalReferences)) { continue; } // Ownership represents only the selected reference type, not // every hierarchical link between these nodes. NodeDesign parent = ii.IsForward ? existing : target; NodeDesign child = ii.IsForward ? target : existing; if (child is InstanceDesign instance && instance.Parent == parent && instance.ReferenceType == referenceType.SymbolicId) { continue; } } references.Add(new Reference { ReferenceType = referenceType.SymbolicId, IsInverse = !ii.IsForward, TargetId = target.SymbolicId, TargetNode = target, SourceNode = existing }); } } if (references.Count > 0) { existing.HasReferences = true; existing.References = [.. references]; } } /// /// Returns a stand-in for the target of a reference into another /// namespace whose node is not part of any loaded design, for example /// the DeviceSet of DI organizing a device when DI is supplied by a /// referenced assembly. The dependency metadata of a referenced /// assembly carries types only, so such a target cannot be resolved /// and the reference used to be dropped. The stand-in carries only /// the NodeId the generated reference needs and is not generated. /// private ObjectDesign? FindOrCreateExternalTarget(NodeId targetId, NodeId sourceId) { if (targetId.IsNull || targetId.NamespaceIndex == 0 || targetId.NamespaceIndex == sourceId.NamespaceIndex) { return null; } string? namespaceUri = m_settings.NamespaceUris.GetString(targetId.NamespaceIndex); object? identifier = ImportIdentifier(targetId); if (string.IsNullOrEmpty(namespaceUri) || identifier == null) { return null; } // The identifier alone does not make a unique name: the identifier // type is dropped and punctuation is replaced (i=5001 and s=5001, // or s=A.B and s=A/B, give the same name). FindNode already // returns the stand-in of a NodeId seen before, so a name taken // here always belongs to another node and gets a numeric suffix. string baseName = "ExternalNode_" + GetCollisionSuffix(targetId.ToString()); var symbolicId = new XmlQualifiedName(baseName, namespaceUri); for (int suffix = 2; m_settings.NodesByQName!.ContainsKey(symbolicId); suffix++) { symbolicId = new XmlQualifiedName( baseName + "_" + suffix.ToString(CultureInfo.InvariantCulture), namespaceUri); } var target = new ObjectDesign { SymbolicId = symbolicId, SymbolicName = symbolicId, BrowseName = symbolicId.Name }; target.SetIdentifier(identifier); m_settings.NodesByQName![symbolicId] = target; m_settings.NodesById[targetId] = target; return target; } private static Permissions[] ToPermissions(PermissionType input) { var permissions = new List(); if ((input & PermissionType.Browse) != 0) { permissions.Add(Permissions.Browse); } if ((input & PermissionType.ReadRolePermissions) != 0) { permissions.Add(Permissions.ReadRolePermissions); } if ((input & PermissionType.WriteAttribute) != 0) { permissions.Add(Permissions.WriteAttribute); } if ((input & PermissionType.WriteRolePermissions) != 0) { permissions.Add(Permissions.WriteRolePermissions); } if ((input & PermissionType.WriteHistorizing) != 0) { permissions.Add(Permissions.WriteHistorizing); } if ((input & PermissionType.Read) != 0) { permissions.Add(Permissions.Read); } if ((input & PermissionType.Write) != 0) { permissions.Add(Permissions.Write); } if ((input & PermissionType.ReadHistory) != 0) { permissions.Add(Permissions.ReadHistory); } if ((input & PermissionType.InsertHistory) != 0) { permissions.Add(Permissions.InsertHistory); } if ((input & PermissionType.ModifyHistory) != 0) { permissions.Add(Permissions.ModifyHistory); } if ((input & PermissionType.DeleteHistory) != 0) { permissions.Add(Permissions.DeleteHistory); } if ((input & PermissionType.ReceiveEvents) != 0) { permissions.Add(Permissions.ReceiveEvents); } if ((input & PermissionType.Call) != 0) { permissions.Add(Permissions.Call); } if ((input & PermissionType.AddReference) != 0) { permissions.Add(Permissions.AddReference); } if ((input & PermissionType.RemoveReference) != 0) { permissions.Add(Permissions.RemoveReference); } if ((input & PermissionType.DeleteNode) != 0) { permissions.Add(Permissions.DeleteNode); } if ((input & PermissionType.AddNode) != 0) { permissions.Add(Permissions.AddNode); } return [.. permissions]; } private RolePermissionSet? ToPermissionSet(Export.RolePermission[]? input) { if (input == null) { return null; } var permissions = new List(); foreach (Export.RolePermission ii in input) { NodeId roleId = ImportNodeId(ii.Value); NodeDesign? role = FindNode(roleId); if (role == null) { continue; } permissions.Add(new RolePermission { Role = role.SymbolicId, Permission = ToPermissions((PermissionType)ii.Permissions) }); } if (permissions.Count == 0) { return null; } return new RolePermissionSet { RolePermission = [.. permissions] }; } /// /// Maps a NodeSet AccessRestrictions bit mask onto the design schema's /// enumeration. Returns null when the mask carries no restriction /// the schema can express - notably the empty mask (which means "no /// restrictions", not "encryption required") and a mask that only sets /// ApplyRestrictionsToBrowse. /// private static AccessRestrictions? ToAccessRestrictions(AccessRestrictionType input) { if ((input & AccessRestrictionType.EncryptionRequired) != 0) { input &= ~AccessRestrictionType.SigningRequired; } switch (input) { case AccessRestrictionType.SigningRequired: return AccessRestrictions.SigningRequired; case AccessRestrictionType.EncryptionRequired: return AccessRestrictions.EncryptionRequired; case AccessRestrictionType.SessionRequired: return AccessRestrictions.SessionRequired; case AccessRestrictionType.SigningRequired | AccessRestrictionType.SessionRequired: return AccessRestrictions.SessionWithSigningRequired; case AccessRestrictionType.EncryptionRequired | AccessRestrictionType.SessionRequired: return AccessRestrictions.SessionWithEncryptionRequired; case AccessRestrictionType.SigningRequired | AccessRestrictionType.ApplyRestrictionsToBrowse: return AccessRestrictions.SigningAndApplyToBrowseRequired; case AccessRestrictionType.EncryptionRequired | AccessRestrictionType.ApplyRestrictionsToBrowse: return AccessRestrictions.EncryptionAndApplyToBrowseRequired; case AccessRestrictionType.SessionRequired | AccessRestrictionType.ApplyRestrictionsToBrowse: return AccessRestrictions.SessionAndApplyToBrowseRequired; case AccessRestrictionType.SigningRequired | AccessRestrictionType.SessionRequired | AccessRestrictionType.ApplyRestrictionsToBrowse: return AccessRestrictions.SessionWithSigningAndApplyToBrowseRequired; case AccessRestrictionType.EncryptionRequired | AccessRestrictionType.SessionRequired | AccessRestrictionType.ApplyRestrictionsToBrowse: return AccessRestrictions.SessionWithEncryptionAndApplyToBrowseRequired; } // An unrecognised combination - a reserved or vendor bit, possibly // alongside a bit the schema does know. Stay fail-closed: a bit this // mapping cannot name is still a restriction the NodeSet asked for, // and dropping it would publish the node with no protection at all. // Only a mask that demands nothing - empty, or nothing beyond // ApplyRestrictionsToBrowse, which restricts nothing on its own - // maps to "unspecified". if ((input & ~AccessRestrictionType.ApplyRestrictionsToBrowse) != 0) { return (input & AccessRestrictionType.ApplyRestrictionsToBrowse) != 0 ? AccessRestrictions.SessionWithEncryptionAndApplyToBrowseRequired : AccessRestrictions.SessionWithEncryptionRequired; } return null; } private void ImportPermissions(UANode input) { NodeId nodeId = ImportNodeId(input.NodeId); if (!m_settings.NodesById.TryGetValue(nodeId, out NodeDesign? existing)) { return; } existing.RolePermissions = ToPermissionSet(input.RolePermissions); AccessRestrictions? restrictions = input.AccessRestrictionsSpecified ? ToAccessRestrictions((AccessRestrictionType)input.AccessRestrictions) : null; existing.AccessRestrictions = restrictions ?? default; existing.AccessRestrictionsSpecified = restrictions.HasValue; } private XmlQualifiedName BuildSymbolicId(UANode node) { NodeId nodeId = ImportNodeId(node.NodeId, false); if (node is not UAInstance instance) { return new XmlQualifiedName( ImportAndFixSymbolicName(node).Name, m_settings.NamespaceUris.GetString(nodeId.NamespaceIndex)); } if (string.IsNullOrWhiteSpace(instance.ParentNodeId)) { return new XmlQualifiedName( ImportAndFixSymbolicName(instance).Name, m_settings.NamespaceUris.GetString(nodeId.NamespaceIndex)); } NodeId parentId = ImportNodeId(instance.ParentNodeId); if (parentId.NamespaceIndex != nodeId.NamespaceIndex) { return new XmlQualifiedName( ImportAndFixSymbolicName(instance).Name, m_settings.NamespaceUris.GetString(nodeId.NamespaceIndex)); } if (parentId.IsNull || !m_index.TryGetValue(parentId, out UANode? parent)) { throw new InvalidDataException( $"Parent node ({instance.ParentNodeId}) for {node.NodeId} not found."); } XmlQualifiedName symbolicId = BuildSymbolicId(parent); XmlQualifiedName symbolicName = ImportSymbolicName(node); if (nodeId.IsNull) { throw new InvalidDataException( $"Node ({symbolicId.Name}) does not have a valid NodeId."); } return new XmlQualifiedName( $"{symbolicId.Name}_{symbolicName.Name}", m_settings.NamespaceUris.GetString(nodeId.NamespaceIndex)); } /// /// Imports a node from the set. /// /// public ModelDesign Import(string? prefix, string? name) { var dictionary = new ModelDesign(); if (m_nodeset.Models == null || m_nodeset.Models.Length == 0) { throw new InvalidOperationException( $"NodeSet ({m_nodeset.NamespaceUris![0]}) does not have any Models defined!"); } if (m_nodeset.Models != null) { ModelTableEntry model = m_nodeset.Models[0]; dictionary.TargetNamespace = model.ModelUri; dictionary.TargetXmlNamespace = model.XmlSchemaUri; dictionary.TargetPublicationDate = model.PublicationDate; dictionary.TargetPublicationDateSpecified = model.PublicationDateSpecified; dictionary.TargetVersion = model.Version; List namespaces = []; ImportModel(namespaces, model); dictionary.Namespaces = [.. namespaces]; Namespace targetNamespace = namespaces[0]; if (name != null) { targetNamespace.Name = name; } if (prefix != null) { targetNamespace.XmlPrefix = targetNamespace.Prefix = prefix; } } AssignSymbolicIds(); foreach (UANode node in m_nodeset.Items!) { ImportNode(node); } foreach (UANode node in m_nodeset.Items) { if (node is UAMethod method && m_settings.NodesById.TryGetValue(ImportNodeId(method.NodeId), out NodeDesign? design) && design is MethodDesign methodDesign) { UpdateMethodArguments(method, methodDesign); } } foreach (UANode node in m_nodeset.Items) { if (node is UAInstance child && !string.IsNullOrWhiteSpace(child.ParentNodeId)) { LinkChildToParent(child); } } ResolveInheritedMethodDeclarations(); foreach (UANode node in m_nodeset.Items) { ImportReferences(node); } foreach (UANode node in m_nodeset.Items) { ImportPermissions(node); } List items = []; for (int ii = 0; ii < m_nodeset.Items.Length; ii++) { UANode node = m_nodeset.Items[ii]; if (node is UAInstance instance && instance.ParentNodeId != null) { continue; } NodeId nodeId = ImportNodeId(node.NodeId); if (!nodeId.IsNull && m_settings.NodesById.TryGetValue(nodeId, out NodeDesign? design)) { items.Add(design); } } Dictionary methods = []; CollectMethodDefinitions(dictionary.TargetNamespace!, methods); items.AddRange(methods .OrderBy(entry => entry.Key.Namespace, StringComparer.Ordinal) .ThenBy(entry => entry.Key.Name, StringComparer.Ordinal) .Select(entry => entry.Value)); foreach (NodeDesign item in items) { if (item is TypeDesign type) { string className = type.SymbolicName.Name; if (type is DataTypeDesign dt) { if (dt.HasFields) { foreach (Parameter field in dt.Fields) { if (field.Name == className) { type.ClassName = className + "DataType"; break; } } } } else if (type.HasChildren) { if (className.EndsWith("Type", StringComparison.Ordinal)) { className = className[..^"Type".Length]; } foreach (InstanceDesign child in type.Children.Items) { if (child.BrowseName == type.ClassName || child.SymbolicName.Name == type.ClassName + "State") { type.ClassName = className + (type is VariableTypeDesign ? "Variable" : "Object"); break; } } } } } dictionary.Items = [.. items]; dictionary.NamespaceUris = m_settings.NamespaceUris; return dictionary; } /// /// Normalizes the ParentNodeId of an instance node so the symbolic /// id derived from it is the one the model actually uses. Shared by the /// import pass and by , which must derive /// the same ids. /// private void NormalizeParentNodeId(UANode node) { if (node is not UAInstance instance) { return; } // View nodes are independent address-space nodes even when an // exporter sets ParentNodeId to an organizing folder. ViewState // derives from NodeState (not BaseInstanceState) and therefore // cannot be added as an AddChild component. Keeping the parent // would absorb the view into the folder's Children, exclude it // from the top-level model items and make the generator emit an // invalid AddChild(...) call. Clearing the parent keeps the view // a standalone predefined node linked purely via Organizes // references (mirrors the DataTypeEncoding handling below and the // way DataType/ReferenceType type nodes are modelled). if (node is UAView) { instance.ParentNodeId = null; } // ensure parents are in the same namespace. if (instance.ParentNodeId != null) { NodeId parentId = ImportNodeId(instance.ParentNodeId); NodeId childId = ImportNodeId(instance.NodeId); if (parentId.NamespaceIndex != childId.NamespaceIndex) { instance.ParentNodeId = null; } else if (FindTarget( node, ReferenceTypeIds.HasTypeDefinition, false) == ObjectTypeIds.DataTypeEncodingType) { // DataTypeEncoding objects are independent address-space // nodes even when an exporter sets ParentNodeId to the // owning DataType. Keeping that parent absorbs the // encoding into DataType.Children, excludes it from the // top-level model items and prevents the NodeManager // generator from registering the encoding node. instance.ParentNodeId = null; } return; } // handle missing ParentNodeId when an inverse reference exists. // is optional in the schema, so an instance can // arrive with neither a parent nor a reference list. if (instance.References == null) { return; } foreach (Export.Reference ii in instance.References .Where(x => !x.IsForward)) { NodeId referenceTypeId = ImportNodeId(ii.ReferenceType); if (referenceTypeId == ReferenceTypeIds.HasProperty || referenceTypeId == ReferenceTypeIds.HasComponent) { // Only a parent in the node's own namespace can own it - // the same rule as for an explicit ParentNodeId above. // An inferred parent elsewhere (e.g. a component added to // the ns=0 Server object) would leave the node neither a // child nor a top-level item, silently dropping it and // its whole subtree from the model. Such a node stays // top-level unless another inverse reference names an // owner in its namespace. if (ImportNodeId(ii.Value).NamespaceIndex != ImportNodeId(instance.NodeId).NamespaceIndex) { continue; } instance.ParentNodeId = ii.Value; break; } } } /// /// Assigns the SymbolicId of every node in the set. Shared by /// and , which must /// derive the same ids. /// private void AssignSymbolicIds() { m_symbolicIds.Clear(); m_collisionSuffixed.Clear(); foreach (UANode node in m_nodeset.Items!) { // hack to ensure DataTypeEncodings have right symbolic names. if (node is UAObject) { if (string.IsNullOrEmpty(node.SymbolicName)) { switch (node.BrowseName) { case BrowseNames.DefaultBinary: node.SymbolicName = nameof(BrowseNames.DefaultBinary); break; case BrowseNames.DefaultXml: node.SymbolicName = nameof(BrowseNames.DefaultXml); break; } } else if (node.SymbolicName == "DefaultXML") { node.SymbolicName = nameof(BrowseNames.DefaultXml); } } // Every ParentNodeId is normalized before any id is built: // BuildSymbolicId recurses into the parent, so a child listed // ahead of a parent whose ParentNodeId is only recovered from an // inverse reference (OPC 30050 PackML declares method arguments // before their methods) would otherwise be named from the // parent's bare name instead of its qualified one. NormalizeParentNodeId(node); } CollectDigitPreservingNames(); var usedIds = new HashSet(); var encodings = new List(); foreach (UANode node in m_nodeset.Items) { // A DataTypeEncoding is named after its DataType's final // SymbolicName, which is only known once the DataType's own id // (and any collision suffix) has been assigned. if (IsDataTypeEncoding(node)) { encodings.Add(node); continue; } AssignSymbolicId(node, usedIds); } foreach (UANode node in encodings) { AssignSymbolicId(node, usedIds); } } private bool IsDataTypeEncoding(UANode node) { return FindTarget(node, ReferenceTypeIds.HasTypeDefinition, false) == ObjectTypeIds.DataTypeEncodingType; } private void AssignSymbolicId(UANode node, HashSet usedIds) { XmlQualifiedName symbolicId = BuildSymbolicId(node); while (!usedIds.Add(symbolicId)) { symbolicId = new XmlQualifiedName( $"{symbolicId.Name}_{GetCollisionSuffix(node.NodeId!)}", symbolicId.Namespace); m_collisionSuffixed.Add(node.NodeId!); } m_symbolicIds[node.NodeId!] = symbolicId; } /// /// Finds the instances whose browse name starts with a digit and whose /// default SymbolicName therefore loses that digit ("1Axis" and "2Axis" /// both become "xAxis"). Where that makes the name clash with another /// node of a different browse name - which fails code generation or /// emits the wrong BrowseName constant - the digit is kept instead /// ("n1Axis"). Names that do not clash are left as they are so that /// existing generated identifiers stay stable. /// private void CollectDigitPreservingNames() { m_digitPreservingNames.Clear(); var browseNamesBySymbolicName = new Dictionary>(); var candidates = new List<(UANode Node, XmlQualifiedName Name)>(); foreach (UANode node in m_nodeset.Items!) { if (string.IsNullOrEmpty(node.BrowseName)) { continue; } XmlQualifiedName name = ImportSymbolicName(node); if (!browseNamesBySymbolicName.TryGetValue(name, out HashSet? browseNames)) { browseNamesBySymbolicName.Add(name, browseNames = new HashSet(StringComparer.Ordinal)); } browseNames.Add(QualifiedName.Parse(node.BrowseName).Name!); if (node is UAInstance && HasLeadingDigitBrowseName(node)) { candidates.Add((node, name)); } } foreach ((UANode node, XmlQualifiedName name) in candidates) { if (browseNamesBySymbolicName[name].Count > 1) { m_digitPreservingNames.Add(node.NodeId!); } } } /// /// Whether the default SymbolicName of the node is derived from a browse /// name that starts with a digit (the lossy case of ImportQualifiedName). /// private static bool HasLeadingDigitBrowseName(UANode node) { if (!string.IsNullOrEmpty(node.SymbolicName) || string.IsNullOrEmpty(node.BrowseName)) { return false; } string? rawName = QualifiedName.Parse(node.BrowseName).Name; return rawName != null && rawName.Length > 0 && rawName[0] is >= '0' and <= '9'; } internal IEnumerable GetImportedSymbols(string modelUri) { // Exactly the id assignment Import() applies, so the symbolic ids // the sidecar validator reports match the ones the import pass // derives. AssignSymbolicIds(); foreach (UANode node in m_nodeset.Items!) { NodeId nodeId = ImportNodeId(node.NodeId, false); if (!nodeId.TryGetValue(out uint numericId)) { continue; } XmlQualifiedName symbolicId = m_symbolicIds[node.NodeId!]; if (!string.Equals(symbolicId.Namespace, modelUri, StringComparison.Ordinal)) { continue; } yield return new NodesetImportedSymbol( symbolicId.Name, numericId, node switch { UAObject => NodeClass.Object, UAVariable => NodeClass.Variable, UAMethod => NodeClass.Method, UAObjectType => NodeClass.ObjectType, UAVariableType => NodeClass.VariableType, UAReferenceType => NodeClass.ReferenceType, UADataType => NodeClass.DataType, UAView => NodeClass.View, _ => NodeClass.Unspecified }); } } private void CollectMethodDefinitions( string targetNamespace, Dictionary methods) { // Index the explicit method nodes already present in the model by // their symbolic name. A concrete method with arguments normally // gets a synthesized "MethodType" declaration, but a combined // NodeSet may already ship that method-type node explicitly (e.g. // an incorporated companion specification method type). // Reuse the existing declaration in that case so code generation // does not emit two identifiers with the same name. Dictionary existingByName = []; foreach (NodeDesign node in m_settings.NodesById.Values) { if (node is MethodDesign existing && existing.SymbolicName != null && !existingByName.ContainsKey(existing.SymbolicName)) { existingByName.Add(existing.SymbolicName, existing); } } MethodDesign[] candidates = [ .. m_settings.NodesById.Values .OfType() .Where(method => method.SymbolicId.Namespace == targetNamespace && MethodDesignArgumentResolver.HasDeclaredArguments(method)) .OrderBy(method => method.SymbolicId.Namespace, StringComparer.Ordinal) .ThenBy(method => method.SymbolicId.Name, StringComparer.Ordinal) ]; var groupedCandidates = new Dictionary>>(); foreach (MethodDesign method in candidates) { if (method.MethodDeclarationNode != null) { MethodDesign definition = MethodDesignArgumentResolver.ResolveMethodDefinition( method.MethodDeclarationNode); if (MethodDesignArgumentResolver.HaveSameDeclaredSignature( method, definition)) { continue; } } // Skip methods whose BrowseName belongs to a base namespace // (e.g. the Core FileType Open/Close/Read/Write methods that a // FileType instance re-declares): they are instances of a // base-type method and must reuse that base method type rather // than get a synthesized method type in this model. if (method.SymbolicName != null && method.SymbolicName.Namespace != targetNamespace) { continue; } // Skip a standalone node that already is a method type (no owning // parent and the conventional "MethodType" name, e.g. an // incorporated companion specification method type. // Synthesizing a declaration for it would emit a spurious // "MethodTypeMethodType" node. A parentless method that is // merely a declaration target of another method still needs one. if (method.Parent == null && method.SymbolicName != null && method.SymbolicName.Name.EndsWith("MethodType", StringComparison.Ordinal)) { continue; } var baseName = new XmlQualifiedName( method.SymbolicName!.Name + "MethodType", method.SymbolicId.Namespace); if (!groupedCandidates.TryGetValue( baseName, out List>? signatureGroups)) { groupedCandidates.Add(baseName, signatureGroups = []); } List? signatureGroup = signatureGroups.FirstOrDefault( group => MethodDesignArgumentResolver.HaveSameDeclaredSignature( group[0], method)); if (signatureGroup == null) { signatureGroups.Add([method]); } else { signatureGroup.Add(method); } } var reservedNames = new HashSet( m_settings.NodesByQName!.Keys); foreach (KeyValuePair>> entry in groupedCandidates .OrderBy(entry => entry.Key.Namespace, StringComparer.Ordinal) .ThenBy(entry => entry.Key.Name, StringComparer.Ordinal)) { bool qualifyWithOwner = entry.Value.Count > 1 || reservedNames.Contains(entry.Key); foreach (List signatureGroup in entry.Value) { MethodDesign representative = signatureGroup[0]; string preferredName = qualifyWithOwner ? GetOwnerQualifiedMethodTypeName(representative) : entry.Key.Name; var name = new XmlQualifiedName( preferredName, representative.SymbolicId.Namespace); int suffix = 2; while (reservedNames.Contains(name) || methods.ContainsKey(name)) { string stem = preferredName.EndsWith( "MethodType", StringComparison.Ordinal) ? preferredName[..^"MethodType".Length] : preferredName; name = new XmlQualifiedName( stem + suffix.ToString(CultureInfo.InvariantCulture) + "MethodType", representative.SymbolicId.Namespace); suffix++; } // Prefer an explicit method-type declaration already in // the model over synthesizing a colliding duplicate. The // concrete method carries the authoritative argument // definitions, so copy them onto the reused declaration // to guarantee code generation emits the correct method // signature and result even when the incorporated // NodeSet declares the method-type argument nodes apart // from the concrete method. if (existingByName.TryGetValue(name, out MethodDesign? declared) && !signatureGroup.Any(method => ReferenceEquals(declared, method))) { declared.InputArguments = representative.InputArguments; declared.OutputArguments = representative.OutputArguments; declared.HasArguments = true; foreach (MethodDesign method in signatureGroup) { method.MethodDeclarationNode = declared; method.TypeDefinition = null; method.MethodType = null; } reservedNames.Add(name); continue; } var declaration = new MethodDesign { SymbolicId = name, SymbolicName = name, BrowseName = name.Name, DisplayName = new LocalizedText { Value = name.Name, IsAutogenerated = true }, InputArguments = representative.InputArguments, OutputArguments = representative.OutputArguments, HasArguments = true }; methods.Add(declaration.SymbolicName, declaration); reservedNames.Add(declaration.SymbolicName); foreach (MethodDesign method in signatureGroup) { method.MethodDeclarationNode = declaration; method.TypeDefinition = null; method.MethodType = null; } } } } private static string GetOwnerQualifiedMethodTypeName(MethodDesign method) { string ownerName = method.SymbolicId.Name; string methodSuffix = "_" + method.SymbolicName.Name; if (ownerName.EndsWith(methodSuffix, StringComparison.Ordinal)) { ownerName = ownerName[..^methodSuffix.Length]; } ownerName = ownerName.Replace("_", string.Empty, StringComparison.Ordinal); return method.SymbolicName.Name + ownerName + "MethodType"; } /// /// Creates an decoder to restore Variant values. /// private XmlDecoder CreateDecoder(System.Xml.XmlElement source, string? sourceNodeSetUri = null) { return CreateDecoder(source, sourceNodeSetUri, mapNamespaces: true, out _); } /// /// Creates a decoder for a Variable/VariableType value. The namespace /// indexes inside the value are left as the NodeSet wrote them, and /// is the NodeSet's own table they /// refer to. The design validator decodes the same XML again without any /// mapping, so the decoded value must mean the same thing either way. /// private XmlDecoder CreateValueDecoder( System.Xml.XmlElement source, out NamespaceTable sourceNamespaceUris) { return CreateDecoder(source, null, mapNamespaces: false, out sourceNamespaceUris); } private XmlDecoder CreateDecoder( System.Xml.XmlElement source, string? sourceNodeSetUri, bool mapNamespaces, out NamespaceTable sourceNamespaceUris) { // The factory knows the standard OPC UA encodeable types. Without them, structured // NodeSet2 values such as method Argument lists (InputArguments/OutputArguments) // cannot be decoded and the generated typed method state would lose its arguments // and result fields. var namespaceUris = new DecodedValueNamespaceTable(m_serverUris); if (sourceNodeSetUri == null || !m_settings.NamespaceTables!.TryGetValue(sourceNodeSetUri, out string[]? nodeSetNamespaceUris)) { nodeSetNamespaceUris = m_nodeset.NamespaceUris; } if (nodeSetNamespaceUris != null) { for (int ii = 0; ii < nodeSetNamespaceUris.Length; ii++) { namespaceUris.Append(nodeSetNamespaceUris[ii]); } } sourceNamespaceUris = namespaceUris; var messageContext = new ServiceMessageContext(m_telemetry, s_valueDecodingFactory); messageContext.NamespaceUris = mapNamespaces ? m_settings.NamespaceUris : namespaceUris; messageContext.ServerUris = m_serverUris; var decoder = new XmlDecoder((XmlElement)source, messageContext) { // pretty-printed NodeSets write empty strings as layout whitespace. TreatWhitespaceOnlyStringsAsEmpty = true }; var serverUris = new StringTable(); if (m_nodeset.ServerUris != null) { // Index 0 of a ServerUris table is the local server, which the // NodeSet's table does not list (its first entry // is svr=1). m_serverUris is seeded with a local placeholder, // so svr=n in a value maps onto m_nodeset.ServerUris[n-1]. serverUris.Append(m_serverUris.GetString(0)!); for (int ii = 0; ii < m_nodeset.ServerUris.Length; ii++) { serverUris.Append(m_nodeset.ServerUris[ii]); // The mapping is created without updating the target // table, so the remote servers must be known to it. m_serverUris.GetIndexOrAppend(m_nodeset.ServerUris[ii]); } } decoder.SetMappingTables(mapNamespaces ? namespaceUris : null, serverUris); return decoder; } /// /// Builds the factory used to decode Variable values and Method arguments out of a NodeSet2. /// /// /// /// hands back a factory with /// no registered types, which silently loses every ExtensionObject in the NodeSet — most /// visibly a Method's InputArguments / OutputArguments, which are encoded as a /// list of . That produced argument-less MethodStates and ObjectType /// proxies for every NodeSet2-sourced model. /// /// /// Rather than hand-picking the types we happen to know about, every encodeable type exported /// from the assembly that defines the OPC UA built-ins is registered, so a NodeSet carrying any /// other standard structure decodes as well. The full set of known types lives in /// Opc.Ua.Core.Types, which the generator deliberately does not reference; a model that /// needs a type from there declares it in its own NodeSet and the generator emits it. /// /// private static IEncodeableFactory CreateValueDecodingFactory() { IEncodeableFactory factory = ServiceMessageContext.CreateEmpty(null!).Factory; factory.AddEncodeableTypes(typeof(Argument).Assembly); return factory; } private static AccessLevel ImportAccessLevel(uint input) { if ((AccessLevelExType.CurrentRead & (AccessLevelExType)input) != 0) { if ((AccessLevelExType.CurrentWrite & (AccessLevelExType)input) != 0) { return AccessLevel.ReadWrite; } return AccessLevel.Read; } if ((AccessLevelExType.CurrentWrite & (AccessLevelExType)input) != 0) { return AccessLevel.Write; } if ((AccessLevelExType.HistoryRead & (AccessLevelExType)input) != 0) { if ((AccessLevelExType.HistoryWrite & (AccessLevelExType)input) != 0) { return AccessLevel.HistoryReadWrite; } return AccessLevel.HistoryRead; } if ((AccessLevelExType.HistoryWrite & (AccessLevelExType)input) != 0) { return AccessLevel.HistoryWrite; } return AccessLevel.None; } /// /// Access level extended type (subset) to support above conversion to /// model level AccessLevel enumeration values. /// [Flags] private enum AccessLevelExType : uint { /// /// No extensions /// None = 0, /// /// Current read /// CurrentRead = 1, /// /// Current write /// CurrentWrite = 2, /// /// History read /// HistoryRead = 4, /// /// History write /// HistoryWrite = 8 } private static ValueRank ImportValueRank(int input) { return input switch { > 1 => ValueRank.OneOrMoreDimensions, 1 => ValueRank.Array, 0 => ValueRank.OneOrMoreDimensions, -1 => ValueRank.Scalar, -2 => ValueRank.ScalarOrArray, -3 => ValueRank.ScalarOrOneDimension, < -3 => ValueRank.ScalarOrArray }; } private static ModellingRule ImportModellingRule(ExpandedNodeId input) { if (input == ObjectIds.ModellingRule_Mandatory) { return ModellingRule.Mandatory; } if (input == ObjectIds.ModellingRule_Optional) { return ModellingRule.Optional; } if (input == ObjectIds.ModellingRule_MandatoryPlaceholder) { return ModellingRule.MandatoryPlaceholder; } if (input == ObjectIds.ModellingRule_OptionalPlaceholder) { return ModellingRule.OptionalPlaceholder; } if (input == ObjectIds.ModellingRule_ExposesItsArray) { return ModellingRule.ExposesItsArray; } return ModellingRule.None; } private static string? ImportCategories(string[]? input) { if (input != null) { StringBuilder output = new(); foreach (string ii in input) { if (output.Length > 0) { output.Append(','); } output.Append(ii); } return output.ToString(); } return null; } private static ReleaseStatus ImportReleaseStatus(Export.ReleaseStatus input) { return input switch { Export.ReleaseStatus.Deprecated => ReleaseStatus.Deprecated, Export.ReleaseStatus.Draft => ReleaseStatus.Draft, _ => ReleaseStatus.Released }; } /// /// Imports a NodeId /// /// private ReferenceNode? ImportReference(Export.Reference source) { if (source == null) { return null; } NodeId referenceTypeId = ImportNodeId(source.ReferenceType, true); if (referenceTypeId.IsNull) { throw new InvalidDataException($"ReferenceType ({source.ReferenceType}) is not valid."); } ExpandedNodeId targetId = ImportExpandedNodeId(source.Value); return new ReferenceNode { ReferenceTypeId = referenceTypeId, IsInverse = !source.IsForward, TargetId = targetId }; } /// /// Returns the identifier of a NodeId in the representation used by /// . All four identifier types defined by /// OPC 10000-3 5.2.2 are supported. /// private static object? ImportIdentifier(NodeId nodeId) { if (nodeId.TryGetValue(out uint numericId)) { return numericId; } if (nodeId.TryGetValue(out string stringId)) { return stringId; } if (nodeId.TryGetValue(out Guid guidId)) { return guidId; } if (nodeId.TryGetValue(out ByteString opaqueId)) { return opaqueId; } return null; } /// /// Imports a NodeId /// private NodeId ImportNodeId(string? source, bool lookupAlias = true) { if (string.IsNullOrEmpty(source)) { return NodeId.Null; } // lookup alias. if (lookupAlias && m_aliases.TryGetValue(source, out NodeId nodeId)) { return nodeId; } // parse the string. nodeId = NodeId.Parse(source); if (nodeId.NamespaceIndex > 0) { ushort namespaceIndex = ImportNamespaceIndex( nodeId.NamespaceIndex, m_settings.NamespaceUris); nodeId = nodeId.WithNamespaceIndex(namespaceIndex); } return nodeId; } /// /// Imports a ExpandedNodeId /// private ExpandedNodeId ImportExpandedNodeId(string? source) { if (string.IsNullOrEmpty(source)) { return ExpandedNodeId.Null; } // lookup aliases if (m_nodeset.Aliases != null) { for (int ii = 0; ii < m_nodeset.Aliases.Length; ii++) { if (m_nodeset.Aliases[ii].Alias == source) { source = m_nodeset.Aliases[ii].Value; break; } } } // parse the node. var nodeId = ExpandedNodeId.Parse(source!); if (nodeId.ServerIndex <= 0 && nodeId.NamespaceIndex <= 0 && string.IsNullOrEmpty(nodeId.NamespaceUri)) { return nodeId; } uint serverIndex = ImportServerIndex( nodeId.ServerIndex, m_serverUris); ushort namespaceIndex = ImportNamespaceIndex( nodeId.NamespaceIndex, m_settings.NamespaceUris); if (serverIndex > 0) { string? namespaceUri = nodeId.NamespaceUri; if (string.IsNullOrEmpty(nodeId.NamespaceUri)) { namespaceUri = m_settings.NamespaceUris.GetString(namespaceIndex); } return nodeId.WithNamespaceUri(namespaceUri).WithServerIndex(serverIndex); } return nodeId.WithNamespaceIndex(namespaceIndex).WithServerIndex(0); } /// /// Imports a QualifiedName /// private XmlQualifiedName? ImportQualifiedName(string? source) { if (string.IsNullOrEmpty(source)) { return null; } var qname = QualifiedName.Parse(source); var builder = new StringBuilder(); foreach (char ch in qname.Name!) { if (builder.Length == 0) { if (!char.IsLetter(ch) && ch != '_') { builder.Append('_'); continue; } } else if (!char.IsLetterOrDigit(ch) && ch != '_' && ch != '.' && ch != '+') { builder.Append('_'); continue; } builder.Append(ch); } if (qname.NamespaceIndex > 0) { ushort namespaceIndex = ImportNamespaceIndex( qname.NamespaceIndex, m_settings.NamespaceUris); return new XmlQualifiedName( builder.ToString(), m_settings.NamespaceUris.GetString(namespaceIndex)); } return new XmlQualifiedName( builder.ToString(), m_settings.NamespaceUris.GetString(0)); } /// /// Imports the array dimensions. /// private static string? ImportArrayDimensions(ArrayOf arrayDimensions) { if (arrayDimensions.IsEmpty) { return null; } StringBuilder output = new(); foreach (uint ii in arrayDimensions) { if (output.Length > 0) { output.Append(','); } output.Append(ii); } return output.ToString(); } /// /// Returns the ArrayDimensions to carry into the design for the given /// NodeSet ValueRank. The design ValueRank enumeration cannot hold a /// rank above one (it maps to OneOrMoreDimensions) and code generation /// recovers the rank from the number of ArrayDimensions entries, so a /// ValueRank of n > 1 without ArrayDimensions (optional in NodeSet2) /// gets n unknown-length (0) dimensions instead of losing the rank. /// private static string? ImportArrayDimensions(int valueRank, string? arrayDimensions) { if (valueRank > 1 && string.IsNullOrWhiteSpace(arrayDimensions)) { return string.Join(",", Enumerable.Repeat("0", valueRank)); } return arrayDimensions; } /// /// OPC 10000-3 5.6.2: MinimumSamplingInterval is a Duration in ms where /// 0 means "continuous / exception based" and -1 "indeterminate"; no /// other negative value (and no NaN) is defined. The design schema /// carries the interval as an xs:int of whole milliseconds, so a /// fractional interval cannot be kept exactly: it is rounded up, since /// truncating 0.5 to 0 would turn "at least 0.5 ms" into "continuous". /// An undefined value is imported as -1 (indeterminate) with a warning, /// never as 0, which would claim continuous sampling. /// private int ImportMinimumSamplingInterval(double input, string browseName) { if (input == 0 || input == -1) { return (int)input; } if (double.IsNaN(input) || input < 0) { m_logger.LogWarning( "Variable '{BrowseName}' has an invalid MinimumSamplingInterval ({Value}); " + "only -1 (indeterminate), 0 (continuous) or a positive duration are defined. " + "It is imported as -1 (indeterminate).", browseName, input); return -1; } double rounded = Math.Ceiling(input); return rounded >= int.MaxValue ? int.MaxValue : (int)rounded; } /// /// Imports a namespace index. /// private ushort ImportNamespaceIndex(ushort namespaceIndex, NamespaceTable namespaceUris) { // nothing special required for indexes 0 and 1. if (namespaceIndex < 1) { return namespaceIndex; } // return a bad value if parameters are bad. if (namespaceUris == null || m_nodeset.NamespaceUris == null || m_nodeset.NamespaceUris.Length <= namespaceIndex - 1) { return ushort.MaxValue; } // find or append uri. return namespaceUris.GetIndexOrAppend(m_nodeset.NamespaceUris[namespaceIndex - 1]); } /// /// Imports a server index. /// private uint ImportServerIndex(uint serverIndex, StringTable serverUris) { // nothing special required for indexes 0. if (serverIndex <= 0) { return serverIndex; } // return a bad value if parameters are bad. if (serverUris == null || m_nodeset.ServerUris == null || m_nodeset.ServerUris.Length <= serverIndex - 1) { return ushort.MaxValue; } // find or append uri. return serverUris.GetIndexOrAppend(m_nodeset.ServerUris[serverIndex - 1]); } /// /// Convert to symbolic name. /// private static string ToSymbolicName(string name) { if (s_keywords.Contains(name)) { name = "_" + name; } StringBuilder output = new(); foreach (char ch in name) { if (!IsLetterOrDigit(ch)) { if (output.Length == 0) { output.Append('x'); continue; } output.Append('_'); continue; } if (output.Length == 0 && ch >= '0' && ch <= '9') { output.Append('n'); } output.Append(ch); } return output.ToString(); } /// /// Creates the server table used while importing. Index 0 is reserved /// for the local server; without an entry there the first remote /// server (svr=1 in the NodeSet) was appended at index 0 and treated /// as local. /// private static StringTable CreateServerUris() { var serverUris = new StringTable(); serverUris.Append(kLocalServerUri); return serverUris; } /// /// Placeholder for the (unknown) local server at index 0 of the server /// table. The converter has no running server. /// private const string kLocalServerUri = "urn:opcfoundation.org:SourceGeneration:LocalServer"; private static readonly string[] s_keywords = [ "private", "public", "protected", "internal", "lock", "char", "byte", "int", "uint", "long", "ulong", "float", "double", "decimal", "for", "foreach", "while", "string" ]; /// /// Encodeable factory used to decode NodeSet2 Value elements. A NodeSet2 encodes a /// Method's InputArguments/OutputArguments Property as a list of /// ExtensionObjects, so the decoder must be able to resolve that /// encoding id; an empty factory silently yields zero arguments and every generated method /// wrapper (NodeState handler and ObjectType proxy) would lose its parameters. /// private static readonly IEncodeableFactory s_valueDecodingFactory = CreateValueDecodingFactory(); private readonly NodeSetReaderSettings m_settings; private readonly ITelemetryContext m_telemetry; private readonly ILogger m_logger; private readonly IFileSystem m_fileSystem; private readonly StringTable m_serverUris = CreateServerUris(); private readonly UANodeSet m_nodeset; private readonly Dictionary m_aliases = []; private readonly Dictionary m_index; private readonly Dictionary m_symbolicIds; private readonly HashSet m_digitPreservingNames = []; private readonly HashSet m_collisionSuffixed = new(StringComparer.Ordinal); private Dictionary>? m_variablesByParent; private Dictionary? m_dataTypesByEncoding; } /// /// The namespace table a NodeSet value was decoded with, together with /// the server URI table its ExpandedNodeId server indexes refer to. Both /// indexes are local to the NodeSet (OPC 10000-6 F.2, F.14); the code /// generator resolves them to URIs and maps those at run time. Deriving /// from lets the server table travel wherever /// the design copies a node's decoded-value namespace table. /// internal sealed class DecodedValueNamespaceTable : NamespaceTable { /// /// Creates the table. /// /// The server URIs the decoded server /// indexes refer to (index 0 is the local server). public DecodedValueNamespaceTable(StringTable serverUris) { ServerUris = serverUris; } /// /// The server URIs the decoded server indexes refer to. /// public StringTable ServerUris { get; } } }