be421a4cb0
#572: listing continues at max(instance in page)+1 (no ascending-order assumption, no-progress guard kept); browsePageDelayMs (default 5 ms) paces pages; DiscoverAsync reads templates through one batch reader (IAbCipTemplateReaderFactory.CreateBatch) holding one lease. #575: differential harness reports a same-status template refusal as an equal refusal; offline test pins the open-attempt budget without relying on the abort ending the phase. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
883 lines
44 KiB
C#
883 lines
44 KiB
C#
using System.Buffers.Binary;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipDriverDiscoveryTests
|
|
{
|
|
/// <summary>Verifies that pre-declared tags emit as variables under device folder.</summary>
|
|
[Fact]
|
|
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
|
|
builder.Variables.Count.ShouldBe(2);
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
|
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every emitted variable states its Logix type, so a browse-commit binds the tag with the type
|
|
/// the controller has rather than the tag model's <c>DInt</c> default (Gitea #536). The
|
|
/// driver-agnostic <see cref="DriverAttributeInfo.DriverDataType"/> cannot serve — <c>SInt</c>,
|
|
/// <c>Int</c> and <c>DInt</c> all arrive there as <see cref="DriverDataType.Int32"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Emitted_variables_state_their_Logix_type_for_the_commit_path()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.Real),
|
|
new AbCipTagDefinition("Count", "ab://10.0.0.5/1,0", "C", AbCipDataType.Int)),
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Single(v => v.BrowseName == "Speed").Info.NativeDataType.ShouldBe("Real");
|
|
|
|
// Int and DInt are indistinguishable downstream — this pair is the whole reason the native
|
|
// name is carried, so assert both halves rather than only the one that differs by kind.
|
|
var count = builder.Variables.Single(v => v.BrowseName == "Count").Info;
|
|
count.NativeDataType.ShouldBe("Int");
|
|
count.DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
}
|
|
|
|
/// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
|
|
[Fact]
|
|
public async Task Device_folder_displayname_falls_back_to_host_address()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
|
|
&& f.DisplayName == "ab://10.0.0.5/1,0");
|
|
}
|
|
|
|
/// <summary>Verifies that pre-declared system tags are filtered out.</summary>
|
|
[Fact]
|
|
public async Task PreDeclared_system_tags_are_filtered_out()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
|
|
}
|
|
|
|
/// <summary>Verifies that tags for mismatched devices are ignored.</summary>
|
|
[Fact]
|
|
public async Task Tags_for_mismatched_device_are_ignored()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
|
|
}, "drv-1");
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that controller enumeration adds tags under Discovered folder.</summary>
|
|
[Fact]
|
|
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
|
|
new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A controller-discovered UDT (a Structure tag) is expanded into a member sub-folder
|
|
/// with one Variable per atomic member, mirroring the pre-declared fan-out. A nested
|
|
/// struct member recurses into its own atomic leaves (dot-joined full names). The bogus
|
|
/// single <c>Structure → String</c> placeholder Variable must NOT be emitted.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_expands_into_member_variables()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Seed the discovered-UDT shapes the driver fans out from. The top-level Motor1 shape
|
|
// carries two atomic members (Speed/Real, Running/Bool) + a nested struct member
|
|
// (Status) whose own shape (Code/DInt) is seeded under its member name.
|
|
drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Motor1", new AbCipUdtShape(
|
|
"MotorUdt", 16,
|
|
[
|
|
new AbCipUdtMember("Speed", 0, AbCipDataType.Real, ArrayLength: 1),
|
|
new AbCipUdtMember("Running", 4, AbCipDataType.Bool, ArrayLength: 1),
|
|
new AbCipUdtMember("Status", 8, AbCipDataType.Structure, ArrayLength: 1),
|
|
]));
|
|
drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Status", new AbCipUdtShape(
|
|
"StatusUdt", 4,
|
|
[
|
|
new AbCipUdtMember("Code", 0, AbCipDataType.DInt, ArrayLength: 1),
|
|
]));
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
// A Motor1 sub-folder is created under the Discovered folder.
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
|
|
// The bare Structure placeholder Variable is NOT emitted (no Motor1 String node).
|
|
builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1");
|
|
|
|
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
|
byName.ShouldContainKey("Motor1.Speed");
|
|
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
byName.ShouldContainKey("Motor1.Running");
|
|
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
|
|
// Nested struct leaf — dot-joined full name + the nested member's atomic type.
|
|
byName.ShouldContainKey("Motor1.Status.Code");
|
|
byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
}
|
|
|
|
/// <summary>
|
|
/// PRODUCTION PATH (no test seam): a controller-discovered Structure tag carries its CIP
|
|
/// template instance id (surfaced by the Symbol Object decoder onto
|
|
/// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>). DiscoverAsync threads that id into
|
|
/// <c>FetchUdtShapeAsync</c>, which reads the Template Object off the controller via the
|
|
/// injected <see cref="IAbCipTemplateReader"/> and fans the top-level UDT out into atomic
|
|
/// member Variables — WITHOUT any call to <c>SeedDiscoveredUdtShapeForTest</c>. This proves the
|
|
/// production top-level expansion is functional, not inert.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_top_level_expands_via_template_id_fetch_no_seam()
|
|
{
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
const uint motorTemplateId = 0x2A; // lower 12 bits of the struct symbol type
|
|
|
|
var builder = new RecordingBuilder();
|
|
// Discovered tag carries the template instance id — exactly what the real Symbol Object
|
|
// decoder produces for a struct symbol.
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
|
|
TemplateInstanceId: motorTemplateId));
|
|
|
|
// Fake template reader keyed by id: a Read Template for motorTemplateId returns a real
|
|
// Template Object blob with two atomic members (Speed/Real, Running/Bool). No seed seam.
|
|
var templateReaderFactory = new IdKeyedTemplateReaderFactory
|
|
{
|
|
Templates =
|
|
{
|
|
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 8,
|
|
("Speed", 0xCA, 0, 0), // 0xCA = Real
|
|
("Running", 0xC1, 0, 4)), // 0xC1 = Bool
|
|
},
|
|
};
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
// The reader was actually consulted for the discovered tag's template id — the production
|
|
// fetch path ran (not the seam).
|
|
templateReaderFactory.LastTemplateId.ShouldBe(motorTemplateId);
|
|
|
|
// The Motor1 sub-folder + atomic member leaves are emitted; the bogus Structure placeholder
|
|
// Variable is NOT.
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
|
|
builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1");
|
|
|
|
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
|
byName.ShouldContainKey("Motor1.Speed");
|
|
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
byName.ShouldContainKey("Motor1.Running");
|
|
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #572: a browse that fetches many templates (a UDT and its nested UDT here) reads them all
|
|
/// through ONE batch reader, so the native transport holds one device lease across them rather than
|
|
/// leasing per template. The batch reader is disposed when the device's browse ends.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Discovery_reads_every_template_through_one_batch_reader()
|
|
{
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
const uint motorTemplateId = 0x2A;
|
|
const uint statusTemplateId = 0x123;
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: motorTemplateId),
|
|
new AbCipDiscoveredTag("Motor2", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 0x2B));
|
|
var templates = new IdKeyedTemplateReaderFactory
|
|
{
|
|
Templates =
|
|
{
|
|
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
|
|
("Speed", 0xCA, 0, 0), ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)),
|
|
[0x2B] = BuildSimpleTemplate("Motor2Udt", 4, ("Speed", 0xCA, 0, 0)),
|
|
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4, ("Code", 0xC4, 0, 0)),
|
|
},
|
|
};
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templates);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
|
|
|
templates.RequestedIds.Count.ShouldBe(3);
|
|
templates.BatchReaders.ShouldBe(1);
|
|
templates.PerCallReaders.ShouldBe(0);
|
|
templates.BatchReadersDisposed.ShouldBe(1);
|
|
|
|
// Outside a discovery run a fetch is still a self-contained per-call read.
|
|
await drv.FetchUdtShapeAsync(device, 0x2B, CancellationToken.None);
|
|
await drv.FetchUdtShapeAsync(device, 0x999, CancellationToken.None);
|
|
templates.PerCallReaders.ShouldBe(1, "0x2B is cached; 0x999 is not");
|
|
}
|
|
|
|
/// <summary>
|
|
/// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
|
|
/// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
|
|
/// <c>SeedDiscoveredUdtShapeForTest</c>. The nested member block carries the nested UDT's
|
|
/// template instance id (low 12 bits, struct flag set); the decoder captures it as
|
|
/// <c>NestedTemplateId</c> and DiscoverAsync threads it into a real <c>FetchUdtShapeAsync</c>
|
|
/// for the nested id. Proves the nested fetch actually happened (reader consulted for the
|
|
/// nested id) and that the nested struct's leaf member is addressable.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam()
|
|
{
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
const uint motorTemplateId = 0x2A; // parent UDT template id
|
|
const uint statusTemplateId = 0x123; // nested UDT template id — exceeds a byte (12-bit proof)
|
|
|
|
var builder = new RecordingBuilder();
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
|
|
TemplateInstanceId: motorTemplateId));
|
|
|
|
// The parent template carries an atomic member (Speed/Real) + a NESTED struct member (Status)
|
|
// whose member-info encodes the struct flag (0x8000) | the nested template id (0x123). The
|
|
// nested template id is read off @udt/0x123 and yields one atomic leaf (Code/DInt).
|
|
var templateReaderFactory = new IdKeyedTemplateReaderFactory
|
|
{
|
|
Templates =
|
|
{
|
|
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
|
|
("Speed", 0xCA, 0, 0), // Real
|
|
("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), // struct + nested id 0x123
|
|
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4,
|
|
("Code", 0xC4, 0, 0)), // DInt
|
|
},
|
|
};
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
// The nested template id actually drove a fetch — the reader was consulted for it (the seam
|
|
// would have left LastTemplateId at the parent id and never read the nested shape).
|
|
templateReaderFactory.RequestedIds.ShouldContain(statusTemplateId);
|
|
|
|
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
|
// The parent atomic leaf is emitted.
|
|
byName.ShouldContainKey("Motor1.Speed");
|
|
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
// The NESTED struct's atomic leaf is addressable — proving the nested fetch + fan-out ran.
|
|
byName.ShouldContainKey("Motor1.Status.Code");
|
|
byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
// A Motor1.Status sub-folder was materialised for the nested struct.
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1.Status");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A discovered Structure whose shape cannot be resolved degrades to the prior single
|
|
/// Variable behavior (no regression): one Variable under the Discovered folder keyed by
|
|
/// the bare struct name, never a broken member fan-out.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_with_unresolvable_shape_degrades_to_single_variable()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("MysteryUdt", null, AbCipDataType.Structure, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// No shape seeded + no fake template reader → FetchUdtShapeAsync returns null → degrade.
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
var mystery = builder.Variables.Where(v => v.Info.FullName == "MysteryUdt").ToList();
|
|
mystery.Count.ShouldBe(1);
|
|
// No member fan-out folder for an unresolvable shape.
|
|
builder.Folders.ShouldNotContain(f => f.BrowseName == "MysteryUdt");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A nested struct member whose sub-shape resolves but yields NO emittable atomic leaf must
|
|
/// not leave an empty nested sub-folder in the browse tree — the folder is materialised lazily,
|
|
/// only once a leaf is actually emitted into it. Here <c>Empty</c> (a nested struct) has only
|
|
/// a further nested struct child whose own shape is unresolvable, so the whole <c>Empty</c>
|
|
/// branch produces no leaf and must produce no folder either; the sibling atomic leaf is kept.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_does_not_leave_empty_nested_subfolder()
|
|
{
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
var builder = new RecordingBuilder();
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Top", null, AbCipDataType.Structure, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Top has one atomic leaf (Ok) + a nested struct (Empty). Empty's shape resolves, but its
|
|
// only member is a further nested struct (Ghost) whose shape is NOT seeded → unresolvable →
|
|
// no leaf anywhere under Empty.
|
|
drv.SeedDiscoveredUdtShapeForTest(device, "Top", new AbCipUdtShape("TopUdt", 8,
|
|
[
|
|
new AbCipUdtMember("Ok", 0, AbCipDataType.DInt, ArrayLength: 1),
|
|
new AbCipUdtMember("Empty", 4, AbCipDataType.Structure, ArrayLength: 1),
|
|
]));
|
|
drv.SeedDiscoveredUdtShapeForTest(device, "Empty", new AbCipUdtShape("EmptyUdt", 4,
|
|
[
|
|
new AbCipUdtMember("Ghost", 0, AbCipDataType.Structure, ArrayLength: 1),
|
|
]));
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
// The Top folder + its atomic leaf are emitted.
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Top");
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Top.Ok");
|
|
// The Empty nested struct yields no leaf → no empty sub-folder is created for it.
|
|
builder.Folders.ShouldNotContain(f => f.BrowseName == "Empty");
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldNotContain(n => n.StartsWith("Top.Empty", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nested-struct recursion is bounded by <c>MaxUdtDepth</c>: a member at the cap depth
|
|
/// is dropped rather than emitted. Here the chain L0.L1.L2.… is seeded deeper than the
|
|
/// cap; the leaf beyond the cap must not appear.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Controller_discovered_UDT_drops_members_deeper_than_MaxUdtDepth()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
var enumeratorFactory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("Deep", null, AbCipDataType.Structure, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Build a chain Deep -> N1 -> N2 -> ... -> N12 (struct each level) with an atomic Leaf at
|
|
// every level. AbCipDriver.MaxUdtDepth bounds how deep the fan-out recurses.
|
|
const int chainDepth = 12;
|
|
drv.SeedDiscoveredUdtShapeForTest(device, "Deep", new AbCipUdtShape("Deep", 8,
|
|
[
|
|
new AbCipUdtMember("Leaf", 0, AbCipDataType.DInt, ArrayLength: 1),
|
|
new AbCipUdtMember("N1", 4, AbCipDataType.Structure, ArrayLength: 1),
|
|
]));
|
|
for (var i = 1; i <= chainDepth; i++)
|
|
{
|
|
var next = i < chainDepth ? new[] { new AbCipUdtMember($"N{i + 1}", 4, AbCipDataType.Structure, 1) } : [];
|
|
var members = new List<AbCipUdtMember> { new($"Leaf", 0, AbCipDataType.DInt, 1) };
|
|
members.AddRange(next);
|
|
drv.SeedDiscoveredUdtShapeForTest(device, $"N{i}", new AbCipUdtShape($"N{i}", 8, members));
|
|
}
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
var names = builder.Variables.Select(v => v.Info.FullName).ToHashSet();
|
|
// The top-level leaf is present.
|
|
names.ShouldContain("Deep.Leaf");
|
|
// A shallow nested leaf (well within the cap) is kept.
|
|
names.ShouldContain("Deep.N1.Leaf");
|
|
names.ShouldContain("Deep.N1.N2.Leaf");
|
|
// A leaf at the deepest chain level (well beyond the depth cap of 8) must be dropped.
|
|
var deepestPrefix = string.Join('.', Enumerable.Range(1, chainDepth).Select(i => $"N{i}"));
|
|
names.ShouldNotContain($"Deep.{deepestPrefix}.Leaf");
|
|
// No member full-name should carry more than MaxUdtDepth path segments below the parent
|
|
// (parent + at most MaxUdtDepth dotted segments). The deepest emitted leaf proves the cap.
|
|
var maxSegmentsBelowParent = names
|
|
.Where(n => n.StartsWith("Deep.", StringComparison.Ordinal))
|
|
.Select(n => n.Split('.').Length - 1) // segments after "Deep"
|
|
.DefaultIfEmpty(0)
|
|
.Max();
|
|
maxSegmentsBelowParent.ShouldBeLessThanOrEqualTo(AbCipDriver.MaxUdtDepth);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig
|
|
/// addresses a dotted member path (<c>Motor1.Status.Code</c>) is delivered as a
|
|
/// <see cref="RawTagEntry"/>, resolves by its RawPath, materialises a runtime on the device, and
|
|
/// decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted
|
|
/// member path (the TagConfig's tagPath), independent of the RawPath identity.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
|
|
{
|
|
const string device = "ab://10.0.0.5/1,0";
|
|
const string rawPath = "line1/motor1/status/code";
|
|
// The authored TagConfig addresses the dotted member path (atomic Real here).
|
|
const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}";
|
|
|
|
var tagFactory = new FakeAbCipTagFactory
|
|
{
|
|
// The driver creates the runtime keyed by the libplctag tag name = the dotted member
|
|
// path; seed a Real value the read should surface.
|
|
Customise = p => new FakeAbCipTag(p) { Value = p.TagName == "Motor1.Status.Code" ? 12.5f : null },
|
|
};
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(device)],
|
|
RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
|
|
}, "drv-1", tagFactory: tagFactory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.ReadAsync([rawPath], CancellationToken.None);
|
|
|
|
results.Count.ShouldBe(1);
|
|
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
results[0].Value.ShouldBe(12.5f);
|
|
// The driver built a runtime under the dotted member tag name — confirming the member-path
|
|
// read addresses the member directly.
|
|
tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
|
|
}
|
|
|
|
/// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
|
|
[Fact]
|
|
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var factory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
|
|
new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
|
|
new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
|
|
}
|
|
|
|
/// <summary>Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification.</summary>
|
|
[Fact]
|
|
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var factory = new FakeEnumeratorFactory(
|
|
new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
}
|
|
|
|
/// <summary>Verifies that controller enumeration receives correct device parameters.</summary>
|
|
[Fact]
|
|
public async Task Controller_enumeration_receives_correct_device_params()
|
|
{
|
|
var factory = new FakeEnumeratorFactory();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
|
|
Timeout = TimeSpan.FromSeconds(7),
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
|
|
|
|
var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
|
|
capturedParams.Gateway.ShouldBe("10.0.0.5");
|
|
capturedParams.Port.ShouldBe(44818);
|
|
capturedParams.CipPath.ShouldBe("1,2,3");
|
|
capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
|
|
capturedParams.TagName.ShouldBe("@tags");
|
|
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
|
}
|
|
|
|
/// <summary>Verifies that default enumerator factory is used when not injected.</summary>
|
|
[Fact]
|
|
public void Default_enumerator_factory_is_used_when_not_injected()
|
|
{
|
|
// Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
|
|
// (covered by the other tests which instantiate without injecting a factory).
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
}, "drv-1");
|
|
drv.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that system tag filter rejects infrastructure names.</summary>
|
|
/// <param name="name">The tag name to test.</param>
|
|
/// <param name="expected">The expected result of the filter.</param>
|
|
[Theory]
|
|
[InlineData("__DEFVAL_X", true)]
|
|
[InlineData("__DEFAULT_Y", true)]
|
|
[InlineData("Routine:Main", true)]
|
|
[InlineData("Task:MainTask", true)]
|
|
[InlineData("Local:1:I", true)]
|
|
[InlineData("Map:Alias", true)]
|
|
[InlineData("Axis:MoveX", true)]
|
|
[InlineData("Cam:Profile1", true)]
|
|
[InlineData("MotionGroup:MG0", true)]
|
|
[InlineData("Motor1", false)]
|
|
[InlineData("Program:Main.Step", false)]
|
|
[InlineData("Recipe_2", false)]
|
|
[InlineData("", true)]
|
|
[InlineData(" ", true)]
|
|
public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
|
|
{
|
|
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
|
|
}
|
|
|
|
/// <summary>Verifies that template cache roundtrip put and get work correctly.</summary>
|
|
[Fact]
|
|
public void TemplateCache_roundtrip_put_get()
|
|
{
|
|
var cache = new AbCipTemplateCache();
|
|
var shape = new AbCipUdtShape("MyUdt", 32,
|
|
[
|
|
new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
|
|
new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
|
|
]);
|
|
cache.Put("ab://10.0.0.5/1,0", 42, shape);
|
|
|
|
cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
|
|
cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
|
|
cache.Count.ShouldBe(1);
|
|
|
|
cache.Clear();
|
|
cache.Count.ShouldBe(0);
|
|
}
|
|
|
|
// ---- real-controller shapes (captures from a CompactLogix 5069-L320ERS2 rev 34.11) ----
|
|
|
|
private static async Task<RecordingBuilder> DiscoverWithTemplatesAsync(
|
|
Dictionary<uint, byte[]> templates, params AbCipDiscoveredTag[] tags)
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var readers = new IdKeyedTemplateReaderFactory();
|
|
foreach (var (id, blob) in templates) readers.Templates[id] = blob;
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
EnableControllerBrowse = true,
|
|
}, "drv-1", enumeratorFactory: new FakeEnumeratorFactory(tags), templateReaderFactory: readers);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
return builder;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A controller reports every STRING tag as a struct (symbol type 0x8FCE → template 0x0FCE,
|
|
/// LEN + DATA). Browse must present it as the one String value it is — the read path already
|
|
/// reads it as a String — not as a folder holding a DINT and an SINT[82].
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Discovered_STRING_struct_emits_one_String_variable_not_LEN_and_DATA()
|
|
{
|
|
var builder = await DiscoverWithTemplatesAsync(
|
|
new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate) },
|
|
new AbCipDiscoveredTag("WW_WorkOrderNum", null, AbCipDataType.Structure, ReadOnly: false,
|
|
TemplateInstanceId: 0x0FCE));
|
|
|
|
var v = builder.Variables.ShouldHaveSingleItem();
|
|
v.Info.FullName.ShouldBe("WW_WorkOrderNum");
|
|
v.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
|
v.Info.IsArray.ShouldBeFalse();
|
|
builder.Folders.ShouldNotContain(f => f.BrowseName == "WW_WorkOrderNum");
|
|
}
|
|
|
|
/// <summary>A nested STRING member of a UDT is one String leaf, addressable as Parent.Member.</summary>
|
|
[Fact]
|
|
public async Task Nested_STRING_member_emits_a_String_leaf()
|
|
{
|
|
var builder = await DiscoverWithTemplatesAsync(
|
|
new()
|
|
{
|
|
[3386] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate),
|
|
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
|
|
},
|
|
new AbCipDiscoveredTag("Lookup", null, AbCipDataType.Structure, ReadOnly: false,
|
|
TemplateInstanceId: 3386));
|
|
|
|
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
|
byName.Keys.ShouldBe(["Lookup.Code", "Lookup.Desc", "Lookup.DataType", "Lookup.Lock"], ignoreOrder: true);
|
|
byName["Lookup.Desc"].DriverDataType.ShouldBe(DriverDataType.String);
|
|
byName["Lookup.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logix packs BOOL members into hidden host integers (<c>ZZZZZZZZZZAlarms0</c>) and AOIs carry
|
|
/// <c>__BitHost00</c>-style backing members. Neither is user data, and a Logix name can never
|
|
/// contain a double underscore, so both are dropped from the browse.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Hidden_BOOL_host_members_are_not_browsed()
|
|
{
|
|
var alarms = TemplateBufferBuilder.Build("Alarms", 4,
|
|
("ZZZZZZZZZZAlarms0", 0xC2, 0, 0),
|
|
("_00", 0xC1, 0, 0),
|
|
("__BitHost00", 0xC4, 0, 0));
|
|
|
|
var builder = await DiscoverWithTemplatesAsync(
|
|
new() { [3425] = alarms },
|
|
new AbCipDiscoveredTag("Alarms", null, AbCipDataType.Structure, ReadOnly: false,
|
|
TemplateInstanceId: 3425));
|
|
|
|
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["Alarms._00"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An ARRAY of a UDT is not fanned out into <c>Tag.Member</c> leaves — those paths name no
|
|
/// element and every read of them would fail. An array of STRING is a String array.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Discovered_struct_arrays_do_not_fan_out_and_STRING_arrays_are_String_arrays()
|
|
{
|
|
var udt = TemplateBufferBuilder.Build("Rec", 8, ("A", 0xC4, 0, 0), ("B", 0xCA, 0, 4));
|
|
var builder = await DiscoverWithTemplatesAsync(
|
|
new()
|
|
{
|
|
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
|
|
[0x55] = udt,
|
|
},
|
|
new AbCipDiscoveredTag("arrMessages", null, AbCipDataType.Structure, ReadOnly: false,
|
|
ElementCount: 50, IsArray: true, TemplateInstanceId: 0x0FCE),
|
|
new AbCipDiscoveredTag("Stored", null, AbCipDataType.Structure, ReadOnly: false,
|
|
ElementCount: 255, IsArray: true, TemplateInstanceId: 0x55));
|
|
|
|
builder.Variables.ShouldNotContain(v => v.Info.FullName.StartsWith("Stored."));
|
|
var messages = builder.Variables.Single(v => v.Info.FullName == "arrMessages").Info;
|
|
messages.DriverDataType.ShouldBe(DriverDataType.String);
|
|
messages.IsArray.ShouldBeTrue();
|
|
messages.ArrayDim.ShouldBe(50u);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
|
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
|
{
|
|
/// <summary>Gets the list of recorded folders.</summary>
|
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
|
/// <summary>Gets the list of recorded variables.</summary>
|
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
|
|
|
/// <summary>Records a folder node.</summary>
|
|
/// <param name="browseName">The browse name of the folder.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
|
{ Folders.Add((browseName, displayName)); return this; }
|
|
|
|
/// <summary>Records a variable node.</summary>
|
|
/// <param name="browseName">The browse name of the variable.</param>
|
|
/// <param name="displayName">The display name of the variable.</param>
|
|
/// <param name="info">The attribute information for the variable.</param>
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
|
|
|
/// <summary>Adds a property (no-op in test).</summary>
|
|
/// <param name="_">Property name (unused in test).</param>
|
|
/// <param name="__">Property data type (unused in test).</param>
|
|
/// <param name="___">Property value (unused in test).</param>
|
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
|
|
|
/// <summary>Test variable handle.</summary>
|
|
private sealed class Handle(string fullRef) : IVariableHandle
|
|
{
|
|
/// <summary>Gets the full reference of the variable.</summary>
|
|
public string FullReference => fullRef;
|
|
/// <summary>Marks the variable as an alarm condition.</summary>
|
|
/// <param name="info">The alarm condition information.</param>
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
|
}
|
|
/// <summary>Null sink for alarm conditions.</summary>
|
|
private sealed class NullSink : IAlarmConditionSink
|
|
{
|
|
/// <summary>Handles alarm transition (no-op).</summary>
|
|
/// <param name="args">The alarm event arguments.</param>
|
|
public void OnTransition(AlarmEventArgs args) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>Fake enumerator factory for testing.</summary>
|
|
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
|
|
{
|
|
private readonly AbCipDiscoveredTag[] _tags;
|
|
/// <summary>Gets the last captured device parameters.</summary>
|
|
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
|
|
/// <summary>Initializes a new instance of the FakeEnumeratorFactory.</summary>
|
|
/// <param name="tags">The tags to enumerate.</param>
|
|
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
|
/// <summary>Creates a new fake enumerator.</summary>
|
|
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
|
|
|
|
/// <summary>Fake tag enumerator for testing.</summary>
|
|
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
|
|
{
|
|
/// <summary>Enumerates discovered tags asynchronously.</summary>
|
|
/// <param name="deviceParams">The device parameters for enumeration.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
|
AbCipTagCreateParams deviceParams,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
outer.LastDeviceParams = deviceParams;
|
|
await Task.CompletedTask;
|
|
foreach (var t in outer._tags) yield return t;
|
|
}
|
|
/// <summary>Disposes the enumerator.</summary>
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake <see cref="IAbCipTemplateReaderFactory"/> whose readers return a Template Object blob
|
|
/// keyed by template instance id — the production fetch path (FetchUdtShapeAsync) keyed by the
|
|
/// id the Symbol Object decoder surfaces, with NO use of the seed seam.
|
|
/// </summary>
|
|
private sealed class IdKeyedTemplateReaderFactory : IAbCipTemplateReaderFactory
|
|
{
|
|
/// <summary>Gets the template blobs keyed by template instance id.</summary>
|
|
public Dictionary<uint, byte[]> Templates { get; } = new();
|
|
/// <summary>Gets the last template id any reader was asked for.</summary>
|
|
public uint? LastTemplateId { get; private set; }
|
|
/// <summary>Gets every template id any reader was asked for, in request order.</summary>
|
|
public List<uint> RequestedIds { get; } = new();
|
|
|
|
/// <summary>Gets how many per-call readers were created.</summary>
|
|
public int PerCallReaders { get; private set; }
|
|
/// <summary>Gets how many batch readers were created.</summary>
|
|
public int BatchReaders { get; private set; }
|
|
/// <summary>Gets how many batch readers have been disposed.</summary>
|
|
public int BatchReadersDisposed { get; set; }
|
|
|
|
/// <summary>Creates a reader over the shared template map.</summary>
|
|
public IAbCipTemplateReader Create() { PerCallReaders++; return new Reader(this); }
|
|
|
|
/// <summary>Creates a batch reader (disposal is counted).</summary>
|
|
public IAbCipTemplateReader CreateBatch() { BatchReaders++; return new Reader(this, batch: true); }
|
|
|
|
private sealed class Reader(IdKeyedTemplateReaderFactory outer, bool batch = false) : IAbCipTemplateReader
|
|
{
|
|
public Task<byte[]> ReadAsync(
|
|
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
|
|
{
|
|
outer.LastTemplateId = templateInstanceId;
|
|
outer.RequestedIds.Add(templateInstanceId);
|
|
return Task.FromResult(
|
|
outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
|
|
}
|
|
|
|
public void Dispose() { if (batch) outer.BatchReadersDisposed++; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
|
|
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
|
|
/// > 0 marks an array member.
|
|
/// </summary>
|
|
private static byte[] BuildSimpleTemplate(
|
|
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
|
|
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
|
|
}
|