v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)

- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:23 -04:00
parent c379e246d0
commit a53be2af65
23 changed files with 565 additions and 460 deletions
@@ -27,11 +27,11 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt,
Writable: true, WriteIdempotent: false, ArrayLength: 8),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "drv-1", new FakeTwinCATClientFactory());
@@ -52,7 +52,7 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -104,12 +104,14 @@ public sealed class TwinCATArraySupportTests
// ---- (2) read path: array read returns a typed CLR array ----
/// <summary>An equipment array tag reads a typed CLR array (boxed as object) through the driver.</summary>
private const string ArrayRawPath = "equip/Speeds";
private const string ScalarRawPath = "equip/Speed";
/// <summary>An array raw tag reads a typed CLR array (boxed as object) through the driver.</summary>
[Fact]
public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array()
public async Task Driver_reads_an_array_raw_tag_as_typed_clr_array()
{
var json =
$$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
var expected = new[] { 10, 20, 30, 40 };
var factory = new FakeTwinCATClientFactory
{
@@ -118,12 +120,12 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
RawTags = [new RawTagEntry(ArrayRawPath, blob, WriteIdempotent: false, DeviceName: Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-arr", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
var r = await drv.ReadAsync([ArrayRawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBeOfType<int[]>().ShouldBe(expected);
@@ -131,11 +133,11 @@ public sealed class TwinCATArraySupportTests
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
}
/// <summary>A scalar equipment tag reads with a null array count (scalar path unchanged).</summary>
/// <summary>A scalar raw tag reads with a null array count (scalar path unchanged).</summary>
[Fact]
public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count()
public async Task Driver_reads_a_scalar_raw_tag_with_null_array_count()
{
var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var blob = """{"symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } },
@@ -143,100 +145,83 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
RawTags = [new RawTagEntry(ScalarRawPath, blob, WriteIdempotent: false, DeviceName: Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-scalar", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
var r = await drv.ReadAsync([ScalarRawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBe(7);
factory.Clients[0].LastReadArrayCount.ShouldBeNull();
}
// ---- (3) resolver: equipment-tag parser threads arrayLength ----
// ---- (3) mapper: FromTagConfig threads arrayLength ----
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
/// <summary>The tag-definition factory threads <c>arrayLength</c> into the definition.</summary>
[Fact]
public void Parser_threads_arrayLength_into_the_definition()
public void FromTagConfig_threads_arrayLength_into_the_definition()
{
var json =
"""{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
TwinCATTagDefinitionFactory.FromTagConfig(blob, ArrayRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(12);
}
/// <summary>A blob without isArray (or with isArray=false) leaves ArrayLength null.</summary>
[Fact]
public void Parser_leaves_ArrayLength_null_for_a_scalar_blob()
public void FromTagConfig_leaves_ArrayLength_null_for_a_scalar_blob()
{
TwinCATEquipmentTagParser.TryParse(
"""{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue();
TwinCATTagDefinitionFactory.FromTagConfig(
"""{"symbolPath":"MAIN.X","dataType":"DInt"}""", ScalarRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary>arrayLength is ignored when isArray is absent/false (no orphan length).</summary>
[Fact]
public void Parser_ignores_arrayLength_when_isArray_is_false()
public void FromTagConfig_ignores_arrayLength_when_isArray_is_false()
{
TwinCATEquipmentTagParser.TryParse(
TwinCATTagDefinitionFactory.FromTagConfig(
"""{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""",
out var def).ShouldBeTrue();
ScalarRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
// ---- (4) Driver.TwinCAT-017 — pre-declared array tag parseable via driver config JSON ----
// ---- (4) Driver.TwinCAT-017 — array raw tag delivered via driver config JSON rawTags ----
/// <summary>
/// A pre-declared tag in the driver config JSON with <c>arrayLength</c> must produce a
/// An array raw tag in the driver config JSON <c>rawTags</c> array must bind + map to a
/// <see cref="TwinCATTagDefinition"/> with the correct <see cref="TwinCATTagDefinition.ArrayLength"/>,
/// which in turn drives <c>IsArray</c>/<c>ArrayDim</c> at discovery and a native array read
/// at runtime (Driver.TwinCAT-017).
/// </summary>
[Fact]
public void Predeclared_array_tag_arrayLength_parses_from_driver_config_json()
public void Array_raw_tag_arrayLength_binds_from_driver_config_json()
{
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}""";
var json = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
tags = new[]
{
new
{
name = "Speeds",
deviceHostAddress = Host,
symbolPath = "MAIN.Speeds",
dataType = "DInt",
arrayLength = 8,
},
},
rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } },
});
var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1");
parsed.Tags.Single().ArrayLength.ShouldBe(8);
var entry = parsed.RawTags.Single();
TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(8);
}
/// <summary>
/// A pre-declared array tag parsed from JSON must surface as <c>IsArray=true</c> /
/// An array raw tag delivered via the driver config JSON must surface as <c>IsArray=true</c> /
/// <c>ArrayDim=8</c> in the discovery address space.
/// </summary>
[Fact]
public async Task Predeclared_array_tag_from_json_reports_IsArray_in_discovery()
public async Task Array_raw_tag_from_json_reports_IsArray_in_discovery()
{
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}""";
var configJson = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
tags = new[]
{
new
{
name = "Speeds",
deviceHostAddress = Host,
symbolPath = "MAIN.Speeds",
dataType = "DInt",
arrayLength = 8,
},
},
rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } },
});
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1", new FakeTwinCATClientFactory());