diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 5075db4..eb11b98 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -1025,10 +1025,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
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)
{
+ 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}";
- udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
+ parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
IsArray: false,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index 2a6f4c7..d76845b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -153,9 +153,14 @@ public sealed record AbCipTagDefinition(
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
///
///
-/// carries the per-member comment from L5K/L5X UDT definitions so
+/// carries the per-member comment from L5K/L5X UDT definitions so
/// 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.
+/// PR abcip-2.6 — tags AOI parameters as Input / Output /
+/// InOut / Local. Plain UDT members default to . Discovery
+/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
+/// Tag/Inputs/..., Tag/Outputs/..., Tag/InOut/... while Local stays at the
+/// UDT root — matching how AOIs visually present in Studio 5000.
///
public sealed record AbCipStructureMember(
string Name,
@@ -163,7 +168,30 @@ public sealed record AbCipStructureMember(
bool Writable = true,
bool WriteIdempotent = false,
int? StringLength = null,
- string? Description = null);
+ string? Description = null,
+ AoiQualifier AoiQualifier = AoiQualifier.Local);
+
+///
+/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
+/// Usage attribute (Input / Output / InOut) 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 , which keeps them at the
+/// UDT root + indicates they are internal storage rather than a directional parameter.
+///
+public enum AoiQualifier
+{
+ /// UDT member or AOI local tag — non-directional, browsed at the parent's root.
+ Local,
+
+ /// AOI input parameter — written by the caller, read by the AOI body.
+ Input,
+
+ /// AOI output parameter — written by the AOI body, read by the caller.
+ Output,
+
+ /// AOI bidirectional parameter — passed by reference, both sides may read/write.
+ InOut,
+}
///
/// One L5K-import entry. Either or must be
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
index f66e066..9db4d78 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
@@ -59,7 +59,8 @@ public sealed class L5kIngest
Name: m.Name,
DataType: memberType,
Writable: writable,
- Description: m.Description));
+ Description: m.Description,
+ AoiQualifier: MapAoiUsage(m.Usage)));
}
udtIndex[dt.Name] = members;
}
@@ -119,6 +120,19 @@ public sealed class L5kIngest
private static bool IsAccessNone(string? externalAccess) =>
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
+ ///
+ /// PR abcip-2.6 — map the AOI Usage attribute string to .
+ /// Plain UDT members (Usage = null) + unrecognised values map to .
+ ///
+ private static AoiQualifier MapAoiUsage(string? usage) =>
+ usage?.Trim().ToUpperInvariant() switch
+ {
+ "INPUT" => AoiQualifier.Input,
+ "OUTPUT" => AoiQualifier.Output,
+ "INOUT" => AoiQualifier.InOut,
+ _ => AoiQualifier.Local,
+ };
+
/// Map a Logix atomic type name. Returns null for UDT/structure references.
private static AbCipDataType? TryMapAtomic(string logixType) =>
logixType?.Trim().ToUpperInvariant() switch
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
index 6836688..e2a0f5a 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
@@ -72,6 +72,16 @@ public static class L5kParser
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++;
}
@@ -312,7 +322,74 @@ public static class L5kParser
if (typePart.Length == 0) return null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : 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 ---------------------------------------------------------
+
+ ///
+ /// PR abcip-2.6 — parse ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION
+ /// blocks. Body is structured around PARAMETER entries (each carrying a Usage
+ /// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
+ /// rows + leave routines alone — only the surface API matters for
+ /// tag-discovery fan-out. The L5K format encloses parameters either inside a
+ /// PARAMETERS ... END_PARAMETERS block or as bare PARAMETER ... ; lines at
+ /// the AOI top level depending on Studio 5000 export options; this parser accepts both.
+ ///
+ private static int ParseAoiDefinitionBlock(string[] lines, int start, List into)
+ {
+ var first = lines[start].Trim();
+ var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
+ var name = ExtractFirstQuotedOrToken(head);
+ var members = new List();
+ 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 -----------------------------------------------------------
@@ -377,10 +454,16 @@ public sealed record L5kTag(
/// One UDT definition extracted from a DATATYPE ... END_DATATYPE block.
public sealed record L5kDataType(string Name, IReadOnlyList Members);
-/// One member line inside a UDT definition.
+/// One member line inside a UDT definition or AOI parameter list.
+///
+/// PR abcip-2.6 — carries the AOI Usage attribute (Input /
+/// Output / InOut) raw text. Plain UDT members + L5K AOI LOCAL_TAGS leave
+/// it null; the ingest layer maps null → .
+///
public sealed record L5kMember(
string Name,
string DataType,
int? ArrayDim,
string? ExternalAccess,
- string? Description = null);
+ string? Description = null,
+ string? Usage = null);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
index 67f5cda..69d8dd6 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
@@ -218,12 +218,19 @@ public static class L5xParser
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 , but those don't go through this
+ // path — only / 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));
+ Description: paramDescription,
+ Usage: string.IsNullOrEmpty(usage) ? null : usage));
}
return new L5kDataType(name, members);
}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs
index 0b369c2..8174386 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs
@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
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]
public async Task UDT_members_mixed_with_flat_tags_coexist()
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
index 1bd949c..677703e 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
@@ -153,6 +153,81 @@ public sealed class L5kIngestTests
Should.Throw(() => 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 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+ 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]
public void NamePrefix_is_applied_to_imported_tags()
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
index e26a414..a761385 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
@@ -150,6 +150,36 @@ public sealed class L5kParserTests
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]
public void Multi_line_TAG_entry_is_concatenated()
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
index f708ddf..f6de06b 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
@@ -191,6 +191,35 @@ public sealed class L5xParserTests
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 elements (Input / Output / InOut) flows
+ // through to L5kMember.Usage so the ingest layer can map it to AoiQualifier.
+ const string body = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ 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]
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
{