[abcip] AbCip — L5X (XML) parser + ingest #347
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
230
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
230
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user