[abcip] AbCip — AOI input/output handling #351
@@ -1025,10 +1025,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||||||
|
// PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local
|
||||||
|
// AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut
|
||||||
|
// members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the
|
||||||
|
// browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT
|
||||||
|
// tags (every member Local) retain the pre-2.6 flat layout under the parent
|
||||||
|
// folder so existing browse paths stay stable.
|
||||||
|
var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local);
|
||||||
|
IAddressSpaceBuilder? inputsFolder = null;
|
||||||
|
IAddressSpaceBuilder? outputsFolder = null;
|
||||||
|
IAddressSpaceBuilder? inOutFolder = null;
|
||||||
foreach (var member in tag.Members)
|
foreach (var member in tag.Members)
|
||||||
{
|
{
|
||||||
|
var parentFolder = udtFolder;
|
||||||
|
if (hasDirectional)
|
||||||
|
{
|
||||||
|
parentFolder = member.AoiQualifier switch
|
||||||
|
{
|
||||||
|
AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"),
|
||||||
|
AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"),
|
||||||
|
AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"),
|
||||||
|
_ => udtFolder, // Local stays at the AOI root
|
||||||
|
};
|
||||||
|
}
|
||||||
var memberFullName = $"{tag.Name}.{member.Name}";
|
var memberFullName = $"{tag.Name}.{member.Name}";
|
||||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||||
FullName: memberFullName,
|
FullName: memberFullName,
|
||||||
DriverDataType: member.DataType.ToDriverDataType(),
|
DriverDataType: member.DataType.ToDriverDataType(),
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
|
|||||||
@@ -153,9 +153,14 @@ public sealed record AbCipTagDefinition(
|
|||||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
||||||
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
||||||
/// not just the top-level tag.
|
/// not just the top-level tag.</para>
|
||||||
|
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
|
||||||
|
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
|
||||||
|
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
|
||||||
|
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
|
||||||
|
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record AbCipStructureMember(
|
public sealed record AbCipStructureMember(
|
||||||
string Name,
|
string Name,
|
||||||
@@ -163,7 +168,30 @@ public sealed record AbCipStructureMember(
|
|||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
int? StringLength = null,
|
int? StringLength = null,
|
||||||
string? Description = null);
|
string? Description = null,
|
||||||
|
AoiQualifier AoiQualifier = AoiQualifier.Local);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
|
||||||
|
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
|
||||||
|
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
|
||||||
|
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
|
||||||
|
/// UDT root + indicates they are internal storage rather than a directional parameter.
|
||||||
|
/// </summary>
|
||||||
|
public enum AoiQualifier
|
||||||
|
{
|
||||||
|
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
|
||||||
|
Local,
|
||||||
|
|
||||||
|
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
|
||||||
|
Input,
|
||||||
|
|
||||||
|
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
|
||||||
|
Output,
|
||||||
|
|
||||||
|
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
|
||||||
|
InOut,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ public sealed class L5kIngest
|
|||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
DataType: memberType,
|
DataType: memberType,
|
||||||
Writable: writable,
|
Writable: writable,
|
||||||
Description: m.Description));
|
Description: m.Description,
|
||||||
|
AoiQualifier: MapAoiUsage(m.Usage)));
|
||||||
}
|
}
|
||||||
udtIndex[dt.Name] = members;
|
udtIndex[dt.Name] = members;
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,19 @@ public sealed class L5kIngest
|
|||||||
private static bool IsAccessNone(string? externalAccess) =>
|
private static bool IsAccessNone(string? externalAccess) =>
|
||||||
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
|
||||||
|
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static AoiQualifier MapAoiUsage(string? usage) =>
|
||||||
|
usage?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"INPUT" => AoiQualifier.Input,
|
||||||
|
"OUTPUT" => AoiQualifier.Output,
|
||||||
|
"INOUT" => AoiQualifier.InOut,
|
||||||
|
_ => AoiQualifier.Local,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
||||||
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
||||||
logixType?.Trim().ToUpperInvariant() switch
|
logixType?.Trim().ToUpperInvariant() switch
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ public static class L5kParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
|
||||||
|
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
|
||||||
|
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||||
|
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
var consumed = ParseAoiDefinitionBlock(lines, i, datatypes);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +322,74 @@ public static class L5kParser
|
|||||||
if (typePart.Length == 0) return null;
|
if (typePart.Length == 0) return null;
|
||||||
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||||
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||||
return new L5kMember(name, typePart, arrayDim, externalAccess, description);
|
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
|
||||||
|
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
|
||||||
|
// in the ingest layer.
|
||||||
|
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
|
||||||
|
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AOI block ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
|
||||||
|
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
|
||||||
|
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
|
||||||
|
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
|
||||||
|
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
|
||||||
|
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
|
||||||
|
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
|
||||||
|
/// </summary>
|
||||||
|
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
|
||||||
|
{
|
||||||
|
var first = lines[start].Trim();
|
||||||
|
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
|
||||||
|
var name = ExtractFirstQuotedOrToken(head);
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
var i = start + 1;
|
||||||
|
var inLocalsBlock = false;
|
||||||
|
var inRoutineBlock = false;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start + 1;
|
||||||
|
}
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
|
||||||
|
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
|
||||||
|
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
|
||||||
|
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
|
||||||
|
|
||||||
|
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
|
||||||
|
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
|
||||||
|
|
||||||
|
if (StartsWithKeyword(line, "PARAMETER"))
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
entry = entry.Substring("PARAMETER".Length).Trim();
|
||||||
|
var member = ParseMemberEntry(entry);
|
||||||
|
if (member is not null) members.Add(member);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
@@ -377,10 +454,16 @@ public sealed record L5kTag(
|
|||||||
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
||||||
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
||||||
|
|
||||||
/// <summary>One member line inside a UDT definition.</summary>
|
/// <summary>One member line inside a UDT definition or AOI parameter list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
|
||||||
|
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
|
||||||
|
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </remarks>
|
||||||
public sealed record L5kMember(
|
public sealed record L5kMember(
|
||||||
string Name,
|
string Name,
|
||||||
string DataType,
|
string DataType,
|
||||||
int? ArrayDim,
|
int? ArrayDim,
|
||||||
string? ExternalAccess,
|
string? ExternalAccess,
|
||||||
string? Description = null);
|
string? Description = null,
|
||||||
|
string? Usage = null);
|
||||||
|
|||||||
@@ -218,12 +218,19 @@ public static class L5xParser
|
|||||||
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
|
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(
|
members.Add(new L5kMember(
|
||||||
Name: paramName,
|
Name: paramName,
|
||||||
DataType: dataType,
|
DataType: dataType,
|
||||||
ArrayDim: arrayDim,
|
ArrayDim: arrayDim,
|
||||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
Description: paramDescription));
|
Description: paramDescription,
|
||||||
|
Usage: string.IsNullOrEmpty(usage) ? null : usage));
|
||||||
}
|
}
|
||||||
return new L5kDataType(name, members);
|
return new L5kDataType(name, members);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
|
|||||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AOI_typed_tag_groups_members_under_directional_subfolders()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — when any member carries a non-Local AoiQualifier, the tag is treated
|
||||||
|
// as an AOI instance: Input / Output / InOut members get grouped under sub-folders so
|
||||||
|
// the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member
|
||||||
|
// Local) keep the pre-2.6 flat layout.
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition(
|
||||||
|
Name: "Valve_001",
|
||||||
|
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
TagPath: "Valve_001",
|
||||||
|
DataType: AbCipDataType.Structure,
|
||||||
|
Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input),
|
||||||
|
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output),
|
||||||
|
new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut),
|
||||||
|
new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
// Sub-folders for each directional bucket land in the recorder; the AOI parent folder
|
||||||
|
// and the Local member's lack of a sub-folder confirm only directional members get
|
||||||
|
// bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients
|
||||||
|
// that browse "Valve_001/Inputs/Cmd" see exactly that path.
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut");
|
||||||
|
|
||||||
|
// Variables emitted under the right full names — full reference still {Tag}.{Member}
|
||||||
|
// so the read/write paths stay unchanged from the flat-UDT case.
|
||||||
|
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||||
|
variables.ShouldContain(("Cmd", "Valve_001.Cmd"));
|
||||||
|
variables.ShouldContain(("Status", "Valve_001.Status"));
|
||||||
|
variables.ShouldContain(("Buffer", "Valve_001.Buffer"));
|
||||||
|
variables.ShouldContain(("LocalVar", "Valve_001.LocalVar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local()
|
||||||
|
{
|
||||||
|
// Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no
|
||||||
|
// Inputs/Outputs/InOut sub-folders should appear since there are no directional members.
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure,
|
||||||
|
Members:
|
||||||
|
[
|
||||||
|
new AbCipStructureMember("Level", AbCipDataType.Real),
|
||||||
|
new AbCipStructureMember("Pressure", AbCipDataType.Real),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs");
|
||||||
|
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,6 +153,81 @@ public sealed class L5kIngestTests
|
|||||||
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
|
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
|
||||||
|
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
|
||||||
|
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
|
||||||
|
const string body = """
|
||||||
|
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
|
||||||
|
PARAMETERS
|
||||||
|
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||||
|
PARAMETER Status : DINT (Usage := Output) := 0;
|
||||||
|
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||||
|
PARAMETER Local1 : DINT := 0;
|
||||||
|
END_PARAMETERS
|
||||||
|
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||||
|
DATATYPE PlainUdt
|
||||||
|
MEMBER Speed : DINT := 0;
|
||||||
|
END_DATATYPE
|
||||||
|
TAG
|
||||||
|
Valve_001 : ValveAoi;
|
||||||
|
Tank1 : PlainUdt;
|
||||||
|
END_TAG
|
||||||
|
""";
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
|
||||||
|
aoiTag.Members.ShouldNotBeNull();
|
||||||
|
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||||
|
|
||||||
|
// Plain UDT members default to Local — no Usage attribute to map.
|
||||||
|
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
|
||||||
|
plainTag.Members.ShouldNotBeNull();
|
||||||
|
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||||
|
{
|
||||||
|
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
|
||||||
|
// formats land at the same downstream representation.
|
||||||
|
const string body = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<RSLogix5000Content>
|
||||||
|
<Controller Name="C">
|
||||||
|
<AddOnInstructionDefinitions>
|
||||||
|
<AddOnInstructionDefinition Name="MyAoi">
|
||||||
|
<Parameters>
|
||||||
|
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||||
|
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||||
|
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||||
|
</Parameters>
|
||||||
|
</AddOnInstructionDefinition>
|
||||||
|
</AddOnInstructionDefinitions>
|
||||||
|
<Tags>
|
||||||
|
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
|
||||||
|
</Tags>
|
||||||
|
</Controller>
|
||||||
|
</RSLogix5000Content>
|
||||||
|
""";
|
||||||
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||||
|
|
||||||
|
var aoiTag = result.Tags.Single();
|
||||||
|
aoiTag.Members.ShouldNotBeNull();
|
||||||
|
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||||
|
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NamePrefix_is_applied_to_imported_tags()
|
public void NamePrefix_is_applied_to_imported_tags()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,6 +150,36 @@ public sealed class L5kParserTests
|
|||||||
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AOI_definition_block_collects_parameters_with_Usage()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
|
||||||
|
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
|
||||||
|
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||||
|
const string body = """
|
||||||
|
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
|
||||||
|
PARAMETERS
|
||||||
|
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||||
|
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
|
||||||
|
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||||
|
PARAMETER Internal : DINT := 0;
|
||||||
|
END_PARAMETERS
|
||||||
|
LOCAL_TAGS
|
||||||
|
Working : DINT := 0;
|
||||||
|
END_LOCAL_TAGS
|
||||||
|
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||||
|
|
||||||
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||||
|
aoi.Members.Count.ShouldBe(4);
|
||||||
|
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||||
|
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||||
|
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||||
|
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Multi_line_TAG_entry_is_concatenated()
|
public void Multi_line_TAG_entry_is_concatenated()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -191,6 +191,35 @@ public sealed class L5xParserTests
|
|||||||
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AOI_parameter_Usage_attribute_is_captured()
|
||||||
|
{
|
||||||
|
// PR abcip-2.6 — Usage attribute on <Parameter> elements (Input / Output / InOut) flows
|
||||||
|
// through to L5kMember.Usage so the ingest layer can map it to AoiQualifier.
|
||||||
|
const string body = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<RSLogix5000Content>
|
||||||
|
<Controller Name="C">
|
||||||
|
<AddOnInstructionDefinitions>
|
||||||
|
<AddOnInstructionDefinition Name="MyAoi" Revision="1.0">
|
||||||
|
<Parameters>
|
||||||
|
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||||
|
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||||
|
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||||
|
</Parameters>
|
||||||
|
</AddOnInstructionDefinition>
|
||||||
|
</AddOnInstructionDefinitions>
|
||||||
|
</Controller>
|
||||||
|
</RSLogix5000Content>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||||
|
var aoi = doc.DataTypes.Single(d => d.Name == "MyAoi");
|
||||||
|
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||||
|
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||||
|
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user