[ablegacy] AbLegacy — Array contiguous block addressing #370
@@ -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
|
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
|
## Known caveat — ab_server upstream gap
|
||||||
|
|
||||||
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
|
|
||||||
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
||||||
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
/ 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
|
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
||||||
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
||||||
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
||||||
|
|||||||
@@ -95,5 +95,22 @@ $results += Test-SubscribeSeesChange `
|
|||||||
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
||||||
-ExpectedValue "$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
|
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
|
|||||||
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
||||||
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
||||||
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
||||||
|
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
|
||||||
|
|
||||||
BEGIN TRAN;
|
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.Equipment WHERE EquipmentId = @EqId;
|
||||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
@@ -99,6 +100,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
|||||||
"DataType": "Int",
|
"DataType": "Int",
|
||||||
"Writable": true,
|
"Writable": true,
|
||||||
"WriteIdempotent": 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);
|
}', 1);
|
||||||
@@ -108,6 +117,17 @@ INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataTyp
|
|||||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
||||||
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
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,
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
@Notes = N'AB Legacy smoke — task #213';
|
@Notes = N'AB Legacy smoke — task #213';
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,17 @@ public sealed record AbLegacyAddress(
|
|||||||
int? BitIndex,
|
int? BitIndex,
|
||||||
string? SubElement,
|
string? SubElement,
|
||||||
AbLegacyAddress? IndirectFileSource = null,
|
AbLegacyAddress? IndirectFileSource = null,
|
||||||
AbLegacyAddress? IndirectWordSource = null)
|
AbLegacyAddress? IndirectWordSource = null,
|
||||||
|
int? ArrayCount = null)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ArrayCount"/>
|
||||||
|
/// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented
|
||||||
|
/// multi-frame read.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxArrayCount = 120;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when either the file number or the word number is sourced from another PCCC
|
/// True when either the file number or the word number is sourced from another PCCC
|
||||||
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||||
@@ -66,6 +75,12 @@ public sealed record AbLegacyAddress(
|
|||||||
: WordNumber.ToString();
|
: WordNumber.ToString();
|
||||||
|
|
||||||
var wordPart = $"{filePart}:{wordSegment}";
|
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 (SubElement is not null) wordPart += $".{SubElement}";
|
||||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||||
return wordPart;
|
return wordPart;
|
||||||
@@ -173,6 +188,46 @@ public sealed record AbLegacyAddress(
|
|||||||
|
|
||||||
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
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
|
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
|
||||||
// indirect address.
|
// indirect address.
|
||||||
int word = 0;
|
int word = 0;
|
||||||
@@ -196,7 +251,38 @@ public sealed record AbLegacyAddress(
|
|||||||
bitIndex = bit;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find the index of the `[` that matches the `]` at <paramref name="closeIdx"/> in
|
||||||
|
/// <paramref name="s"/>, accounting for nested brackets. Returns -1 if no match.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -141,6 +141,25 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
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
|
// 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
|
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
|
||||||
// and pass it down to the runtime as a synthetic bitIndex.
|
// 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);
|
tag.DataType, parsed?.SubElement);
|
||||||
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
||||||
tag.DataType, parsed?.SubElement);
|
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(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: effectiveType,
|
DriverDataType: effectiveType,
|
||||||
IsArray: false,
|
IsArray: arrayLen is int al && al > 1,
|
||||||
ArrayDim: null,
|
ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null,
|
||||||
SecurityClass: tag.Writable && !plcSetBit
|
SecurityClass: tag.Writable && !plcSetBit
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
@@ -454,13 +478,24 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
|
$"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(
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
Port: device.ParsedAddress.Port,
|
Port: device.ParsedAddress.Port,
|
||||||
CipPath: device.ParsedAddress.CipPath,
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
TagName: parsed.ToLibplctagName(),
|
TagName: tagName,
|
||||||
Timeout: _options.Timeout));
|
Timeout: _options.Timeout,
|
||||||
|
ElementCount: elementCount));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
@@ -474,6 +509,54 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — pull <paramref name="elementCount"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.Bit => BuildArray<bool>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray<int>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Long => BuildArray<int>(runtime, type, elementCount),
|
||||||
|
AbLegacyDataType.Float => BuildArray<float>(runtime, type, elementCount),
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
$"AbLegacyDataType {type} is not supported in array contiguous-block reads."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T[] BuildArray<T>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — resolve the effective array element count for a tag. Explicit
|
||||||
|
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> on the tag definition wins; otherwise
|
||||||
|
/// the parsed <see cref="AbLegacyAddress.ArrayCount"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
||||||
tagName: t.Name),
|
tagName: t.Name),
|
||||||
Writable: t.Writable ?? true,
|
Writable: t.Writable ?? true,
|
||||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||||
|
ArrayLength: t.ArrayLength))]
|
||||||
: [],
|
: [],
|
||||||
Probe = new AbLegacyProbeOptions
|
Probe = new AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
@@ -112,6 +113,11 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
public string? DataType { get; init; }
|
public string? DataType { get; init; }
|
||||||
public bool? Writable { get; init; }
|
public bool? Writable { get; init; }
|
||||||
public bool? WriteIdempotent { get; init; }
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int? ArrayLength { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class AbLegacyProbeDto
|
internal sealed class AbLegacyProbeDto
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ public sealed record AbLegacyTagDefinition(
|
|||||||
string Address,
|
string Address,
|
||||||
AbLegacyDataType DataType,
|
AbLegacyDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? ArrayLength = null);
|
||||||
|
|
||||||
public sealed class AbLegacyProbeOptions
|
public sealed class AbLegacyProbeOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ public interface IAbLegacyTagRuntime : IDisposable
|
|||||||
int GetStatus();
|
int GetStatus();
|
||||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
|
||||||
|
/// block read. Implementations call the same per-element accessors used by
|
||||||
|
/// <see cref="DecodeValue"/> at offset <c>elementIndex × elementBytes</c>. Default
|
||||||
|
/// implementation throws so existing fakes that don't override remain explicit.
|
||||||
|
/// </summary>
|
||||||
|
object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
|
||||||
|
=> throw new NotSupportedException(
|
||||||
|
"Array decoding requires an IAbLegacyTagRuntime that overrides DecodeArrayElement.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAbLegacyTagFactory
|
public interface IAbLegacyTagFactory
|
||||||
@@ -26,4 +36,5 @@ public sealed record AbLegacyTagCreateParams(
|
|||||||
string CipPath,
|
string CipPath,
|
||||||
string LibplctagPlcAttribute,
|
string LibplctagPlcAttribute,
|
||||||
string TagName,
|
string TagName,
|
||||||
TimeSpan Timeout);
|
TimeSpan Timeout,
|
||||||
|
int ElementCount = 1);
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
Name = p.TagName,
|
Name = p.TagName,
|
||||||
Timeout = p.Timeout,
|
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);
|
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||||
@@ -127,6 +132,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — decode element <paramref name="elementIndex"/> 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 <c>elementIndex × elementBytes</c>.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
public void Dispose() => _tag.Dispose();
|
||||||
|
|
||||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — wire-level smoke for contiguous PCCC array reads. One libplctag tag with
|
||||||
|
/// <c>elem_count=N</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<int[]>();
|
||||||
|
arr.Length.ShouldBe(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ services:
|
|||||||
"ab_server",
|
"ab_server",
|
||||||
"--plc=SLC500",
|
"--plc=SLC500",
|
||||||
"--port=44818",
|
"--port=44818",
|
||||||
"--tag=N7[10]",
|
"--tag=N7[120]",
|
||||||
"--tag=F8[10]",
|
"--tag=F8[120]",
|
||||||
"--tag=B3[10]",
|
"--tag=B3[10]",
|
||||||
"--tag=L19[10]"
|
"--tag=L19[10]"
|
||||||
]
|
]
|
||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
"--plc=Micrologix",
|
"--plc=Micrologix",
|
||||||
"--port=44818",
|
"--port=44818",
|
||||||
"--tag=B3[10]",
|
"--tag=B3[10]",
|
||||||
"--tag=N7[10]",
|
"--tag=N7[120]",
|
||||||
"--tag=L19[10]"
|
"--tag=L19[10]"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
"ab_server",
|
"ab_server",
|
||||||
"--plc=PLC/5",
|
"--plc=PLC/5",
|
||||||
"--port=44818",
|
"--port=44818",
|
||||||
"--tag=N7[10]",
|
"--tag=N7[120]",
|
||||||
"--tag=F8[10]",
|
"--tag=F8[120]",
|
||||||
"--tag=B3[10]"
|
"--tag=B3[10]"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — array contiguous block addressing. One libplctag tag with <c>elem_count=N</c>
|
||||||
|
/// pulls N consecutive PCCC words in a single frame. Parser accepts both Rockwell `,N`
|
||||||
|
/// and libplctag `[N]` suffixes; driver advertises <see cref="DriverAttributeInfo.IsArray"/>
|
||||||
|
/// and decodes the buffer element-by-element.
|
||||||
|
/// </summary>
|
||||||
|
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<object?>().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<int[]>();
|
||||||
|
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<float[]>();
|
||||||
|
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<bool[]>();
|
||||||
|
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<RecordedVariable> 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);
|
||||||
|
}
|
||||||
@@ -60,6 +60,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
|||||||
return Value;
|
return Value;
|
||||||
}
|
}
|
||||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 7 — array contiguous-block element decoder. Tests seed <see cref="ArrayValues"/>
|
||||||
|
/// with the per-index payload; the fake then returns each element in order. Falls back
|
||||||
|
/// to <see cref="Value"/> when the test only seeded a scalar (Convert.ChangeType handles
|
||||||
|
/// the cast back to the requested element type in the driver's BuildArray helper).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<object?>? 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;
|
public virtual void Dispose() => Disposed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user