From 44ea6595e515123fb855d3bd0acfaafb88c591cb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 16:17:48 -0400 Subject: [PATCH] feat(arrays): advertise AbCip/AbLegacy/TwinCAT array writes in DriverArrayWriteSupport The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write encoders (review 07 U10/U9), but the capability table still listed all three as scalar-only, so every array node materialised read-only and a client write was BadNotWritable before it reached the driver. Rows added, each listing only what WriteAsync encodes end to end: - AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only. - AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match), plain : address, <= 256 elements; not /bit, .sub, indirect, S:/I:/O:, nor native PLC-5. - TwinCAT: every codec element type, declared length; not a BOOL bit-in-word path. Elements convert in all three encoders, so the rows never key on an exact node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out only node types whose elements never convert (DateTime/String over numeric, Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent). Tests: driver-level parity for each driver (every element type x node type plus shape cases, against the real write path), the AbLegacy parity tests un-skipped, Core.Abstractions row tests, a composer test (three drivers writable, refused shapes read-only), and the existing AbCip negative cases moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules. Refs #737 #738 #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .claude/rules/data-plane-auth.md | 4 +- docs/Uns.md | 21 +- docs/drivers/AbCip.md | 21 +- docs/drivers/AbLegacy.md | 20 +- docs/drivers/TwinCAT.md | 14 ++ .../DriverArrayWriteSupport.cs | 202 +++++++++++++++++- .../DriverArrayWriteSupportTests.cs | 108 +++++++++- .../AbCipArrayWriteSupportParityTests.cs | 133 ++++++++++++ .../AbLegacyArrayWriteSupportParityTests.cs | 167 +++++++++++++-- .../TwinCATArrayWriteSupportParityTests.cs | 135 ++++++++++++ .../ArrayWriteWireTests.cs | 10 +- .../AddressSpaceComposerDualNamespaceTests.cs | 75 ++++++- 12 files changed, 864 insertions(+), 46 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteSupportParityTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayWriteSupportParityTests.cs diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 44492c76..ca9f7886 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -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 diff --git a/docs/Uns.md b/docs/Uns.md index 8f59101e..8de7c783 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -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 `:` 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` 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 diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 889e161f..4ddf1daa 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -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` diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 88ae97a6..d2774cc0 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -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 `:` 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. diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 22477d55..645fd025 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs index f16f8c21..08b87af5 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs @@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// DateTime — and only with a declared arrayLength: an undeclared length materialises an /// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the /// mxaccessgw gateway.md contract), which is never what a client meant. -/// Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar -/// write encoders only; array nodes stay read-only. +/// AbCip — dataType SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt / +/// LReal (AbCipArrayCodec, both transports), and not a safetyTag. BOOL arrays (bit-packed +/// DWORDs), STRING and structure arrays have no array encoder. +/// AbLegacy — dataType 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 <file><number>:<word> 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), /bit and .sub +/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and +/// the S: / I: / O: files (libplctag refuses their translated names). Indirect +/// addresses (N7:[N7:0]) are kept read-only deliberately: the element moves with the index word +/// and the index read is not atomic with the write. +/// TwinCAT — every element type TwinCATArrayCodec lays out (all but Structure, which +/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path +/// (MAIN.Word.3), 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. +/// Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array +/// nodes stay read-only. /// -/// Modbus, S7 and Galaxy require a declared length — isArray: true AND an integer -/// arrayLength >= 1. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose +/// Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — isArray: true AND +/// an integer arrayLength >= 1. 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. +/// Elements convert; they are not exact-matched. ScadaBridge writes CLR arrays such as +/// int[] / List<int> to Int16 nodes; the node manager converts a numeric array to the +/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then Convert each element to the +/// controller's type (an element that does not fit is BadOutOfRange). So those rows do not key on an +/// exact node DataType; they leave out only the node types whose elements NEVER convert: a +/// DateTime or String 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 Boolean 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. /// Multi-dimensional arrays (ValueRank > 1) are out of scope altogether — every array node /// is materialised 1-D (ValueRank = OneDimension), so nothing here considers more than one rank. /// @@ -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 AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase) + { + "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal", + }; + + /// The PCCC data-file element count ceiling (AbLegacyArray.MaxElements): the AbLegacy tag + /// factory CLAMPS a longer arrayLength to it, so a node of the authored (longer) length never matches. + 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 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 TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase) + { + "Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal", + "String", "WString", "Time", "Date", "DateTime", "TimeOfDay", + }; + /// /// Whether a driver of can write a 1-D array tag of the given shape. /// @@ -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 + // `:` 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); + } + + // `:` 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); + /// /// Whether a driver of is known to REFUSE an /// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs index 2da30f96..18137306 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs @@ -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) ---- + + /// AbCip: the atomic element types AbCipArrayCodec 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. + [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); + + /// AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the + /// write path refuses stays read-only. + [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); + + /// 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. + [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); + + /// TwinCAT: every element type TwinCATArrayCodec encodes (the factory refuses Structure tags). A + /// BOOL bit-in-word path writes a scalar bit and is refused for an array value. + [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); + + /// ScadaBridge writes CLR int[] / List<int> 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. + [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} "); + } + /// Every driver type NOT in the table — and an unknown or absent one — stays read-only. [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. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteSupportParityTests.cs new file mode 100644 index 00000000..488020db --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteSupportParityTests.cs @@ -0,0 +1,133 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Pins '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 +/// encodes a whole-array value for it (through the production +/// , which the fake runtime's EncodeArray calls), so a driver change that adds +/// or drops an array element type fails here until the table follows. +/// +/// +/// The value written is the CLR array the server hands the driver for the node's DataType: the node manager +/// converts a numeric client array (ScadaBridge's int[]) to the node's element type first, and the codec +/// then converts each element to the controller's type. The fake controller holds exactly +/// width × count bytes, so only the element type and the value can make the write fail. +/// +[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 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 TypeMatrix() + { + var data = new TheoryData(); + foreach (var t in Enum.GetValues()) + foreach (var n in NodeTypes) + data.Add(t, n); + return data; + } + + /// Every element type × every node DataType: the table answers exactly what the write path does. + [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"); + } + + /// + /// 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. + /// + [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); + } + + /// The ScadaBridge wire shape reaches the codec: a CLR int[] (and List<int>) to an + /// INT array on an Int16 node the table advertises writable encodes. Positive control for the conversion rule. + [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 { 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")); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs index 3f5a50f6..b35d0b26 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs @@ -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; /// -/// Pins 's AbLegacy row against the REAL array encoder -/// (), 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 '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 +/// encodes a whole-array value for it, so a driver change that adds or drops +/// an array shape fails here until the table follows. /// /// -/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but -/// (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 — FromTagConfig, the transport's own WriteRefusal, the data-file +/// width check, the element conversion — against a fake runtime that records EncodeArray. The value written +/// is the CLR array the server hands the driver for the node's DataType (the node manager converts a numeric +/// client array to the node's element type first), sized to the node's declared length. /// [Trait("Category", "Unit")] public sealed class AbLegacyArrayWriteSupportParityTests { - public static TheoryData AllTypes() => new(Enum.GetValues()); + 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 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 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 TypeMatrix() + { + var data = new TheoryData(); + foreach (var t in Enum.GetValues()) + foreach (var n in NodeTypes) + data.Add(t, n); + return data; + } + + /// Every element type × every node DataType, on the matching data file: the table answers exactly what + /// the write path does. + [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"); } + /// + /// Shapes beyond the element type, both directions: the /bit, .sub, 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. + /// + [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}"); + } + + /// + /// 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 S: / I: / + /// O: 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). + /// + [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(); + /// /// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a /// /bit or .sub 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 dataType alone it would - /// advertise them writable. Skipped with the parity theory until F1 lands. + /// a native PLC-5 device. Keyed on dataType alone the row would advertise them writable. /// - [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(); - /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file). + /// What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file). [Theory] [InlineData(AbLegacyDataType.Int, true)] [InlineData(AbLegacyDataType.AnalogInt, true)] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayWriteSupportParityTests.cs new file mode 100644 index 00000000..fdf05c23 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayWriteSupportParityTests.cs @@ -0,0 +1,135 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Pins '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 +/// converts a whole-array value (TwinCATArrayCodec.ToWriteElements) +/// and hands it to WriteArrayAsync, AND the production lays the +/// converted elements out. A driver change that adds or drops an array element type fails here until the table +/// follows. +/// +/// +/// The value written is the CLR array the server hands the driver for the node's DataType (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 (CheckArray: 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. +/// +[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 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 TypeMatrix() + { + var data = new TheoryData(); + foreach (var t in Enum.GetValues()) + foreach (var n in NodeTypes) + data.Add(t, n); + return data; + } + + /// Every element type × every node DataType: the table answers exactly what the write path does. + [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"); + } + + /// + /// 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. + /// + [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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs index 3d8f0168..26934f4f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs @@ -18,9 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// 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. -/// An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is -/// authored ReadWrite: the write is BadNotWritable at the node and never reaches the -/// gateway — the "never advertise a write the driver will refuse" rule. +/// 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 ReadWrite: the write is +/// BadNotWritable at the node and never reaches the gateway — the "never advertise a write the +/// driver will refuse" rule. /// A device-refused array write is reverted on whichever NodeId it was written through. /// A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and /// leave the node holding its prior array. @@ -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" }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs index 0698f2e8..fbbd41ac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs @@ -271,8 +271,8 @@ public sealed class AddressSpaceComposerDualNamespaceTests } /// Gitea #610: ArrayWritable is derived per OWNING DRIVER at compose time, and the UNS mirror - /// inherits it as BackingArrayWritable. 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. + /// inherits it as BackingArrayWritable. 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. [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(); } + + /// + /// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an + /// authored-ReadWrite 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 ArrayWritable, 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. + /// + [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(), + 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(); + } }