v3(b1-abcip): RawPath read-path for AbCip (multi-device)

Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.

TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
This commit is contained in:
Joseph Doherty
2026-07-15 20:18:28 -04:00
parent c379e246d0
commit c0379742bc
32 changed files with 819 additions and 783 deletions
@@ -19,11 +19,9 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
Tags =
[
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),
],
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -61,12 +59,10 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
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),
],
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -83,7 +79,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -418,21 +414,19 @@ public sealed class AbCipDriverDiscoveryTests
}
/// <summary>
/// A discovered member-path read goes end-to-end through the driver: with NO pre-declared
/// tag for the member, <see cref="AbCipDriver.ReadAsync"/> resolves the authored TagConfig
/// JSON blob (FullName = <c>Parent.Member</c>) via the equipment-tag resolver, materialises a
/// runtime on the device, reads it, and decodes the member's atomic value — proving discovered
/// members are readable with no extra registration. The libplctag tag name the driver builds
/// for the member equals the dotted member path.
/// 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 Discovered_member_path_reads_through_driver_as_member_atomic_type()
public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
{
const string device = "ab://10.0.0.5/1,0";
// The wire reference handed to ReadAsync for a discovered member is the authored TagConfig
// JSON blob (FullName = Motor1.Status.Code, atomic type Real here). No tag is pre-declared.
const string tagConfig =
"{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"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
{
@@ -443,16 +437,17 @@ public sealed class AbCipDriverDiscoveryTests
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([tagConfig], 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 (no parent-UDT registration needed).
// read addresses the member directly.
tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
}