using System.Globalization; using System.Xml; using System.Xml.XPath; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; /// /// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K /// and carries the same tag / datatype / program shape, plus richer metadata (notably the /// AddOnInstructionDefinition catalogue and explicit TagType attributes). /// /// This parser produces the same bundle as /// so consumes both formats interchangeably. /// The two parsers share the post-parse downstream layer; the only difference is how the /// bundle is materialized from the source bytes. /// /// /// AOIs (AddOnInstructionDefinition) are surfaced as L5K-style UDT entries — their /// parameters become rows so AOI-typed tags pick up a member layout /// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call /// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without /// attempting to model their call semantics. /// /// /// /// Uses with an for read-only /// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats /// forward-only XmlReader on simplicity for the same throughput at this size class. /// The parser is permissive about missing optional attributes — a real export always has /// Name + DataType, but ExternalAccess defaults to Read/Write /// when absent (matching Studio 5000's own default for new tags). /// public static class L5xParser { public static L5kDocument Parse(IL5kSource source) { ArgumentNullException.ThrowIfNull(source); var xml = source.ReadAll(); using var reader = XmlReader.Create( new System.IO.StringReader(xml), new XmlReaderSettings { // L5X exports never include a DOCTYPE, but disable DTD processing defensively. DtdProcessing = DtdProcessing.Prohibit, IgnoreWhitespace = true, IgnoreComments = true, }); var doc = new XPathDocument(reader); var nav = doc.CreateNavigator(); var tags = new List(); var datatypes = new List(); // Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag")) { var t = ReadTag(tagNode, programScope: null); if (t is not null) tags.Add(t); } // Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program")) { var programName = programNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(programName)) continue; foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag")) { var t = ReadTag(tagNode, programName); if (t is not null) tags.Add(t); } } // UDTs: /RSLogix5000Content/Controller/DataTypes/DataType foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType")) { var udt = ReadDataType(dtNode); if (udt is not null) datatypes.Add(udt); } // AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout. // Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6. foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition")) { var aoi = ReadAddOnInstruction(aoiNode); if (aoi is not null) datatypes.Add(aoi); } return new L5kDocument(tags, datatypes); } private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope) { var name = tagNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(name)) return null; var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed var dataType = tagNode.GetAttribute("DataType", string.Empty); var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty); var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty); // Alias tags often omit DataType (it's inherited from the target). Surface them with // an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type. if (string.IsNullOrEmpty(dataType) && !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase)) { return null; } // Description child — L5X wraps description text in (sometimes inside CDATA). string? description = null; var descNode = tagNode.SelectSingleNode("Description"); if (descNode is not null) { var raw = descNode.Value; if (!string.IsNullOrEmpty(raw)) description = raw.Trim(); } return new L5kTag( Name: name, DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType, ProgramScope: programScope, ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess, Description: description, AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor); } private static L5kDataType? ReadDataType(XPathNavigator dtNode) { var name = dtNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(name)) return null; var members = new List(); foreach (XPathNavigator memberNode in dtNode.Select("Members/Member")) { var m = ReadMember(memberNode); if (m is not null) members.Add(m); } return new L5kDataType(name, members); } private static L5kMember? ReadMember(XPathNavigator memberNode) { var name = memberNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(name)) return null; // Skip auto-inserted hidden host members for backing storage of BOOL packing — they're // emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to // surface as OPC UA variables. if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null; var dataType = memberNode.GetAttribute("DataType", string.Empty); if (string.IsNullOrEmpty(dataType)) return null; var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty); int? arrayDim = null; var dimText = memberNode.GetAttribute("Dimension", string.Empty); if (!string.IsNullOrEmpty(dimText) && int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim) && dim > 0) { arrayDim = dim; } // Description child — same shape as on Tag nodes; sometimes wrapped in CDATA. string? description = null; var descNode = memberNode.SelectSingleNode("Description"); if (descNode is not null) { var raw = descNode.Value; if (!string.IsNullOrEmpty(raw)) description = raw.Trim(); } return new L5kMember( Name: name, DataType: dataType, ArrayDim: arrayDim, ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess, Description: description); } private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode) { var name = aoiNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(name)) return null; var members = new List(); foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter")) { var paramName = paramNode.GetAttribute("Name", string.Empty); if (string.IsNullOrEmpty(paramName)) continue; // RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true. // Skip them — they aren't part of the AOI's user-facing surface. var hidden = paramNode.GetAttribute("Hidden", string.Empty); if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue; var dataType = paramNode.GetAttribute("DataType", string.Empty); if (string.IsNullOrEmpty(dataType)) continue; var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty); int? arrayDim = null; var dimText = paramNode.GetAttribute("Dimension", string.Empty); if (!string.IsNullOrEmpty(dimText) && int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim) && dim > 0) { arrayDim = dim; } string? paramDescription = null; var paramDescNode = paramNode.SelectSingleNode("Description"); if (paramDescNode is not null) { var raw = paramDescNode.Value; if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim(); } // PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix // also serialises Local AOI tags inside , but those don't go through this // path — only / entries do — so any Usage value on a parameter // is one of the directional buckets. var usage = paramNode.GetAttribute("Usage", string.Empty); members.Add(new L5kMember( Name: paramName, DataType: dataType, ArrayDim: arrayDim, ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess, Description: paramDescription, Usage: string.IsNullOrEmpty(usage) ? null : usage)); } return new L5kDataType(name, members); } }