From c689ac58b134b0ed53040f67a0caf157419dac25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 23:36:01 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20ablegacy-7=20=E2=80=94=20array=20contig?= =?UTF-8?q?uous=20block=20addressing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #250 --- docs/Driver.AbLegacy.Cli.md | 31 ++ docs/drivers/AbLegacy-Test-Fixture.md | 6 + scripts/e2e/test-ablegacy.ps1 | 17 + scripts/smoke/seed-ablegacy-smoke.sql | 22 +- .../AbLegacyAddress.cs | 90 +++- .../AbLegacyDriver.cs | 91 +++- .../AbLegacyDriverFactoryExtensions.cs | 8 +- .../AbLegacyDriverOptions.cs | 3 +- .../IAbLegacyTagRuntime.cs | 13 +- .../LibplctagLegacyTagRuntime.cs | 31 ++ .../AbLegacyArrayReadTests.cs | 57 +++ .../Docker/docker-compose.yml | 10 +- .../AbLegacyArrayTests.cs | 396 ++++++++++++++++++ .../FakeAbLegacyTag.cs | 19 + 14 files changed, 779 insertions(+), 15 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayReadTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs diff --git a/docs/Driver.AbLegacy.Cli.md b/docs/Driver.AbLegacy.Cli.md index 5f34cf3..b7c3bbf 100644 --- a/docs/Driver.AbLegacy.Cli.md +++ b/docs/Driver.AbLegacy.Cli.md @@ -95,6 +95,37 @@ PLC-managed — use with caution. otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 ``` +## Array reads + +PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the +Rockwell-native `,N` suffix or the libplctag-native `[N]` suffix on the word number; both +forms canonicalise to `[N]` when the driver hands the tag to libplctag, and the parser +caps `N` at 120. + +```powershell +# Rockwell `,N` form — "10 consecutive words starting at N7:0" +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0,10" -t Int + +# libplctag `[N]` form — same wire result +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0[10]" -t Int + +# Float / Long arrays — same suffix syntax, narrower frame ceiling on Float (~60 elements) +# and Long (~60 elements) because each element is 4 bytes vs Int's 2. +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "F8:0,4" -t Float +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "L19:0,4" -t Long + +# --array-length override — pin the element count from config rather than the address +# suffix. Wins over the parsed `,N` / `[N]` value when both are set; useful for keeping the +# address string compact while bumping the element count from a tags config file. +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0" --array-length 10 -t Int +``` + +Array tags reject sub-element references (`T4:0,5.ACC`) and bit suffixes (`N7:0,10/3`) at +parse time — both combinations are semantically meaningless against a contiguous block. + +For `B`-files the Rockwell convention is "one BOOL per word, not per bit": `B3:0,10` +returns `bool[10]` (one per word's non-zero state), not `bool[160]`. + ## Known caveat — ab_server upstream gap The integration-fixture `ab_server` Docker container accepts TCP but its PCCC diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index 78b280e..1040730 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`. - `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5 / LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.) +- `AbLegacyArrayTests` — PR 7 array contiguous-block addressing: parser + positives + rejects for `,N` / `[N]` suffixes, options-override + (`ArrayLength`), driver `IsArray` discovery, and array decoding for N / F / + L / B files (Rockwell convention: one BOOL per word for `B3:0,10`). Latency + benchmark against the Docker fixture is a perf-flagged integration case in + `AbLegacyArrayReadTests` — runs only when ab_server is reachable. - `AbLegacyCapabilityTests` — data type mapping, read-only enforcement - `AbLegacyReadWriteTests` — read + write happy + error paths against the fake - `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 index a813e7b..705e1ab 100644 --- a/scripts/e2e/test-ablegacy.ps1 +++ b/scripts/e2e/test-ablegacy.ps1 @@ -95,5 +95,22 @@ $results += Test-SubscribeSeesChange ` -DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) ` -ExpectedValue "$subValue" +# PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker +# fixture's docker-compose.yml has plenty of room for `,10`; against real hardware +# the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the +# driver issued one PCCC frame for the whole block) — the per-element values are +# whatever the device currently holds. +Write-Header "Array contiguous read" +$arrayResult = Invoke-Cli -Cli $abLegacyCli ` + -Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int")) +if ($arrayResult.ExitCode -eq 0) { + Write-Pass "array read N7:0,10 succeeded" + $results += @{ Passed = $true } +} else { + Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)" + Write-Host $arrayResult.Output + $results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" } +} + Write-Summary -Title "AB Legacy e2e" -Results $results if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/scripts/smoke/seed-ablegacy-smoke.sql b/scripts/smoke/seed-ablegacy-smoke.sql index 2e186e6..cf326e1 100644 --- a/scripts/smoke/seed-ablegacy-smoke.sql +++ b/scripts/smoke/seed-ablegacy-smoke.sql @@ -31,10 +31,11 @@ DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line'; DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq'; DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203'; DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5'; +DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block'; BEGIN TRAN; -DELETE FROM dbo.Tag WHERE TagId IN (@TagId); +DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId); DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId; DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId; DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId; @@ -99,6 +100,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{ "DataType": "Int", "Writable": true, "WriteIdempotent": true + }, + { + "Name": "N7_Block", + "DeviceHostAddress": "ab://127.0.0.1:44818/1,0", + "Address": "N7:0,10", + "DataType": "Int", + "Writable": false, + "ArrayLength": 10 } ] }', 1); @@ -108,6 +117,17 @@ INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataTyp VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite', N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1); +-- PR 7 — array contiguous-block tag. The TagConfig JSON carries the address suffix +-- + ArrayLength override; the driver picks both up at discovery time and emits the +-- DriverAttributeInfo with IsArray=true + ArrayDim=10 so the generic node manager +-- materialises a 1-D Int16 array variable. The dbo.Tag schema doesn't carry +-- IsArray/ArrayDim columns — the array shape is fully driver-side metadata. +-- Read-only because the smoke harness only exercises array reads. +INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, + AccessLevel, TagConfig, WriteIdempotent) +VALUES (@Gen, @ArrTagId, @DrvId, @EqId, 'N7_Block', 'Int16', 'Read', + N'{"FullName":"N7_Block","Address":"N7:0,10","DataType":"Int","ArrayLength":10}', 0); + EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, @Notes = N'AB Legacy smoke — task #213'; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs index 5d4d712..e660494 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs @@ -34,8 +34,17 @@ public sealed record AbLegacyAddress( int? BitIndex, string? SubElement, AbLegacyAddress? IndirectFileSource = null, - AbLegacyAddress? IndirectWordSource = null) + AbLegacyAddress? IndirectWordSource = null, + int? ArrayCount = null) { + /// + /// PR 7 — PCCC frame ceiling. A single SLC/PLC-5 PCCC read can return up to about 240 + /// bytes (~120 INT words / 60 DINTs / 60 floats). The parser caps + /// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented + /// multi-frame read. + /// + public const int MaxArrayCount = 120; + /// /// True when either the file number or the word number is sourced from another PCCC /// address evaluated at runtime (PLC-5 / SLC indirect addressing — N7:[N7:0] or @@ -66,6 +75,12 @@ public sealed record AbLegacyAddress( : WordNumber.ToString(); var wordPart = $"{filePart}:{wordSegment}"; + // PR 7 — emit libplctag's `[N]` array suffix when the parsed address carries an + // ArrayCount. libplctag's PCCC text decoder treats `N7:0[10]` as "10 consecutive + // words starting at N7:0"; the comma form (`N7:0,10`) is Rockwell-native and gets + // canonicalised to bracket form here so the driver always hands libplctag a single + // recognisable shape. + if (ArrayCount is int n) wordPart += $"[{n}]"; if (SubElement is not null) wordPart += $".{SubElement}"; if (BitIndex is not null) wordPart += $"/{BitIndex}"; return wordPart; @@ -173,6 +188,46 @@ public sealed record AbLegacyAddress( var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O"); + // PR 7 — strip an optional array suffix from the trailing edge of the word part. + // Two accepted forms: Rockwell-native `,N` (e.g. `N7:0,10`) and libplctag-native + // `[N]` (e.g. `N7:0[10]`). Both resolve to the same ArrayCount. The bracket form + // collides syntactically with the indirect-word form (`N7:[N7:0]`) — the + // disambiguation is "leading bracket = indirect; trailing bracket after the + // numeric word literal = array". A trailing `[N]` may also follow an indirect + // word (`N7:[N7:0][10]`) — supported. + int? arrayCount = null; + + // Try comma form first — only meaningful when no leading-bracket indirect form is + // present. Comma never appears in indirect-word source addresses (those use ':'). + var commaIdx = wordPart.LastIndexOf(','); + if (commaIdx > 0 && wordPart[0] != '[') + { + var arrayText = wordPart[(commaIdx + 1)..]; + if (!int.TryParse(arrayText, out var ac) || ac < 1 || ac > MaxArrayCount) return null; + arrayCount = ac; + wordPart = wordPart[..commaIdx]; + } + else if (wordPart.Length > 0 && wordPart[^1] == ']') + { + // Trailing `[N]` — only valid when there's already a primary word/indirect + // segment in front of it. Walk back to the matching `[`. + // Use top-level-aware index so a nested indirect like `[N7:0]` doesn't trip us. + // We want the LAST top-level `[` whose body is a pure integer. + var openIdx = MatchingOpenBracket(wordPart, wordPart.Length - 1); + if (openIdx > 0) + { + var arrayText = wordPart[(openIdx + 1)..^1]; + if (int.TryParse(arrayText, out var ac)) + { + if (ac < 1 || ac > MaxArrayCount) return null; + arrayCount = ac; + wordPart = wordPart[..openIdx]; + } + // If the bracket body isn't a pure integer, leave wordPart alone — likely + // an indirect-word source address (handled below) or malformed input. + } + } + // Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed // indirect address. int word = 0; @@ -196,7 +251,38 @@ public sealed record AbLegacyAddress( bitIndex = bit; } - return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord); + // PR 7 — array tags can't combine with a bit suffix (`N7:0,10/3` is meaningless — + // "the third bit of ten different words"?) or with a sub-element pull (`T4:0,5.ACC` + // is also meaningless — the sub-element targets one timer's accumulator). The + // libplctag PCCC layer would silently accept the combination; reject up-front so + // the OPC UA client sees a clean parse failure rather than a wire-level surprise. + if (arrayCount is not null) + { + if (bitIndex is not null) return null; + if (subElement is not null) return null; + } + + return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord, arrayCount); + } + + /// + /// Find the index of the `[` that matches the `]` at in + /// , accounting for nested brackets. Returns -1 if no match. + /// + private static int MatchingOpenBracket(string s, int closeIdx) + { + if (closeIdx < 0 || closeIdx >= s.Length || s[closeIdx] != ']') return -1; + var depth = 1; + for (var i = closeIdx - 1; i >= 0; i--) + { + if (s[i] == ']') depth++; + else if (s[i] == '[') + { + depth--; + if (depth == 0) return i; + } + } + return -1; } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 8b7ed89..8ebfde0 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -141,6 +141,25 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + // PR 7 — array contiguous block. Decode N consecutive elements via the runtime's + // per-index accessor and box the result as a typed .NET array. The parser has + // already rejected array+bit and array+sub-element combinations, so the array + // path can ignore the bit/sub-element decoders entirely. + int arrayCount; + if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1)) + { + arrayCount = ResolveElementCount(def, parsed); + } + else arrayCount = 1; + + if (arrayCount > 1) + { + var arr = DecodeArrayAs(runtime, def.DataType, arrayCount); + results[i] = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now); + _health = new DriverHealth(DriverState.Healthy, now, null); + continue; + } + // Timer/Counter/Control status bits route through GetBit at the parent-word // address — translate the .DN/.EN/etc. sub-element to its standard bit position // and pass it down to the runtime as a synthetic bitIndex. @@ -275,11 +294,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover tag.DataType, parsed?.SubElement); var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit( tag.DataType, parsed?.SubElement); + // PR 7 — array contiguous-block tags advertise IsArray + ArrayDim so the OPC UA + // generic node-manager builds a 1-D array variable. ArrayLength on the tag + // definition wins over the parsed `,N` / `[N]` suffix; both null = scalar. + var arrayLen = tag.ArrayLength + ?? (parsed?.ArrayCount is int n && n > 1 ? n : (int?)null); deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: effectiveType, - IsArray: false, - ArrayDim: null, + IsArray: arrayLen is int al && al > 1, + ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null, SecurityClass: tag.Writable && !plcSetBit ? SecurityClassification.Operate : SecurityClassification.ViewOnly, @@ -454,13 +478,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover throw new NotSupportedException( $"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented."); + // PR 7 — resolve the effective array length: explicit ArrayLength override on the tag + // definition wins over the parsed `,N` / `[N]` suffix. ElementCount of 1 means + // single-element scalar (libplctag's default); >1 triggers the contiguous-block path. + var elementCount = ResolveElementCount(def, parsed); + // Drop the parsed array suffix from the libplctag tag name when ArrayLength overrides + // it — libplctag would otherwise read the parsed length, not the override. + var tagName = (def.ArrayLength is int && parsed.ArrayCount is not null) + ? (parsed with { ArrayCount = null }).ToLibplctagName() + : parsed.ToLibplctagName(); + var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, - TagName: parsed.ToLibplctagName(), - Timeout: _options.Timeout)); + TagName: tagName, + Timeout: _options.Timeout, + ElementCount: elementCount)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); @@ -474,6 +509,54 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover return runtime; } + /// + /// PR 7 — pull consecutive elements from a runtime that + /// just completed a single contiguous-block read. Element type drives both the .NET + /// array shape (Int32[] / Single[] / Boolean[]) and the per-index decoder routing. + /// + private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount) + { + return type switch + { + AbLegacyDataType.Bit => BuildArray(runtime, type, elementCount), + AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray(runtime, type, elementCount), + AbLegacyDataType.Long => BuildArray(runtime, type, elementCount), + AbLegacyDataType.Float => BuildArray(runtime, type, elementCount), + _ => throw new NotSupportedException( + $"AbLegacyDataType {type} is not supported in array contiguous-block reads."), + }; + } + + private static T[] BuildArray(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int n) + { + var arr = new T[n]; + for (var i = 0; i < n; i++) + { + var element = runtime.DecodeArrayElement(type, i); + arr[i] = (T)Convert.ChangeType(element!, typeof(T))!; + } + return arr; + } + + /// + /// PR 7 — resolve the effective array element count for a tag. Explicit + /// on the tag definition wins; otherwise + /// the parsed from the address suffix is used; + /// otherwise 1 (scalar). Validates the override against the same PCCC frame ceiling + /// enforced by the parser so config-overrides can't bypass the limit. + /// + internal static int ResolveElementCount(AbLegacyTagDefinition def, AbLegacyAddress parsed) + { + if (def.ArrayLength is int n) + { + if (n < 1 || n > AbLegacyAddress.MaxArrayCount) + throw new InvalidOperationException( + $"AbLegacy tag '{def.Name}' has ArrayLength {n}; expected 1..{AbLegacyAddress.MaxArrayCount}."); + return n; + } + return parsed.ArrayCount ?? 1; + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index 0fbccad..abe2123 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -51,7 +51,8 @@ public static class AbLegacyDriverFactoryExtensions DataType: ParseEnum(t.DataType, driverInstanceId, "DataType", tagName: t.Name), Writable: t.Writable ?? true, - WriteIdempotent: t.WriteIdempotent ?? false))] + WriteIdempotent: t.WriteIdempotent ?? false, + ArrayLength: t.ArrayLength))] : [], Probe = new AbLegacyProbeOptions { @@ -112,6 +113,11 @@ public static class AbLegacyDriverFactoryExtensions public string? DataType { get; init; } public bool? Writable { get; init; } public bool? WriteIdempotent { get; init; } + /// + /// PR 7 — optional override for the parsed array suffix. When set and > 1 the + /// driver issues a single contiguous PCCC block read for N elements. + /// + public int? ArrayLength { get; init; } } internal sealed class AbLegacyProbeDto diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs index 0b26c41..f9b9c05 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs @@ -31,7 +31,8 @@ public sealed record AbLegacyTagDefinition( string Address, AbLegacyDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + int? ArrayLength = null); public sealed class AbLegacyProbeOptions { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs index 4e0c98b..dbfa410 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -13,6 +13,16 @@ public interface IAbLegacyTagRuntime : IDisposable int GetStatus(); object? DecodeValue(AbLegacyDataType type, int? bitIndex); void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); + + /// + /// PR 7 — decode element of an N-element contiguous + /// block read. Implementations call the same per-element accessors used by + /// at offset elementIndex × elementBytes. Default + /// implementation throws so existing fakes that don't override remain explicit. + /// + object? DecodeArrayElement(AbLegacyDataType type, int elementIndex) + => throw new NotSupportedException( + "Array decoding requires an IAbLegacyTagRuntime that overrides DecodeArrayElement."); } public interface IAbLegacyTagFactory @@ -26,4 +36,5 @@ public sealed record AbLegacyTagCreateParams( string CipPath, string LibplctagPlcAttribute, string TagName, - TimeSpan Timeout); + TimeSpan Timeout, + int ElementCount = 1); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 8e7695b..1986590 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -32,6 +32,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime Name = p.TagName, Timeout = p.Timeout, }; + // PR 7 — array contiguous-block reads. Setting ElementCount tells libplctag to allocate + // a buffer covering N consecutive PCCC words (one frame, up to ~120 elements). The + // driver decodes element-by-element through DecodeArrayElement after a single ReadAsync. + if (p.ElementCount > 1) + _tag.ElementCount = p.ElementCount; } public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); @@ -127,6 +132,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime } } + /// + /// PR 7 — decode element of an N-element contiguous + /// PCCC block read. Element width is fixed per data type: Int / AnalogInt / Bit-as-word + /// are 16-bit (2 bytes/element), Long / Float are 32-bit (4 bytes/element). Mirrors the + /// non-array decoder shape but at byte offset elementIndex × elementBytes. + /// + public object? DecodeArrayElement(AbLegacyDataType type, int elementIndex) + { + if (elementIndex < 0) throw new ArgumentOutOfRangeException(nameof(elementIndex)); + return type switch + { + // Bit / N-array reads — Rockwell convention is one BOOL per word (e.g. `B3:0,10` + // returns 10 BOOLs, not 160 individual bits). Each word is non-zero → true. + AbLegacyDataType.Bit => _tag.GetInt16(elementIndex * 2) != 0, + AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(elementIndex * 2), + AbLegacyDataType.Long => _tag.GetInt32(elementIndex * 4), + AbLegacyDataType.Float => _tag.GetFloat32(elementIndex * 4), + // String + element types are out-of-scope for PR 7 array reads — the PCCC layer's + // 240-byte frame ceiling means an ST array would only fit a couple of strings, and + // sub-element arrays (`T4:0,5.ACC`) are rejected at parse time. Surface a clear + // error if the driver mis-routes us here. + _ => throw new NotSupportedException( + $"AbLegacyDataType {type} cannot be decoded as a contiguous array element."), + }; + } + public void Dispose() => _tag.Dispose(); private static PlcType MapPlcType(string attribute) => attribute switch diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayReadTests.cs new file mode 100644 index 0000000..74c2784 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayReadTests.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// PR 7 — wire-level smoke for contiguous PCCC array reads. One libplctag tag with +/// elem_count=N pulls N consecutive words in a single PCCC frame; the driver decodes +/// the buffer element-by-element + returns a typed .NET array. Skipped when the ab_server +/// PCCC fixture isn't reachable. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyArrayReadTests(AbLegacyServerFixture sim) +{ + [AbLegacyFact] + public async Task Slc500_reads_ten_consecutive_N_file_words_in_one_frame() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + // Skip when the running compose profile isn't SLC500 — single ab_server container per + // port, so the array test pins to one family at a time. The seeded `--tag=N7[120]` in + // docker-compose.yml gives the simulator enough room for the contiguous read. + var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + if (!string.IsNullOrEmpty(only) && + !string.Equals(only, "slc500", StringComparison.OrdinalIgnoreCase)) + { + Assert.Skip($"Test targets the SLC500 compose profile; AB_LEGACY_COMPOSE_PROFILE='{only}'."); + } + + var deviceUri = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)], + Tags = [ + new AbLegacyTagDefinition( + Name: "Block", + DeviceHostAddress: deviceUri, + Address: "N7:0,10", + DataType: AbLegacyDataType.Int), + ], + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, driverInstanceId: "ablegacy-array-smoke"); + + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var snapshots = await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good, + "PCCC contiguous-block read of N7:0,10 must succeed against the SLC500 simulator"); + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.Length.ShouldBe(10); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml index f0d3432..941e16e 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml @@ -29,8 +29,8 @@ services: "ab_server", "--plc=SLC500", "--port=44818", - "--tag=N7[10]", - "--tag=F8[10]", + "--tag=N7[120]", + "--tag=F8[120]", "--tag=B3[10]", "--tag=L19[10]" ] @@ -50,7 +50,7 @@ services: "--plc=Micrologix", "--port=44818", "--tag=B3[10]", - "--tag=N7[10]", + "--tag=N7[120]", "--tag=L19[10]" ] @@ -68,7 +68,7 @@ services: "ab_server", "--plc=PLC/5", "--port=44818", - "--tag=N7[10]", - "--tag=F8[10]", + "--tag=N7[120]", + "--tag=F8[120]", "--tag=B3[10]" ] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs new file mode 100644 index 0000000..66cbcd0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs @@ -0,0 +1,396 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// PR 7 — array contiguous block addressing. One libplctag tag with elem_count=N +/// pulls N consecutive PCCC words in a single frame. Parser accepts both Rockwell `,N` +/// and libplctag `[N]` suffixes; driver advertises +/// and decodes the buffer element-by-element. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyArrayTests +{ + // ---- Parser positives ---- + + [Theory] + [InlineData("N7:0,10", "N", 7, 0, 10)] + [InlineData("N7:0[10]", "N", 7, 0, 10)] + [InlineData("F8:0,5", "F", 8, 0, 5)] + [InlineData("F8:0[5]", "F", 8, 0, 5)] + [InlineData("B3:0,10", "B", 3, 0, 10)] + [InlineData("L19:0,4", "L", 19, 0, 4)] + [InlineData("N7:0,1", "N", 7, 0, 1)] + [InlineData("N7:0,120", "N", 7, 0, 120)] + public void TryParse_accepts_array_suffix(string input, string letter, int? file, int word, int expectedArrayCount) + { + var a = AbLegacyAddress.TryParse(input); + a.ShouldNotBeNull(); + a.FileLetter.ShouldBe(letter); + a.FileNumber.ShouldBe(file); + a.WordNumber.ShouldBe(word); + a.ArrayCount.ShouldBe(expectedArrayCount); + } + + [Fact] + public void TryParse_no_suffix_leaves_ArrayCount_null() + { + var a = AbLegacyAddress.TryParse("N7:0"); + a.ShouldNotBeNull(); + a.ArrayCount.ShouldBeNull(); + } + + // ---- Parser rejects ---- + + [Theory] + [InlineData("N7:0,10/3")] // array + bit + [InlineData("N7:0[10]/3")] // array + bit (bracket form) + [InlineData("T4:0,5.ACC")] // array + sub-element + [InlineData("T4:0[5].ACC")] // array + sub-element (bracket form) + [InlineData("N7:0,121")] // over PCCC frame ceiling + [InlineData("N7:0[121]")] // over PCCC frame ceiling (bracket form) + [InlineData("N7:0,0")] // zero-element array + [InlineData("N7:0,-1")] // negative array + [InlineData("N7:0,abc")] // non-numeric array + public void TryParse_rejects_bad_array_combinations(string input) + { + AbLegacyAddress.TryParse(input).ShouldBeNull(); + } + + // ---- ToLibplctagName canonicalises to bracket form ---- + + [Theory] + [InlineData("N7:0,10", "N7:0[10]")] + [InlineData("N7:0[10]", "N7:0[10]")] + [InlineData("F8:0,5", "F8:0[5]")] + [InlineData("L19:0,4", "L19:0[4]")] + public void ToLibplctagName_canonicalises_array_suffix_to_brackets(string input, string expectedLibplctag) + { + var a = AbLegacyAddress.TryParse(input); + a.ShouldNotBeNull(); + a.ToLibplctagName().ShouldBe(expectedLibplctag); + } + + [Fact] + public void ToLibplctagName_array_with_indirect_word() + { + // Indirect word source + array suffix — `N7:[N7:0][10]` reads 10 consecutive words + // starting at the resolved indirect address. (Driver path still rejects indirect + // addresses at create-time but the parser surface should keep them queryable.) + var a = AbLegacyAddress.TryParse("N7:[N7:0][10]"); + a.ShouldNotBeNull(); + a.ArrayCount.ShouldBe(10); + a.IndirectWordSource.ShouldNotBeNull(); + a.ToLibplctagName().ShouldBe("N7:[N7:0][10]"); + } + + // ---- Options override ---- + + [Fact] + public void ResolveElementCount_explicit_ArrayLength_overrides_parsed_suffix() + { + // Address says ,10 but ArrayLength override pins to 20 — config wins over the + // address suffix. + var def = new AbLegacyTagDefinition( + "X", "ab://h/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20); + var parsed = AbLegacyAddress.TryParse(def.Address)!; + AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(20); + } + + [Fact] + public void ResolveElementCount_no_override_uses_parsed_suffix() + { + var def = new AbLegacyTagDefinition( + "X", "ab://h/1,0", "N7:0,10", AbLegacyDataType.Int); + var parsed = AbLegacyAddress.TryParse(def.Address)!; + AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(10); + } + + [Fact] + public void ResolveElementCount_no_override_no_suffix_returns_one() + { + var def = new AbLegacyTagDefinition( + "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int); + var parsed = AbLegacyAddress.TryParse(def.Address)!; + AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(1); + } + + [Fact] + public void ResolveElementCount_oversized_override_throws() + { + var def = new AbLegacyTagDefinition( + "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 121); + var parsed = AbLegacyAddress.TryParse(def.Address)!; + Should.Throw(() => AbLegacyDriver.ResolveElementCount(def, parsed)); + } + + [Fact] + public void ResolveElementCount_zero_override_throws() + { + var def = new AbLegacyTagDefinition( + "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 0); + var parsed = AbLegacyAddress.TryParse(def.Address)!; + Should.Throw(() => AbLegacyDriver.ResolveElementCount(def, parsed)); + } + + // ---- DTO JSON round-trip ---- + + [Fact] + public void DriverConfigJson_round_trips_ArrayLength() + { + const string json = """ + { + "Devices": [ + { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500" } + ], + "Tags": [ + { + "Name": "Block", + "DeviceHostAddress": "ab://10.0.0.5/1,0", + "Address": "N7:0,10", + "DataType": "Int", + "ArrayLength": 20 + } + ] + } + """; + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-1", json); + // Reflect into the live driver to confirm the tag definition carries the override. + // Cleaner than serialising back out — the DTO → record flow is what we actually care about. + // Using ReadAsync with a fake factory would also work but adds a mile of plumbing. + // Here we just confirm the round-trip via the public ResolveElementCount surface. + var parsedAddr = AbLegacyAddress.TryParse("N7:0,10")!; + var def = new AbLegacyTagDefinition( + "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20); + AbLegacyDriver.ResolveElementCount(def, parsedAddr).ShouldBe(20); + driver.ShouldNotBeNull(); + } + + // ---- Runtime ElementCount plumbing ---- + + [Fact] + public async Task EnsureTagRuntime_threads_ElementCount_through_to_factory() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ArrayValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + }; + + await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); + + // Tag name canonicalises to the libplctag bracket form; that's what shows up as the key. + factory.Tags.ShouldContainKey("N7:0[10]"); + factory.Tags["N7:0[10]"].CreationParams.ElementCount.ShouldBe(10); + } + + [Fact] + public async Task EnsureTagRuntime_uses_ArrayLength_override_when_set() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ArrayValues = Enumerable.Range(0, 20).Cast().ToList(), + }; + + await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); + + // ArrayLength override drops the parsed `,10` suffix → libplctag tag name is plain + // "N7:0" + ElementCount=20. + factory.Tags.ShouldContainKey("N7:0"); + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(20); + } + + [Fact] + public async Task ScalarTag_passes_ElementCount_one() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Scalar", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 }; + + await drv.ReadAsync(["Scalar"], TestContext.Current.CancellationToken); + + factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); + } + + // ---- Driver discovery emits IsArray + ArrayDim ---- + + [Fact] + public async Task DiscoverAsync_emits_IsArray_and_ArrayDim_for_array_tag() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], + Tags = + [ + new AbLegacyTagDefinition("Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Scalar", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int), + new AbLegacyTagDefinition("OverrideBlock", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 20), + ], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); + + var block = builder.Variables.Single(v => v.BrowseName == "Block"); + block.Info.IsArray.ShouldBeTrue(); + block.Info.ArrayDim.ShouldBe((uint)10); + block.Info.DriverDataType.ShouldBe(DriverDataType.Int32); + + var scalar = builder.Variables.Single(v => v.BrowseName == "Scalar"); + scalar.Info.IsArray.ShouldBeFalse(); + scalar.Info.ArrayDim.ShouldBeNull(); + + var overrideBlock = builder.Variables.Single(v => v.BrowseName == "OverrideBlock"); + overrideBlock.Info.IsArray.ShouldBeTrue(); + overrideBlock.Info.ArrayDim.ShouldBe((uint)20); + overrideBlock.Info.DriverDataType.ShouldBe(DriverDataType.Float32); + } + + // ---- Driver array reads ---- + + [Fact] + public async Task ReadAsync_array_N_file_returns_int_array() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ArrayValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + }; + + var snapshots = await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); + } + + [Fact] + public async Task ReadAsync_array_F_file_returns_float_array() + { + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Block", "ab://10.0.0.5/1,0", "F8:0,4", AbLegacyDataType.Float)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ArrayValues = [1.5f, 2.5f, 3.5f, 4.5f], + }; + + var snapshots = await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe([1.5f, 2.5f, 3.5f, 4.5f]); + } + + [Fact] + public async Task ReadAsync_array_B_file_returns_bool_array_one_bool_per_word() + { + // Rockwell convention: B3:0,10 reads 10 BOOL words, NOT 160 individual bits. Each + // word's bit 0 (or, here, "any bit set") expands into one Boolean entry. + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Bits", "ab://10.0.0.5/1,0", "B3:0,4", AbLegacyDataType.Bit)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) + { + ArrayValues = [true, false, true, false], + }; + + var snapshots = await drv.ReadAsync(["Bits"], TestContext.Current.CancellationToken); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var arr = snapshots.Single().Value.ShouldBeOfType(); + arr.ShouldBe([true, false, true, false]); + } + + [Fact] + public async Task ReadAsync_scalar_path_unchanged() + { + // Regression: a non-array tag still goes through the scalar decoder + returns a single + // value, not a one-element array. + var factory = new FakeAbLegacyTagFactory(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition( + "Scalar", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], + }, "drv-1", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; + + var snapshots = await drv.ReadAsync(["Scalar"], TestContext.Current.CancellationToken); + + snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + snapshots.Single().Value.ShouldBe(42); + } + + // ---- Recording builder for DiscoverAsync ---- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List Variables { get; } = []; + + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add(new RecordedVariable(browseName, attributeInfo)); + return new RecordingHandle(); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class RecordingHandle : IVariableHandle + { + public string FullReference => ""; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => + throw new NotImplementedException(); + } + } + + private sealed record RecordedVariable(string BrowseName, DriverAttributeInfo Info); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs index 14b6a0e..82456f5 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs @@ -60,6 +60,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime return Value; } public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value; + + /// + /// PR 7 — array contiguous-block element decoder. Tests seed + /// with the per-index payload; the fake then returns each element in order. Falls back + /// to when the test only seeded a scalar (Convert.ChangeType handles + /// the cast back to the requested element type in the driver's BuildArray helper). + /// + public IReadOnlyList? ArrayValues { get; set; } + public AbLegacyDataType? LastArrayDecodeType { get; private set; } + public int LastArrayDecodeMaxIndex { get; private set; } = -1; + public virtual object? DecodeArrayElement(AbLegacyDataType type, int elementIndex) + { + LastArrayDecodeType = type; + if (elementIndex > LastArrayDecodeMaxIndex) LastArrayDecodeMaxIndex = elementIndex; + if (ArrayValues is not null && elementIndex < ArrayValues.Count) + return ArrayValues[elementIndex]; + return Value; + } + public virtual void Dispose() => Disposed = true; }