Auto: abcip-2.6 — AOI input/output handling
AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -153,6 +153,81 @@ public sealed class L5kIngestTests
|
||||
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]
|
||||
public void NamePrefix_is_applied_to_imported_tags()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 <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]
|
||||
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user