Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -151,7 +151,9 @@ Two things to know before relying on any of this:
|
||||
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus bounded
|
||||
by `maxRegistersPerWrite`/coil cap, S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
|
||||
resize the Galaxy attribute); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
|
||||
resize the Galaxy attribute; AbCip atomic non-BOOL types, AbLegacy Int/Long/Float on the matching data file
|
||||
(no `/bit`/`.sub`/indirect/`S:`/`I:`/`O:`, not native PLC-5, ≤ 256), TwinCAT every codec type but a BOOL
|
||||
bit-in-word path — #737/#738/#739; those three CONVERT elements, so they never key on an exact node type); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
|
||||
ArrayWritable)` is projected onto the raw node and every UNS mirror.
|
||||
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
|
||||
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
|
||||
|
||||
+16
-5
@@ -327,12 +327,23 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
|
||||
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
||||
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
|
||||
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
|
||||
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||||
| **AB CIP** | Yes (#737) | `SInt`/`USInt`/`Int`/`UInt`/`DInt`/`UDInt`/`Real`/`Dt`/`LInt`/`ULInt`/`LReal` (`AbCipArrayCodec`, both transports). Not `Bool` (bit-packed DWORDs), `String` or `Structure`, nor a `safetyTag` |
|
||||
| **AB Legacy** | Yes (#738) | `Int`/`AnalogInt` on an N/B/A file, `Long` on an L file, `Float` on an F file — the encode width is the data file's, so a type that does not match its file stays read-only — on a plain `<file><number>:<word>` address, ≤ 256 elements. Not Bit / String / Timer / Counter / Control arrays, `/bit` or `.sub` addresses, indirect addresses, the `S:`/`I:`/`O:` files, nor any PLC-5 on the native transport |
|
||||
| **TwinCAT** | Yes (#739) | Every element type `TwinCATArrayCodec` lays out (numeric, `Bool`, `String`/`WString`, temporal); multi-dimensional PLC arrays are written row-major from the flat value. Not a BOOL bit-in-word path (`MAIN.Word.3`) |
|
||||
| FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||||
|
||||
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
|
||||
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
|
||||
scalar encoder cannot take an array), even though the composer still materialises an array node;
|
||||
that node stays read-only.
|
||||
Every "Yes" driver but OpcUaClient additionally needs a **declared length** — `isArray: true` *and* an
|
||||
integer `arrayLength >= 1`. The tag factories read anything else as a **scalar** tag (whose scalar
|
||||
encoder cannot take an array), even though the composer still materialises an array node; that node
|
||||
stays read-only.
|
||||
|
||||
**Elements convert; they are not exact-matched.** ScadaBridge writes CLR `int[]` / `List<int>` to Int16
|
||||
nodes: the node manager converts a numeric array to the node's element type, and the AB CIP, AB Legacy
|
||||
and TwinCAT encoders convert each element again to the controller's type (an element that does not fit
|
||||
is `BadOutOfRange`). Those rows therefore do not key on an exact node `DataType`; they leave out only
|
||||
node types whose elements never convert — a `DateTime` or `String` node over a numeric element, and a
|
||||
`Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update, no rebuild).
|
||||
|
||||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||||
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
|
||||
|
||||
+16
-5
@@ -252,7 +252,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
write, read back, restored byte-exact). A user-approved **native array-element write** followed: `Program:EM01_PowerUnit._SEQ_EP[31]`
|
||||
0→4242→0 (the array's only alias targets `[1]`, no routine indexes or block-copies it; all 32 elements snapshotted
|
||||
twice before, after the write and after the restore — only `[31]` changed). Still ab_server-only: whole-array
|
||||
writes (the driver has none), libplctag member writes, and fragmented writes. #566 is answered (the controller drops TCP; see "A target-closed CIP connection"). Mixed STRING/array/scalar
|
||||
writes (#737; see [1-D array support](#1-d-array-support)), libplctag member writes, and fragmented writes. #566 is answered (the controller drops TCP; see "A target-closed CIP connection"). Mixed STRING/array/scalar
|
||||
Multiple Service Packet reads (#582) are proven on the press (one 5-read MSP, values equal to single reads). Results:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
- **libplctag cannot be deleted yet.** Scalar, UDT-member and STRING native writes are proven on a CompactLogix
|
||||
@@ -262,7 +262,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
libplctag member-write path is `ab_server`-only. See [Member writes](#member-writes).
|
||||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. A write fragments only
|
||||
above `MaxRequestBytes` (4000 B on a Large Forward Open), so it needs a ~4 KB value; Z-35162 has no safe one (a DINT[32]
|
||||
or a STRING is a single Write Tag, and AbCip has no whole-array write anyway). It stays open until a bench controller
|
||||
or a STRING is a single Write Tag — and a whole DINT[32] array write, #737, is 128 B). It stays open until a bench controller
|
||||
or an approved large scratch tag exists.
|
||||
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
|
||||
are paced, and a browse's template reads share one lease. Observed on Z-35162 (2026-10-09, 41
|
||||
@@ -361,7 +361,17 @@ integration fixture is down during normal dev (Docker host fixture currently off
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires `ab_server` Docker fixture on `10.100.0.35:44818`).
|
||||
|
||||
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization. Safety-partition array tags (`SafetyTag: true`) are excluded (GuardLogix write constraints extend to array writes).
|
||||
**Array writes (#737, review 07 U10)** — a whole-array write of exactly `arrayLength` elements, encoded by
|
||||
`AbCipArrayCodec` little-endian at the element width on both transports. Element values **convert**
|
||||
(ScadaBridge writes `int[]` to INT arrays); a value that does not fit is `BadOutOfRange`, one that cannot convert
|
||||
`BadTypeMismatch`, a different length `BadOutOfRange`, all before anything is sent. The controller buffer must be
|
||||
width × count (else `BadTypeMismatch`, checked before sending). Writable element types: `SInt`/`USInt`/`Int`/`UInt`/
|
||||
`DInt`/`UDInt`/`Real`/`Dt`/`LInt`/`ULInt`/`LReal`. **BOOL arrays** (bit-packed DWORDs), **STRING** and **structure**
|
||||
arrays are refused `BadNotSupported`, so their nodes stay read-only (`DriverArrayWriteSupport`); so does a
|
||||
safety-partition array tag (`safetyTag: true`, `BadNotWritable`). An array deployed read-only before #737 turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update). Proven on `ab_server` only until the hardware gate runs.
|
||||
|
||||
**Deferrals** — multi-dimensional arrays, per-element historization, whole-array writes on hardware.
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
@@ -454,8 +464,9 @@ when it is browse-committed in `/raw`, and that tag writes like any other atomic
|
||||
`writable` key. The node is writable only once the operator sets the raw tag to `ReadWrite` (the
|
||||
node-manager gate); an authored `"writable": false` still refuses at the driver (`BadNotWritable`). The
|
||||
browse's `SecurityClass` (members inherit the parent symbol's read-only bit) is not consulted by the
|
||||
commit. An element of a member array is authored by hand as `Seg.Points[2]`; a whole member array is
|
||||
not writable (AbCip has no array encoder, `DriverArrayWriteSupport`).
|
||||
commit. An element of a member array is authored by hand as `Seg.Points[2]`; a whole member array of an
|
||||
atomic type is writable as an array tag (`isArray` + `arrayLength`, #737), a BOOL / STRING / structure member
|
||||
array is not (`DriverArrayWriteSupport`).
|
||||
- **The request names the member.** One CIP symbolic segment per member (plus an element segment), the
|
||||
member's own CIP type, the member's own bytes — never the parent UDT, never a hidden host. Pinned at the
|
||||
request-byte level by `NativeAbCipTagRuntimeTests.A_member_path_write_names_the_member_and_sends_only_its_own_bytes`
|
||||
|
||||
@@ -304,8 +304,18 @@ N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC
|
||||
limit).
|
||||
|
||||
This closes the previously-noted limitation ("single-element addressing today") for the
|
||||
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
|
||||
array *writes* remain a follow-up.
|
||||
array read path.
|
||||
|
||||
**Array writes (#738, review 07 U10 / r0 I3)** — on both transports, a whole-array write encodes exactly
|
||||
`arrayLength` elements at the **data file's** element width (N / B / A words, L longs, F floats). Element values
|
||||
**convert** (ScadaBridge writes `int[]` to Int16 nodes); a length mismatch or an element that overflows is
|
||||
`BadOutOfRange`, a non-numeric value `BadTypeMismatch`, before any runtime exists. Writable: `Int` / `AnalogInt` on
|
||||
an N / B / A file, `Long` on an L file, `Float` on an F file, on a plain `<file><number>:<word>` address of at most
|
||||
256 elements. These nodes stay **read-only** (`DriverArrayWriteSupport`): Bit arrays (bit-packed words), String /
|
||||
Timer / Counter / Control arrays (rejected at `FromTagConfig`), `/bit` and `.sub` addresses, a type that does not
|
||||
match its file (`BadTypeMismatch`), any PLC-5 on the native transport (writes refused before the wire), the
|
||||
`S:` / `I:` / `O:` files (libplctag refuses their translated names), and — deliberately — indirect addresses, whose
|
||||
element moves with the index word. An array deployed read-only before #738 turns writable on the next deploy.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
|
||||
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
|
||||
@@ -313,8 +323,10 @@ down during normal dev.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
|
||||
|
||||
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
|
||||
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
|
||||
**Deferrals** — multi-dimensional arrays, per-element historization, array writes on hardware
|
||||
(`AbLegacyArrayWriteIntegrationTests` is green on `ab_server` and ready for a real controller via
|
||||
`AB_LEGACY_ARRAY_WRITE_BASE`).
|
||||
`arrayLength > 256` is clamped to 256 by the tag factory (PCCC payload cap), so such a node stays read-only.
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
|
||||
@@ -362,6 +362,20 @@ element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `
|
||||
down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent,
|
||||
is refused with `BadTypeMismatch`.
|
||||
|
||||
### Array writes (#739, review 07 U9)
|
||||
|
||||
An array node is **writable** (`DriverArrayWriteSupport`) for every element type the codec lays out —
|
||||
numeric, `Bool` (one byte per element, as TwinCAT stores a BOOL array), `STRING`/`WSTRING` and the temporal
|
||||
types. The write takes exactly `arrayLength` elements and **converts** each one (`ConvertForWrite`; ScadaBridge
|
||||
writes `int[]` to an Int16 node); a length mismatch or an out-of-range element is `BadOutOfRange` and a scalar
|
||||
value `BadTypeMismatch`, before any wire traffic. `WriteArrayAsync` then plans the layout from the resolved PLC
|
||||
array with the same `CheckArray` the read uses and encodes with `TwinCATArrayCodec.Encode` (the inverse of the
|
||||
decode); a shorter `arrayLength` writes the leading sub-range, and a multi-dimensional PLC array is written
|
||||
row-major from the flat 1-D value. A BOOL **bit-in-word** path (`MAIN.Word.3`) stays read-only as an array: it is a
|
||||
scalar bit write. Under a `DateTime` node only `DATE`/`DT`/`WSTRING` elements are writable, and under a `String`
|
||||
node only `STRING`/`WSTRING` (other conversions would depend on the text). An array deployed read-only before #739
|
||||
turns writable on the next deploy. Unit- and XAR-gate-tested; real-hardware proof is pending.
|
||||
|
||||
### Notification size cap
|
||||
|
||||
ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
|
||||
|
||||
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
|
||||
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
|
||||
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
|
||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</item>
|
||||
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
|
||||
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
|
||||
/// DWORDs), STRING and structure arrays have no array encoder.</item>
|
||||
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
|
||||
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
|
||||
/// on a plain <c><file><number>:<word></c> address of at most 256 elements (the PCCC
|
||||
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
|
||||
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
|
||||
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
|
||||
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
|
||||
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
|
||||
/// and the index read is not atomic with the write.</item>
|
||||
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
|
||||
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
|
||||
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
|
||||
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
|
||||
/// against the resolved symbol at write time, which no compose-time table can know.</item>
|
||||
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
|
||||
/// nodes stay read-only.</item>
|
||||
/// </list>
|
||||
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
|
||||
/// an integer <c>arrayLength >= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
|
||||
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
/// driver whatever the client sent and is not refused.</para>
|
||||
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
||||
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
||||
/// </summary>
|
||||
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
|
||||
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
|
||||
};
|
||||
|
||||
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
|
||||
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
|
||||
};
|
||||
|
||||
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
|
||||
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
|
||||
public const int AbLegacyMaxArrayElements = 256;
|
||||
|
||||
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
|
||||
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
|
||||
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
|
||||
// writes on the native transport alone.
|
||||
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
|
||||
["L"] = ["Long"],
|
||||
["F"] = ["Float"],
|
||||
};
|
||||
|
||||
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
|
||||
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
|
||||
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
|
||||
/// </summary>
|
||||
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
return S7ArrayTypes.Contains(native);
|
||||
}
|
||||
case DriverTypeNames.AbCip:
|
||||
return AbCipSupports(dataType, tagConfig);
|
||||
case DriverTypeNames.AbLegacy:
|
||||
return AbLegacySupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.TwinCAT:
|
||||
return TwinCATSupports(dataType, tagConfig);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
|
||||
private static bool AbCipSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|
||||
|| ReadArrayLength(root) is null
|
||||
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
|
||||
}
|
||||
|
||||
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
|
||||
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
|
||||
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
|
||||
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
|
||||
// bool, string and DateTime elements are refused.
|
||||
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|
||||
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|
||||
|| dataType is "Boolean" or "String" or "DateTime")
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
|
||||
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|
||||
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|
||||
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return !AbLegacyIsNativePlc5(driverConfigs);
|
||||
}
|
||||
|
||||
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
|
||||
// index, no sub-element and no indirection, for the file letters that carry a file number.
|
||||
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
|
||||
{
|
||||
fileLetter = "";
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
var src = address.Trim();
|
||||
var colon = src.IndexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
var letterEnd = 0;
|
||||
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
|
||||
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
|
||||
var fileNumber = src[letterEnd..colon];
|
||||
var word = src[(colon + 1)..];
|
||||
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|
||||
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
|
||||
return false;
|
||||
fileLetter = src[..letterEnd];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
|
||||
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
|
||||
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
|
||||
// family (AbLegacyTransportResolver), which writes PLC-5.
|
||||
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
|
||||
{
|
||||
var native = false;
|
||||
var plc5 = false;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
{
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
|
||||
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
|
||||
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var device in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (device.ValueKind != JsonValueKind.Object) continue;
|
||||
foreach (var dp in device.EnumerateObject())
|
||||
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return native && plc5;
|
||||
}
|
||||
|
||||
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
|
||||
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
|
||||
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
|
||||
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
|
||||
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
|
||||
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
|
||||
private static bool TwinCATSupports(string? dataType, string? tagConfig)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|
||||
|| ReadArrayLength(root) is null)
|
||||
return false;
|
||||
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
|
||||
if (!TwinCATArrayTypes.Contains(native)) return false;
|
||||
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
|
||||
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
|
||||
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
|
||||
if (dataType == "String" && !isString) return false;
|
||||
if (dataType == "DateTime" && !isWString && !isDate) return false;
|
||||
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
|
||||
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
|
||||
}
|
||||
|
||||
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
|
||||
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
|
||||
private static bool HasTwinCATBitSuffix(string symbolPath)
|
||||
{
|
||||
var src = symbolPath.Trim();
|
||||
if (src.StartsWith('.')) src = src[1..];
|
||||
var depth = 0;
|
||||
var lastDot = -1;
|
||||
for (var i = 0; i < src.Length; i++)
|
||||
{
|
||||
if (src[i] == '[') depth++;
|
||||
else if (src[i] == ']') depth--;
|
||||
else if (src[i] == '.' && depth == 0) lastDot = i;
|
||||
}
|
||||
if (lastDot < 0) return false;
|
||||
var last = src[(lastDot + 1)..];
|
||||
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
|
||||
}
|
||||
|
||||
private static bool HasNonBlankString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
|
||||
|
||||
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsStringValue(JsonElement el, string value) =>
|
||||
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
|
||||
+107
-1
@@ -138,11 +138,117 @@ public sealed class DriverArrayWriteSupportTests
|
||||
public void OpcUaClient_forwards_every_array() =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
|
||||
|
||||
// ---- AbCip / AbLegacy / TwinCAT (review 07 U10 / U9: their array encoders landed in #737 / #738 / #739) ----
|
||||
|
||||
/// <summary>AbCip: the atomic element types <c>AbCipArrayCodec</c> encodes. BOOL (bit-packed DWORDs), STRING and
|
||||
/// structure arrays stay read-only, as do a safety tag, an undeclared length and a node type no element converts
|
||||
/// from.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"int\"" + Arr2 + "}", "Int16", true)] // case-insensitive, like the factory
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"LReal\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Dt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInnt\"" + Arr2 + "}", "Int32", false)] // typo ⇒ the factory refuses the tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"safetyTag\":true" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"isArray\":true}", "Int32", false)] // no length ⇒ scalar
|
||||
[InlineData("{\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", false)] // no tagPath ⇒ not an AbCip tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "DateTime", false)] // DateTime elements never convert
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "String", false)] // text converts only when numeric
|
||||
public void AbCip_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the
|
||||
/// write path refuses stays read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\"" + Arr2 + "}", "Int16", true)] // dataType ⇒ Int
|
||||
[InlineData("{\"address\":\" n7:10 \",\"dataType\":\"analogint\"" + Arr2 + "}", "Int16", true)] // trimmed, case-insensitive
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"A9:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"L9:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Float\"" + Arr2 + "}", "Float", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":256}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":257}", "Int16", false)] // the driver clamps to 256
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Bit\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"address\":\"ST9:0\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"T4:0\",\"dataType\":\"TimerElement\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0/3\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"T4:0.ACC\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:[N7:0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // type / file mismatch
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"address\":\"S:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // libplctag refuses a translated name
|
||||
[InlineData("{\"address\":\"O:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Innt\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Boolean", false)] // bool elements are refused
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
public void AbLegacy_rows_follow_the_element_type_and_data_file(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>The native transport refuses every PLC-5 write before the wire, so a native PLC-5 device keeps its
|
||||
/// arrays read-only, wherever the transport and the family are configured. libplctag PLC-5 is the control.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"transport\":\"Native\",\"devices\":[{\"hostAddress\":\"ab://h/1,0\",\"plcFamily\":\"Plc5\"}]}", "{}", false)]
|
||||
[InlineData("{\"Transport\":\"native\"}", "{\"PlcFamily\":\"plc5\"}", false)]
|
||||
[InlineData("{\"transport\":\"Native\"}", "{\"plcFamily\":\"Slc500\"}", true)]
|
||||
[InlineData("{\"transport\":\"Libplctag\"}", "{\"plcFamily\":\"Plc5\"}", true)]
|
||||
[InlineData("{}", "{\"plcFamily\":\"Plc5\"}", true)] // absent ⇒ libplctag
|
||||
public void AbLegacy_native_PLC5_stays_read_only(string driverConfig, string deviceConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16",
|
||||
"{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", driverConfig, deviceConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>TwinCAT: every element type <c>TwinCATArrayCodec</c> encodes (the factory refuses Structure tags). A
|
||||
/// BOOL bit-in-word path writes a scalar bit and is refused for an array value.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"DateTime\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Grid[0,0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)] // bit-in-word
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "DateTime", false)] // a duration, not a date
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "DateTime", false)] // culture-dependent text vs strict Windows-1252
|
||||
public void TwinCAT_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>ScadaBridge writes CLR <c>int[]</c> / <c>List<int></c> to Int16 nodes, and an unknown DataType
|
||||
/// name (served as BaseDataType) hands the driver whatever the client sent. The element CONVERTS in all three
|
||||
/// encoders, so neither case may make the row read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.AbCip, "{\"tagPath\":\"Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.AbLegacy, "{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
public void Converting_drivers_do_not_key_on_an_exact_node_type(string driverType, string tagConfig)
|
||||
{
|
||||
foreach (var dataType in new[] { "Int16", "Int32", "Int64", "UInt16", "Double", "Float32", "Reference" })
|
||||
DriverArrayWriteSupport.Supports(driverType, dataType, tagConfig).ShouldBeTrue($"{driverType} {dataType}");
|
||||
DriverArrayWriteSupport.Supports(driverType, null, tagConfig).ShouldBeTrue($"{driverType} <null>");
|
||||
}
|
||||
|
||||
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
|
||||
[Fact]
|
||||
public void Every_other_driver_type_is_not_array_writable()
|
||||
{
|
||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||
var listed = new[]
|
||||
{
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy,
|
||||
DriverTypeNames.AbCip, DriverTypeNames.AbLegacy, DriverTypeNames.TwinCAT,
|
||||
};
|
||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||
others.ShouldNotBeEmpty();
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbCip row against the REAL write path (#737, review 07 U10), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="AbCipDriver.WriteAsync"/> encodes a whole-array value for it (through the production
|
||||
/// <see cref="AbCipArrayCodec"/>, which the fake runtime's <c>EncodeArray</c> calls), so a driver change that adds
|
||||
/// or drops an array element type fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value written is the CLR array the server hands the driver for the node's <c>DataType</c>: the node manager
|
||||
/// converts a numeric client array (ScadaBridge's <c>int[]</c>) to the node's element type first, and the codec
|
||||
/// then converts each element to the controller's type. The fake controller holds exactly
|
||||
/// <c>width × count</c> bytes, so only the element type and the value can make the write fail.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
// Non-numeric text: a String node over a numeric element converts only when the text happens to be a
|
||||
// number, which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, AbCipDataType elementType, string nodeType, int count)
|
||||
{
|
||||
// The controller holds exactly what an encodable element array of this length occupies.
|
||||
var size = (AbCipArrayCodec.ElementWidth(elementType) ?? 1) * count;
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
|
||||
return r[0].StatusCode == AbCipStatusMapper.Good
|
||||
&& factory.Tags.TryGetValue("Arr", out var tag)
|
||||
&& tag.EncodedArrayCount == count && tag.WriteCount == 1;
|
||||
}
|
||||
|
||||
public static TheoryData<AbCipDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbCipDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbCipDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: the table answers exactly what the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbCipDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"tagPath":"Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, dataType, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a safety tag (never writable), an undeclared length (a
|
||||
/// scalar to the factory, whose scalar encoder cannot take an array), a 1-element array, an element-offset path,
|
||||
/// and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":1}""", 1)]
|
||||
[InlineData("""{"tagPath":"Arr","isArray":true,"arrayLength":3}""", 3)] // dataType ⇒ DInt
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","safetyTag":true,"isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":0}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":false,"arrayLength":3}""", 3)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, int nodeLength)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, AbCipDataType.DInt, "Int32", nodeLength);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int32", tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
|
||||
/// <summary>The ScadaBridge wire shape reaches the codec: a CLR <c>int[]</c> (and <c>List<int></c>) to an
|
||||
/// INT array on an Int16 node the table advertises writable encodes. Positive control for the conversion rule.</summary>
|
||||
[Fact]
|
||||
public async Task A_ScadaBridge_int_list_to_an_INT_array_encodes()
|
||||
{
|
||||
const string json = """{"tagPath":"Arr","dataType":"Int","isArray":true,"arrayLength":3}""";
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int16", json).ShouldBeTrue();
|
||||
|
||||
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 6 } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", json, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity-sb", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", value)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr"].EncodedArrayBytes.ShouldBe(Convert.FromHexString("0100FEFF2C01"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-18
@@ -1,24 +1,48 @@
|
||||
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.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL write path, the same way the Modbus,
|
||||
/// S7 and Galaxy parity tests do: the server must advertise an array node writable exactly when
|
||||
/// <see cref="AbLegacyDriver.WriteAsync"/> encodes a whole-array value for it, so a driver change that adds or drops
|
||||
/// an array shape fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// The driver is driven end to end — <c>FromTagConfig</c>, the transport's own <c>WriteRefusal</c>, the data-file
|
||||
/// width check, the element conversion — against a fake runtime that records <c>EncodeArray</c>. The value written
|
||||
/// is the CLR array the server hands the driver for the node's <c>DataType</c> (the node manager converts a numeric
|
||||
/// client array to the node's element type first), sized to the node's declared length.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
// Tag.DataType name → the CLR array the server delivers for a 1-D write of that type.
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
private static string AddressFor(AbLegacyDataType t) => t switch
|
||||
{
|
||||
@@ -32,24 +56,131 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public Array? Encoded { get; private set; }
|
||||
|
||||
public override void EncodeArray(AbLegacyDataType type, Array values) => Encoded = values;
|
||||
}
|
||||
|
||||
// The fake runtime, but the PRODUCTION transport's pre-wire write refusal (libplctag: translated names; native:
|
||||
// PLC-5 and status-bit sub-elements).
|
||||
private sealed class ParityFactory(IAbLegacyTagFactory transport) : IAbLegacyTagFactory
|
||||
{
|
||||
public List<RecordingTag> Created { get; } = [];
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var tag = new RecordingTag(p);
|
||||
lock (Created) Created.Add(tag);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public string? WriteRefusal(AbLegacyTagCreateParams p) => transport.WriteRefusal(p);
|
||||
}
|
||||
|
||||
// Whether the driver encodes a whole-array write of the node's declared length for this tag.
|
||||
private static async Task<bool> DriverEncodesAsync(
|
||||
string tagConfig, string nodeType, int nodeLength, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var factory = new ParityFactory(native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());
|
||||
using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, family)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, nodeLength))], CancellationToken.None);
|
||||
|
||||
bool encoded;
|
||||
lock (factory.Created) encoded = factory.Created.Any(t => t.Encoded is { } a && a.Length == nodeLength && t.WriteCount == 1);
|
||||
return r[0].StatusCode == AbLegacyStatusMapper.Good && encoded;
|
||||
}
|
||||
|
||||
public static TheoryData<AbLegacyDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbLegacyDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbLegacyDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType, on the matching data file: the table answers exactly what
|
||||
/// the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbLegacyDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 2, AbLegacyPlcFamily.Slc500, native: false);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: the <c>/bit</c>, <c>.sub</c>, type-versus-file-width and
|
||||
/// over-long cases the write path refuses, a native PLC-5 device (refused before the wire), and their positive
|
||||
/// controls. Each row carries the address, element type, length, family and transport.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.MicroLogix, true)]
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, false)] // libplctag writes PLC-5
|
||||
[InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, true)] // native refuses every PLC-5 write
|
||||
[InlineData("B3:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("A9:0", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:10", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Long", 4, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Float", 4, AbLegacyPlcFamily.Slc500, true)]
|
||||
[InlineData("N7:0", "Int", 256, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Int", 300, AbLegacyPlcFamily.Slc500, false)] // clamped to 256 by the factory: a 300-element write is refused
|
||||
[InlineData("N7:0/3", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0/3", "Bit", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("T4:0.ACC", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("F8:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Long", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("L9:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("N7:0", "Float", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
[InlineData("S:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] // libplctag refuses a translated name
|
||||
[InlineData("O:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(
|
||||
string address, string dataType, int length, AbLegacyPlcFamily family, bool native)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":{{length}}}""";
|
||||
var driverConfig = native ? """{"transport":"Native"}""" : "{}";
|
||||
var deviceConfig = $$"""{"hostAddress":"{{Host}}","plcFamily":"{{family}}"}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, "Int16", length, family, native);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json, driverConfig, deviceConfig)
|
||||
.ShouldBe(driver, $"{address} {dataType}[{length}] {family} native={native}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes the table keeps read-only although the write path may encode them — the deliberately conservative
|
||||
/// direction, pinned so it is a decision and not drift: an INDIRECT address (its element moves with the index
|
||||
/// word, and the index read is not atomic with the write), and the file-number-less <c>S:</c> / <c>I:</c> /
|
||||
/// <c>O:</c> files on the native transport (libplctag refuses them; the table does not read the transport
|
||||
/// closely enough to widen output-image and status-file writes on native alone).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"S:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
[InlineData("""{"address":"O:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")]
|
||||
public void Table_keeps_the_conservative_shapes_read_only(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||||
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// a native PLC-5 device. Keyed on <c>dataType</c> alone the row would advertise them writable.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[Theory]
|
||||
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
@@ -60,7 +191,7 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
/// <summary>What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s TwinCAT row against the REAL write path (#739, review 07 U9), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="TwinCATDriver.WriteAsync"/> converts a whole-array value (<c>TwinCATArrayCodec.ToWriteElements</c>)
|
||||
/// and hands it to <c>WriteArrayAsync</c>, AND the production <see cref="TwinCATArrayCodec.Encode"/> lays the
|
||||
/// converted elements out. A driver change that adds or drops an array element type fails here until the table
|
||||
/// follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value written is the CLR array the server hands the driver for the node's <c>DataType</c> (the node manager
|
||||
/// converts a numeric client array to the node's element type first). Every array node is materialised 1-D; a
|
||||
/// multi-dimensional PLC array is written row-major from that flat value, so nothing here considers more than one
|
||||
/// rank. The PLC-side shape check (<c>CheckArray</c>: declared length ≤ the PLC's element count, element width)
|
||||
/// runs against the resolved symbol at write time and is outside what a compose-time table can know.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
private static Array ClrArray(string dataType, int n) => dataType switch
|
||||
{
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(),
|
||||
"Byte" => Enumerable.Repeat((byte)1, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"UInt32" => Enumerable.Repeat(1u, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"UInt64" => Enumerable.Repeat(1UL, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
// Non-numeric text: a String node over a non-string element converts only when the text happens to parse,
|
||||
// which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc), n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dataType)),
|
||||
};
|
||||
|
||||
private static readonly string[] NodeTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
// A PLC element width the codec can lay each type out at (STRING(80) / WSTRING(80) for the string types).
|
||||
private static int StrideOf(TwinCATDataType t) => t switch
|
||||
{
|
||||
TwinCATDataType.String => 81,
|
||||
TwinCATDataType.WString => 162,
|
||||
_ => TwinCATTypeGuard.DeclaredByteSize(t),
|
||||
};
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, string nodeType, int count)
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
using var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "twincat-parity", new FakeTwinCATClientFactory { Customise = () => fake });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
if (r[0].StatusCode != TwinCATStatusMapper.Good || fake.ArrayWriteLog.Count != 1) return false;
|
||||
|
||||
var (_, type, elements) = fake.ArrayWriteLog[0];
|
||||
if (elements.Count != count) return false;
|
||||
try
|
||||
{
|
||||
TwinCATArrayCodec.Encode(type, elements, new TwinCATArrayLayout(count, StrideOf(type)), utf8: false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<TwinCATDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<TwinCATDataType, string>();
|
||||
foreach (var t in Enum.GetValues<TwinCATDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: the table answers exactly what the write path does.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(TwinCATDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"symbolPath":"MAIN.Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
if (dataType == TwinCATDataType.String && nodeType == "DateTime")
|
||||
{
|
||||
// The one CULTURE-dependent pair: a DateTime element reaches a STRING as Convert.ToString(DateTime), and
|
||||
// whether its strict Windows-1252 encode accepts that text depends on the host culture (ICU's en-US puts a
|
||||
// U+202F before "PM"; NLS a plain space). The table keeps it read-only — the conservative direction.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBeFalse();
|
||||
return;
|
||||
}
|
||||
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a BOOL bit-in-word path (a scalar bit write, which an array
|
||||
/// value cannot feed), an undeclared length (a scalar tag), an element-offset path into a multi-dimensional
|
||||
/// array, a TwinCAT 2 global, and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"symbolPath":"MAIN.Flags","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Grid[1,0]","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":".GlobalArr","dataType":"Real","isArray":true,"arrayLength":3}""", "Float")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true,"arrayLength":0}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","isArray":true,"arrayLength":3}""", "Int32")] // dataType ⇒ DInt
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, string nodeType)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// <list type="bullet">
|
||||
/// <item>An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and
|
||||
/// its UNS mirror, and a whole-array write round-trips.</item>
|
||||
/// <item>An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is
|
||||
/// authored <c>ReadWrite</c>: the write is <c>BadNotWritable</c> at the node and never reaches the
|
||||
/// gateway — the "never advertise a write the driver will refuse" rule.</item>
|
||||
/// <item>An AbCip BOOL array (bit-packed DWORDs: AbCip's array encoder refuses it, though it encodes the atomic
|
||||
/// numeric arrays since #737) stays read-only even though it is authored <c>ReadWrite</c>: the write is
|
||||
/// <c>BadNotWritable</c> at the node and never reaches the gateway — the "never advertise a write the
|
||||
/// driver will refuse" rule.</item>
|
||||
/// <item>A device-refused array write is reverted on whichever NodeId it was written through.</item>
|
||||
/// <item>A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and
|
||||
/// leave the node holding its prior array.</item>
|
||||
@@ -110,7 +111,8 @@ public sealed class ArrayWriteWireTests
|
||||
{
|
||||
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
|
||||
// A BOOL array: the AbCip array encoder has no encoding for it (a DInt array is writable since #737).
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
|
||||
+71
-4
@@ -271,8 +271,8 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
|
||||
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; an AbCip BOOL array (bit-packed
|
||||
/// DWORDs, which AbCip's array encoder refuses) is not; a scalar is never flagged.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
||||
{
|
||||
@@ -286,7 +286,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
{
|
||||
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
||||
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
@@ -303,7 +303,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
|
||||
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no BOOL-array encoder
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
|
||||
@@ -347,4 +347,71 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
||||
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an
|
||||
/// authored-<c>ReadWrite</c> array tag on each now materialises WRITABLE — raw node and UNS mirror alike — with
|
||||
/// the ScadaBridge-shaped Int16 node type. An array already deployed read-only turns writable on the next deploy
|
||||
/// through <c>ArrayWritable</c>, which rides the surgical whitelist (pinned in the classifier tests). A shape
|
||||
/// each encoder refuses (an AbCip BOOL array, an AbLegacy Bit array, an AbLegacy Int array on an F file, a
|
||||
/// TwinCAT BOOL bit-in-word path) stays read-only on the SAME drivers — the negative control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AbCip_AbLegacy_and_TwinCAT_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab", DriverType = "AbCip", DriverConfig = "{}" };
|
||||
var slc = new DriverInstance { DriverInstanceId = "drv-slc", ClusterId = "c1", RawFolderId = "f", Name = "Slc", DriverType = "AbLegacy", DriverConfig = "{}" };
|
||||
var tc = new DriverInstance { DriverInstanceId = "drv-tc", ClusterId = "c1", RawFolderId = "f", Name = "Tc", DriverType = "TwinCAT", DriverConfig = "{}" };
|
||||
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-ab", Name = "plc", DeviceConfig = "{}" };
|
||||
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-slc", Name = "slc", DeviceConfig = "{\"plcFamily\":\"Slc500\"}" };
|
||||
var d3 = new Device { DeviceId = "d3", DriverInstanceId = "drv-tc", Name = "ads", DeviceConfig = "{}" };
|
||||
static Tag Arr(string id, string device, string dataType, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, DeviceId = device, Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
var tags = new[]
|
||||
{
|
||||
Arr("abInts", "d1", "Int16", "{\"tagPath\":\"Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("abBools", "d1", "Boolean", "{\"tagPath\":\"Bools\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcInts", "d2", "Int16", "{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcBits", "d2", "Boolean", "{\"address\":\"B3:0\",\"dataType\":\"Bit\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcWrongFile", "d2", "Int16", "{\"address\":\"F8:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcInts", "d3", "Int16", "{\"symbolPath\":\"MAIN.Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcBit", "d3", "Boolean", "{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r-ab", EquipmentId = "e", TagId = "abInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-slc", EquipmentId = "e", TagId = "slcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-tc", EquipmentId = "e", TagId = "tcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-bits", EquipmentId = "e", TagId = "slcBits" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { ab, slc, tc }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2, d3 });
|
||||
|
||||
foreach (var writable in new[] { "abInts", "slcInts", "tcInts" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == writable);
|
||||
plan.ArrayWritable.ShouldBeTrue(writable);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
||||
}
|
||||
foreach (var refused in new[] { "abBools", "slcBits", "slcWrongFile", "tcBit" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == refused);
|
||||
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
||||
plan.ArrayWritable.ShouldBeFalse(refused);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
||||
}
|
||||
|
||||
foreach (var id in new[] { "r-ab", "r-slc", "r-tc" })
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user