Merge pull request '[abcip] AbCip — L5X (XML) parser + ingest' (#347) from auto/abcip/2.2 into auto/driver-gaps

This commit was merged in pull request #347.
This commit is contained in:
2026-04-25 18:13:14 -04:00
4 changed files with 529 additions and 22 deletions

View File

@@ -130,28 +130,27 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var allTags = new List<AbCipTagDefinition>(_options.Tags);
foreach (var import in _options.L5kImports)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
throw new InvalidOperationException(
"AbCip L5K import is missing DeviceHostAddress — every imported tag needs a target device.");
IL5kSource? src = null;
if (!string.IsNullOrEmpty(import.FilePath))
src = new FileL5kSource(import.FilePath);
else if (!string.IsNullOrEmpty(import.InlineText))
src = new StringL5kSource(import.InlineText);
if (src is null) continue;
var doc = L5kParser.Parse(src);
var ingest = new L5kIngest
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
var result = ingest.Ingest(doc);
foreach (var importedTag in result.Tags)
{
if (declaredNames.Contains(importedTag.Name)) continue;
allTags.Add(importedTag);
declaredNames.Add(importedTag.Name);
}
MergeImport(
deviceHost: import.DeviceHostAddress,
filePath: import.FilePath,
inlineText: import.InlineText,
namePrefix: import.NamePrefix,
parse: L5kParser.Parse,
formatLabel: "L5K",
declaredNames: declaredNames,
allTags: allTags);
}
foreach (var import in _options.L5xImports)
{
MergeImport(
deviceHost: import.DeviceHostAddress,
filePath: import.FilePath,
inlineText: import.InlineText,
namePrefix: import.NamePrefix,
parse: L5xParser.Parse,
formatLabel: "L5X",
declaredNames: declaredNames,
allTags: allTags);
}
foreach (var tag in allTags)
@@ -194,6 +193,47 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
/// <summary>
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
/// only behavioural axis between the two formats. Adds the parser's tags to
/// <paramref name="allTags"/> while skipping any name already covered by an earlier
/// declaration or import (declared > L5K > L5X precedence falls out from call order).
/// </summary>
private static void MergeImport(
string deviceHost,
string? filePath,
string? inlineText,
string namePrefix,
Func<IL5kSource, L5kDocument> parse,
string formatLabel,
HashSet<string> declaredNames,
List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(deviceHost))
throw new InvalidOperationException(
$"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device.");
IL5kSource? src = null;
if (!string.IsNullOrEmpty(filePath))
src = new FileL5kSource(filePath);
else if (!string.IsNullOrEmpty(inlineText))
src = new StringL5kSource(inlineText);
if (src is null) return;
var doc = parse(src);
var ingest = new L5kIngest
{
DefaultDeviceHostAddress = deviceHost,
NamePrefix = namePrefix,
};
var result = ingest.Ingest(doc);
foreach (var importedTag in result.Tags)
{
if (declaredNames.Contains(importedTag.Name)) continue;
allTags.Add(importedTag);
declaredNames.Add(importedTag.Name);
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -32,6 +32,16 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
/// <summary>
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
/// so an L5X re-export of the same controller doesn't double-emit. See
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
/// </summary>
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -150,6 +160,22 @@ public sealed record AbCipL5kImportOptions(
string? InlineText = null,
string NamePrefix = "");
/// <summary>
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
/// two are kept as distinct types so configuration JSON makes the source format explicit
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a <c>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded L5X body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.</param>
public sealed record AbCipL5xImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
{

View 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 &lt;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);
}
}

View File

