AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
238 lines
10 KiB
C#
238 lines
10 KiB
C#
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;
|
|
}
|
|
|
|
// 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<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;
|
|
}
|
|
|
|
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 <LocalTags>, but those don't go through this
|
|
// path — only <Parameters>/<Parameter> 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);
|
|
}
|
|
}
|