Auto: abcip-2.2 — L5X (XML) parser + ingest
Adds Import/L5xParser.cs that consumes Studio 5000 L5X (XML) controller exports via System.Xml.XPath and produces the same L5kDocument bundle as L5kParser, so L5kIngest handles both formats interchangeably. - Controller-scope and program-scope <Tag> elements with Name, DataType, TagType, ExternalAccess, AliasFor, and <Description> child. - <DataType>/<Members>/<Member> with Hidden BOOL-host (ZZZZZZZZZZ*) skip. - AddOnInstructionDefinitions surfaced as L5kDataType entries so AOI-typed tags pick up a member layout the same way UDT-typed tags do; hidden EnableIn/EnableOut parameters skipped. Full directional Input/Output/InOut modelling stays deferred to PR 2.6. AbCipDriverOptions gains parallel L5xImports collection (mirrors L5kImports field-for-field). InitializeAsync funnels both through one shared MergeImport helper that differs only in the parser delegate. Tests: 8 L5X fixtures cover controller- and program-scope tags, alias skip, UDT layout fan-out, AOI-typed tag, ZZZZZZZZZZ host skip, hidden AOI param skip, missing-ExternalAccess default, and an empty-controller no-throw. Closes #230
This commit is contained in:
211
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
211
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.XPath;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>TagType</c> attributes).
|
||||
/// <para>
|
||||
/// This parser produces the same <see cref="L5kDocument"/> bundle as
|
||||
/// <see cref="L5kParser"/> so <see cref="L5kIngest"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// AOIs (<c>AddOnInstructionDefinition</c>) are surfaced as L5K-style UDT entries — their
|
||||
/// parameters become <see cref="L5kMember"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <see cref="System.Xml.XPath"/> with an <see cref="XPathDocument"/> for read-only
|
||||
/// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats
|
||||
/// forward-only <c>XmlReader</c> on simplicity for the same throughput at this size class.
|
||||
/// The parser is permissive about missing optional attributes — a real export always has
|
||||
/// <c>Name</c> + <c>DataType</c>, but <c>ExternalAccess</c> defaults to <c>Read/Write</c>
|
||||
/// when absent (matching Studio 5000's own default for new tags).
|
||||
/// </remarks>
|
||||
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<L5kTag>();
|
||||
var datatypes = new List<L5kDataType>();
|
||||
|
||||
// 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 <Description> (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<L5kMember>();
|
||||
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;
|
||||
}
|
||||
|
||||
return new L5kMember(
|
||||
Name: name,
|
||||
DataType: dataType,
|
||||
ArrayDim: arrayDim,
|
||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess);
|
||||
}
|
||||
|
||||
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
|
||||
{
|
||||
var name = aoiNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var members = new List<L5kMember>();
|
||||
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;
|
||||
}
|
||||
|
||||
members.Add(new L5kMember(
|
||||
Name: paramName,
|
||||
DataType: dataType,
|
||||
ArrayDim: arrayDim,
|
||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess));
|
||||
}
|
||||
return new L5kDataType(name, members);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user