@@ -0,0 +1,230 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class L5xParserTests
{
[Fact]
public void Controller_scope_Tag_elements_parse_with_metadata()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content SchemaRevision="1.0" SoftwareRevision="32.00">
<Controller Name="MyController" ProcessorType="1756-L83E">
<Tags>
<Tag Name="Motor1_Speed" TagType="Base" DataType="DINT" ExternalAccess="Read/Write">
<Description><![CDATA[Motor 1 set point]]></Description>
</Tag>
<Tag Name="Tank_Level" TagType="Base" DataType="REAL" ExternalAccess="Read Only" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
doc.Tags[0].DataType.ShouldBe("DINT");
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
doc.Tags[0].ProgramScope.ShouldBeNull();
doc.Tags[0].AliasFor.ShouldBeNull();
doc.Tags[1].Name.ShouldBe("Tank_Level");
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
}
[Fact]
public void Program_scope_Tag_elements_carry_program_name()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<Programs>
<Program Name="MainProgram" Class="Standard">
<Tags>
<Tag Name="StepIndex" TagType="Base" DataType="DINT" />
<Tag Name="Running" TagType="Base" DataType="BOOL" />
</Tags>
</Program>
</Programs>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
}
[Fact]
public void Alias_tag_carries_AliasFor_and_is_skipped_on_ingest()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<Tags>
<Tag Name="Real" TagType="Base" DataType="DINT" />
<Tag Name="Aliased" TagType="Alias" AliasFor="Real" ExternalAccess="Read/Write" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
var alias = doc.Tags.Single(t => t.Name == "Aliased");
alias.AliasFor.ShouldBe("Real");
var ingestResult = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
ingestResult.SkippedAliasCount.ShouldBe(1);
ingestResult.Tags.ShouldAllBe(t => t.Name != "Aliased");
}
[Fact]
public void DataType_block_collects_member_elements_and_skips_hidden_zzzz_host()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<DataTypes>
<DataType Name="TankUDT" Class="User">
<Members>
<Member Name="ZZZZZZZZZZTankUDT0" DataType="SINT" Hidden="true" />
<Member Name="Level" DataType="REAL" ExternalAccess="Read/Write" />
<Member Name="Active" DataType="BIT" Target="ZZZZZZZZZZTankUDT0" BitNumber="0" />
</Members>
</DataType>
</DataTypes>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
doc.DataTypes.Count.ShouldBe(1);
var udt = doc.DataTypes[0];
udt.Name.ShouldBe("TankUDT");
udt.Members.Count.ShouldBe(2);
udt.Members.ShouldContain(m => m.Name == "Level" && m.DataType == "REAL");
udt.Members.ShouldContain(m => m.Name == "Active");
}
[Fact]
public void UDT_typed_tag_picks_up_member_layout_through_ingest()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<DataTypes>
<DataType Name="TankUDT" Class="User">
<Members>
<Member Name="Level" DataType="REAL" />
<Member Name="Pressure" DataType="REAL" />
</Members>
</DataType>
</DataTypes>
<Tags>
<Tag Name="Tank1" TagType="Base" DataType="TankUDT" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
var tag = result.Tags.Single();
tag.Name.ShouldBe("Tank1");
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldNotBeNull();
tag.Members!.Count.ShouldBe(2);
tag.Members.Select(m => m.Name).ShouldBe(["Level", "Pressure"]);
}
[Fact]
public void AOI_definition_surfaces_as_datatype_with_visible_parameters()
{
// EnableIn / EnableOut on real exports carry Hidden="true" — the parser must skip those
// so AOI-typed tags don't end up with phantom EnableIn/EnableOut members.
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<AddOnInstructionDefinitions>
<AddOnInstructionDefinition Name="MyValveAoi" Revision="1.0">
<Parameters>
<Parameter Name="EnableIn" TagType="Base" DataType="BOOL" Usage="Input" Hidden="true" />
<Parameter Name="EnableOut" TagType="Base" DataType="BOOL" Usage="Output" Hidden="true" />
<Parameter Name="Cmd" TagType="Base" DataType="BOOL" Usage="Input" />
<Parameter Name="Status" TagType="Base" DataType="DINT" Usage="Output" ExternalAccess="Read Only" />
</Parameters>
</AddOnInstructionDefinition>
</AddOnInstructionDefinitions>
<Tags>
<Tag Name="Valve_001" TagType="Base" DataType="MyValveAoi" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
// AOI definition should appear as a "DataType" entry alongside any UDTs.
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
aoi.Members.Count.ShouldBe(2);
aoi.Members.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
var tag = result.Tags.Single();
tag.Name.ShouldBe("Valve_001");
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldNotBeNull();
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
}
[Fact]
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
{
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C" />
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(0);
doc.DataTypes.Count.ShouldBe(0);
}
[Fact]
public void Missing_external_access_defaults_to_writable_through_ingest()
{
// L5X: ExternalAccess attribute absent → ingest treats as default (writable, not skipped).
const string body = """
<?xml version="1.0" encoding="UTF-8"?>
<RSLogix5000Content>
<Controller Name="C">
<Tags>
<Tag Name="Plain" TagType="Base" DataType="DINT" />
</Tags>
</Controller>
</RSLogix5000Content>
""";
var doc = L5xParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
result.Tags.Single().Writable.ShouldBeTrue();
result.SkippedNoAccessCount.ShouldBe(0);
}
}