diff --git a/docs/Uns.md b/docs/Uns.md
index c48fbaf0..15a1277a 100644
--- a/docs/Uns.md
+++ b/docs/Uns.md
@@ -323,12 +323,17 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
| Driver | Array writes | Element types |
|---|---|---|
-| **Modbus** | Yes | `region: Coils` (FC15, any element); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16). Not BitInRegister / String / Bool-in-register; input regions are read-only |
+| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **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 |
-| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) |
+| **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` | — |
+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.
+
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
fails a test until the table follows.
@@ -336,8 +341,20 @@ fails a test until the table follows.
What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
- (an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length); anything else is
- `BadTypeMismatch`, refused inline before dispatch. A partial **IndexRange** write is
+ (an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
+ OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
+- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array,
+ a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused
+ inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the
+ check the OPC UA SDK would normally make itself: it skips its own type check on any node with an
+ `OnWriteValue` handler, which every writable equipment node has, so the node manager makes it
+ instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was
+ written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep
+ their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge)
+ write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder
+ converts with a range check, so an out-of-range scalar fails the device write and is reverted.
+- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline.
+- A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it.
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
@@ -347,6 +364,16 @@ What a client sees on a writable array node:
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`.
+> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
+> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
+> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
+> shape the table above accepts is materialised **writable** — no deployment needed, since the
+> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
+> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
+> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
+> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
+> other drivers are unaffected.
+
### Out of scope (named deferrals)
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index e6a723c6..07869fe3 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -116,3 +116,4 @@ The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`
- **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason.
- **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag.
- **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
+- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node exactly (`BadTypeMismatch` inline otherwise), and `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
diff --git a/docs/drivers/Modbus.md b/docs/drivers/Modbus.md
index 746e76b9..1f9e7262 100644
--- a/docs/drivers/Modbus.md
+++ b/docs/drivers/Modbus.md
@@ -137,8 +137,13 @@ numeric, String, and BitInRegister array reads.
**Live-verify** — Mac-verifiable against the simulator at `10.100.0.35:5020` (no fixture
bring-up required; the sim is always-on on the shared Docker host).
-**Deferrals** — array *writes* (FC16 multi-register write for an array value) are a named
-follow-up; multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
+**Array writes** (#610) — a whole-array write goes out as one FC15 (coils) or one FC16 (numeric
+holding registers); the driver refuses rather than splits an FC16 longer than `maxRegistersPerWrite`
+(default 123). The server mirrors that: an array tag is client-writable only when
+`arrayLength × registers-per-element` fits the cap (and a coil array ≤ 1968), else its node stays
+read-only — see [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
+
+**Deferrals** — multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
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/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
index 68a33120..00267ccd 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
@@ -20,28 +20,46 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// until this table follows.
///
///
-/// Modbus — region: Coils (FC15, any element) and region: HoldingRegisters
-/// with a fixed-width numeric dataType (FC16). Bit-in-register, String and Bool-in-register
-/// arrays have no array encoder; input regions are read-only.
+/// Modbus — region: Coils (FC15, any element, at most 1968 coils) and
+/// region: HoldingRegisters with a fixed-width numeric dataType (FC16) whose
+/// arrayLength × registers-per-element fits the driver's maxRegistersPerWrite (default
+/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
+/// Bool-in-register arrays have no array encoder; input regions are read-only.
/// S7 — dataType Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
/// block, EncodeArrayBlock). Bool arrays would clobber neighbouring bits; the wide / String /
/// DateTime element types have no array encoder.
/// OpcUaClient — every element type: the value is forwarded to the upstream server as a
-/// Variant unchanged, and the upstream server is the arbiter.
+/// Variant unchanged, and the upstream server is the arbiter (of shape, too).
/// Galaxy — the array variants MxValueEncoder encodes, keyed by the OPC UA
/// DataType the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
-/// DateTime.
+/// 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.
///
+/// 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
+/// scalar encoder cannot take an array value), though the composer still materialises an array node.
/// 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.
///
public static class DriverArrayWriteSupport
{
- private static readonly HashSet ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
+ /// The Modbus driver's FC16 write cap when maxRegistersPerWrite is absent or 0
+ /// (ModbusDriver resolves it the same way).
+ public const int ModbusDefaultMaxRegistersPerWrite = 123;
+
+ /// Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
+ /// whole array without chunking, so a longer coil array is refused by every conformant device.
+ public const int ModbusMaxCoilsPerWrite = 1968;
+
+ // Element type → registers per element (ModbusDriver.RegisterCount).
+ private static readonly Dictionary ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
- "Int16", "UInt16", "Bcd16", "Int32", "UInt32", "Bcd32", "Float32", "Int64", "UInt64", "Float64",
+ ["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
+ ["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
+ ["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
};
private static readonly HashSet S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
@@ -61,31 +79,43 @@ public static class DriverArrayWriteSupport
/// constant); or unknown ⇒ .
/// The tag's OPC UA DataType name (Tag.DataType).
/// The tag's TagConfig JSON (driver-native address + element type).
+ /// The DriverConfig / DeviceConfig JSON blobs that can carry a
+ /// driver write cap (Modbus maxRegistersPerWrite). Every one is consulted and the SMALLEST cap
+ /// wins, so the answer stays conservative however the driver's config merge resolves them.
/// only when the driver encodes a whole-array write for this tag.
- public static bool Supports(string? driverType, string? dataType, string? tagConfig)
+ public static bool Supports(string? driverType, string? dataType, string? tagConfig, params string?[] driverConfigs)
{
switch (driverType)
{
case DriverTypeNames.OpcUaClient:
return true;
case DriverTypeNames.Galaxy:
- return dataType is not null && GalaxyArrayDataTypes.Contains(dataType);
+ return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
+ && TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
case DriverTypeNames.Modbus:
{
- // Same reads as ModbusTagDefinitionFactory: an absent (or non-string) region defaults to
- // HoldingRegisters and an absent dataType to Int16 (TagConfigJson.TryReadEnumStrict).
- if (!TryParseObject(tagConfig, out var root)) return false;
+ // Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
+ // a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
+ // HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
+ if (!TryParseObject(tagConfig, out var root)
+ || !root.TryGetProperty("region", out _)
+ || !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
+ || ReadArrayLength(root) is not { } count)
+ return false;
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
var native = ReadEnumName(root, "dataType") ?? "Int16";
- if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return true;
+ if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
- && ModbusRegisterArrayTypes.Contains(native);
+ && ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
+ && (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
- if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)) return false;
+ if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)
+ || ReadArrayLength(root) is null)
+ return false;
var native = ReadEnumName(root, "dataType") ?? "Int16";
return S7ArrayTypes.Contains(native);
}
@@ -94,6 +124,36 @@ public static class DriverArrayWriteSupport
}
}
+ // The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
+ // default (123). The key is matched case-insensitively, like the driver's DTO binding.
+ private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
+ {
+ int? cap = null;
+ foreach (var config in driverConfigs ?? [])
+ {
+ if (!TryParseObject(config, out var root)) continue;
+ foreach (var prop in root.EnumerateObject())
+ {
+ if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
+ && prop.Value.ValueKind == JsonValueKind.Number
+ && prop.Value.TryGetInt32(out var configured) && configured > 0)
+ {
+ cap = Math.Min(cap ?? int.MaxValue, configured);
+ }
+ }
+ }
+ return cap ?? ModbusDefaultMaxRegistersPerWrite;
+ }
+
+ // The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
+ // Modbus and S7 tag factories apply. Anything else is a scalar to those drivers ⇒ null.
+ private static int? ReadArrayLength(JsonElement root) =>
+ root.TryGetProperty("isArray", out var isArray) && isArray.ValueKind == JsonValueKind.True
+ && root.TryGetProperty("arrayLength", out var length) && length.ValueKind == JsonValueKind.Number
+ && length.TryGetInt32(out var count) && count >= 1
+ ? count
+ : null;
+
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
// detaches the element from the disposed document.
private static bool TryParseObject(string? tagConfig, out JsonElement root)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
index bcb3a1d6..6f1a9720 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
@@ -39,8 +39,13 @@ internal static class MxValueEncoder
case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) };
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
- case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
- case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
+ // Exact-type guards: CLR array covariance makes `uint[] is int[]` and `ulong[] is long[]` true, so
+ // without them an unsigned array would be encoded by REINTERPRETING its bits (uint.MaxValue → -1) —
+ // unlike the range-checked scalar arms above. An unsigned array is refused instead (Gitea #610 review).
+ case int[] arr when arr.GetType() == typeof(int[]):
+ return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
+ case long[] arr when arr.GetType() == typeof(long[]):
+ return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs));
case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs));
case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index 4b3c7ece..28854354 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -668,7 +668,8 @@ public static class AddressSpaceComposer
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables(
- unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
+ unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
+ BuildArrayWriteCapability(driverInstances, devices));
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{
@@ -767,7 +768,7 @@ public static class AddressSpaceComposer
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
- var driverTypeById = BuildDriverTypeById(driverInstances);
+ var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
var rawTags = new List();
foreach (var t in tags ?? Array.Empty())
@@ -779,8 +780,7 @@ public static class AddressSpaceComposer
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
- var arrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
- driverTypeById.GetValueOrDefault(driverInstanceId), t.DataType, t.TagConfig);
+ var arrayWritable = intent.IsArray && arrayWritableFor(t);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
@@ -808,12 +808,30 @@ public static class AddressSpaceComposer
return (containers, rawTags);
}
- /// DriverInstanceId → DriverType, for the per-driver array-write capability (Gitea #610).
- /// First row wins on a duplicate id, matching the composer's other id maps.
- private static IReadOnlyDictionary BuildDriverTypeById(IReadOnlyList driverInstances) =>
- driverInstances
+ ///
+ /// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
+ /// DriverType, the driver's DriverConfig and the tag's own device's DeviceConfig
+ /// (either config can carry a write cap such as Modbus maxRegistersPerWrite; the table takes the
+ /// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
+ /// compose and shared by the raw and UNS passes, so a mirror is writable exactly when its raw node is.
+ ///
+ private static Func BuildArrayWriteCapability(
+ IReadOnlyList driverInstances, IReadOnlyList? devices)
+ {
+ var driverById = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
- .ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
+ var deviceById = (devices ?? Array.Empty())
+ .GroupBy(d => d.DeviceId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
+ return tag =>
+ {
+ var device = deviceById.GetValueOrDefault(tag.DeviceId);
+ var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
+ return DriverArrayWriteSupport.Supports(
+ driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
+ };
+ }
///
/// Emit the UNS reference Variables: one per whose backing raw tag +
@@ -829,11 +847,10 @@ public static class AddressSpaceComposer
IReadOnlyList unsLines,
IReadOnlyList equipment,
RawTopology topology,
- IReadOnlyList driverInstances)
+ Func arrayWritableFor)
{
var refs = unsTagReferences ?? Array.Empty();
if (refs.Count == 0) return Array.Empty();
- var driverTypeById = BuildDriverTypeById(driverInstances);
var tagsById = (tags ?? Array.Empty())
.GroupBy(t => t.TagId, StringComparer.Ordinal)
@@ -866,9 +883,7 @@ public static class AddressSpaceComposer
var intent = TagConfigIntent.Parse(tag.TagConfig);
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is.
- var backingArrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
- driverTypeById.GetValueOrDefault(topology.DriverIdByDevice.GetValueOrDefault(tag.DeviceId, string.Empty)),
- tag.DataType, tag.TagConfig);
+ var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 0b22c4ea..2db42517 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -2018,10 +2018,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
- /// /array payload whose element type isn't cheaply known) is allowed through so
- /// the SDK's own (authoritative) type coercion in BaseVariableState.WriteValue remains the
- /// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
- /// here — that is left to the SDK.
+ /// payload whose type isn't cheaply known) is allowed through.
+ ///
+ ///
+ /// ⚠️ This is the ONLY type check a writable equipment node gets (Gitea #610 review). The SDK's
+ /// BaseVariableState.WriteValueAttribute runs TypeInfo.IsInstanceOfDataType only when
+ /// the node has NO OnWriteValue handler; with one (every writable equipment node) it checks the
+ /// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
+ /// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array
+ /// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose
+ /// (see ).
///
/// Pure (no SDK server / Lock needed): reads only and the node's static
/// DataType, so it is unit-testable in isolation.
@@ -2042,8 +2048,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
+ var isArrayNode = false;
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
{
+ isArrayNode = true;
if (value is not Array { Rank: 1 } array)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
@@ -2064,14 +2072,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
+ // Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
+ // up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted. A
+ // byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides
+ // whether the node takes one). An unresolved-DataType node deferred above, as it always has.
+ if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
+ {
+ return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
+ }
+
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
if (!IsCheckableExpectedType(expected)) return null;
- // TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
+ // TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
+ // unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
+
+ // Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK
+ // runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every
+ // writable equipment node has one, so nothing else stands between a mistyped array and the driver: a
+ // UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote
+ // uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16
+ // DataType. This restores the SDK's own rule for arrays — the element type must match, with the single
+ // Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and
+ // an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were
+ // never client-writable before #610, so no client relies on a looser rule.
+ if (isArrayNode)
+ {
+ return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)
+ ? null
+ : new ServiceResult(
+ StatusCodes.BadTypeMismatch,
+ $"array element type {actual} does not match node data type {expected}");
+ }
+
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
if (!IsBuiltInTypeCompatible(expected, actual))
@@ -2099,9 +2136,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
///
- /// Conservative built-in-type compatibility test for .
- /// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
- /// numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
+ /// Conservative built-in-type compatibility test for 's
+ /// SCALAR path (array writes are matched exactly — see there). Returns true (compatible — allow through)
+ /// unless there is a confident mismatch. Exact matches pass; numeric-to-numeric is treated as compatible —
+ /// deliberately looser than the SDK, which would refuse it: consumers (ScadaBridge) write CLR-typed values
+ /// such as a Double to a Float node, and every driver's SCALAR encoder converts with a range check
+ /// (Convert.ToInt16, MxValueEncoder's uint ≤ int.MaxValue arm, …), so an out-of-range value
+ /// fails the device write and is reverted rather than silently reinterpreted; a
/// ↔ array nuance and any
/// on either side are treated as compatible. Only a clear cross-family
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
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 7518fcf7..e84550d7 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
@@ -6,27 +6,78 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
///
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
/// real encoder is pinned in that driver's own test project (*ArrayWriteSupportParityTests); these
-/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs).
+/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs, declared length,
+/// write caps).
///
public sealed class DriverArrayWriteSupportTests
{
+ private const string Arr2 = ",\"isArray\":true,\"arrayLength\":2";
+
[Theory]
- [InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"}", true)]
- [InlineData("{\"region\":\"Coils\",\"address\":0}", true)] // FC15 ignores dataType
- [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"}", true)]
- [InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"}", true)] // case-insensitive, like the driver
- [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0}", true)] // dataType ⇒ Int16
- [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"}", false)]
- [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"}", false)]
- [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"}", false)]
- [InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"}", false)] // read-only region
- [InlineData("{\"region\":\"DiscreteInputs\",\"address\":0}", false)]
- [InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
+ [InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
+ [InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
+ [InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
+ [InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
+ [InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
+ [InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
+ [InlineData("{\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no region ⇒ the factory refuses the tag
+ [InlineData("{\"region\":\"HoldingRegisters\",\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no address ⇒ likewise
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true}", false)] // no length ⇒ scalar to the driver
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":0}", false)]
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":\"4\"}", false)] // non-numeric length
+ [InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":1}", true)] // 1-element array is valid
[InlineData("not json", false)]
[InlineData("", false)]
- public void Modbus_rows_follow_region_and_element_type(string tagConfig, bool expected) =>
+ public void Modbus_rows_follow_region_element_type_and_declared_length(string tagConfig, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
+ /// #610 review MINOR-1 — the driver refuses (does not chunk) an FC16 over maxRegistersPerWrite
+ /// (default 123), so an array whose registers cannot fit is never advertised writable.
+ [Theory]
+ [InlineData("Int16", 123, true)]
+ [InlineData("Int16", 124, false)]
+ [InlineData("Int32", 61, true)] // 122 registers
+ [InlineData("Int32", 62, false)] // 124 registers
+ [InlineData("Float64", 30, true)] // 120 registers
+ [InlineData("Float64", 31, false)] // 124 registers
+ public void Modbus_register_arrays_respect_the_default_write_cap(string dataType, int length, bool expected) =>
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray(dataType, length)).ShouldBe(expected);
+
+ /// A configured cap (driver- or device-level, case-insensitive) is honoured; when both are visible the
+ /// smaller wins, and a non-positive value means the driver default.
+ [Fact]
+ public void Modbus_register_arrays_respect_a_configured_write_cap()
+ {
+ var tag = RegisterArray("Int16", 10);
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":10}").ShouldBeTrue();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"MaxRegistersPerWrite\":9}").ShouldBeFalse();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
+ .ShouldBeFalse();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
+ // A larger configured cap lifts the default: 124 registers fit under 200.
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
+ .ShouldBeTrue();
+ }
+
+ /// FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
+ /// writable.
+ [Fact]
+ public void Modbus_coil_arrays_respect_the_FC15_ceiling()
+ {
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
+ "{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1968}").ShouldBeTrue();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
+ "{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
+ }
+
+ private static string RegisterArray(string dataType, int length) =>
+ $"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
+
[Theory]
[InlineData("Byte", true)]
[InlineData("Int16", true)]
@@ -41,12 +92,23 @@ public sealed class DriverArrayWriteSupportTests
[InlineData("String", false)]
[InlineData("DateTime", false)]
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
- DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"}}")
+ DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"{Arr2}}}")
.ShouldBe(expected);
[Fact]
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
- DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"}").ShouldBeFalse();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"" + Arr2 + "}").ShouldBeFalse();
+
+ /// #610 review MINOR-2 — S7 maps isArray without a positive integer arrayLength to a SCALAR tag.
+ [Theory]
+ [InlineData("", false)]
+ [InlineData(",\"isArray\":true", false)]
+ [InlineData(",\"isArray\":true,\"arrayLength\":0", false)]
+ [InlineData(",\"isArray\":false,\"arrayLength\":4", false)]
+ [InlineData(",\"isArray\":true,\"arrayLength\":4", true)]
+ public void S7_requires_a_declared_length(string shape, bool expected) =>
+ DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"{shape}}}")
+ .ShouldBe(expected);
[Theory]
[InlineData("Boolean", true)]
@@ -60,7 +122,17 @@ public sealed class DriverArrayWriteSupportTests
[InlineData("UInt32", false)]
[InlineData("Byte", false)]
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
- DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}").ShouldBe(expected);
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"" + Arr2 + "}").ShouldBe(expected);
+
+ /// #610 review MINOR-4 — with no declared length the node is unbounded and a short whole-array write
+ /// would RESIZE the Galaxy attribute, so an undeclared-length Galaxy array stays read-only.
+ [Fact]
+ public void Galaxy_requires_a_declared_length()
+ {
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true}").ShouldBeFalse();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":0}").ShouldBeFalse();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":3}").ShouldBeTrue();
+ }
[Fact]
public void OpcUaClient_forwards_every_array() =>
@@ -73,15 +145,15 @@ public sealed class DriverArrayWriteSupportTests
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
var others = DriverTypeNames.All.Except(listed).ToList();
others.ShouldNotBeEmpty();
- const string blob = "{\"region\":\"Coils\",\"dataType\":\"Int32\"}";
+ // A blob every listed driver would accept, so only the driver type can make the answer false.
+ const string blob = "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int32\"" + Arr2 + "}";
// Positive control: the same blob IS array-writable under the listed drivers that read it.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", blob).ShouldBeTrue();
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
{
- // A blob every listed driver would accept, so only the driver type can make the answer false.
- DriverArrayWriteSupport.Supports(driverType, "Int32", "{\"region\":\"Coils\",\"dataType\":\"Int32\"}")
- .ShouldBeFalse(driverType ?? "");
+ DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "");
}
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyArrayWriteSupportParityTests.cs
index 86985574..fddb4130 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyArrayWriteSupportParityTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyArrayWriteSupportParityTests.cs
@@ -7,10 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
///
/// Gitea #610 — pins 's Galaxy row against the REAL write encoder
-/// (). The OPC UA server hands the driver the CLR array type of the node's
-/// DataType (the SDK refuses a mistyped array before the write handler runs), so for every
-/// Tag.DataType the server can materialise, the node is advertised writable exactly when the encoder
-/// accepts that array type.
+/// (). The driver receives the CLR array type of the node's DataType and no
+/// other: NOT because the SDK checks it (it skips its own type check on a node with an OnWriteValue
+/// handler, which every writable equipment node has) but because OtOpcUaNodeManager.EvaluateEquipmentWriteStructure
+/// refuses an array whose element type does not match the node's DataType exactly (#610 review MAJOR-1). So
+/// for every Tag.DataType the server can materialise, the node is advertised writable exactly when the
+/// encoder accepts that array type.
///
[Trait("Category", "Unit")]
public sealed class GalaxyArrayWriteSupportParityTests
@@ -50,14 +52,18 @@ public sealed class GalaxyArrayWriteSupportParityTests
encoderAccepts = false;
}
- // UInt32 / UInt64 are a deliberate exclusion: CLR array covariance lets uint[] / ulong[] match the encoder's
- // int[] / long[] arms, so it "accepts" them by REINTERPRETING the bits (uint.MaxValue → -1) — unlike the
- // scalar arms, which range-check. A Galaxy attribute is never unsigned (DataTypeMap), so the table refuses
- // rather than advertise a write that silently corrupts large values.
- var expected = encoderAccepts && dataType is not ("UInt32" or "UInt64");
- if (dataType is "UInt32" or "UInt64") encoderAccepts.ShouldBeTrue("the covariance hazard this exclusion documents");
-
- DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}")
- .ShouldBe(expected, dataType);
+ // UInt32 / UInt64 used to be a hand-written exclusion here, because CLR array covariance let uint[] /
+ // ulong[] match the encoder's int[] / long[] arms and be encoded by reinterpreting their bits. The encoder
+ // now refuses them itself, so the table and the encoder agree with no special case.
+ DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":2}")
+ .ShouldBe(encoderAccepts, dataType);
}
+
+ /// Positive control: the theory above sees both answers, so it is not vacuously all-refused.
+ [Fact]
+ public void The_matrix_contains_both_answers() =>
+ ClrArrays.Keys
+ .Select(dt => DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dt, "{\"isArray\":true,\"arrayLength\":2}"))
+ .ToHashSet()
+ .ShouldBe(new[] { true, false }, ignoreOrder: true);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs
index 8e5cb180..390f1238 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs
@@ -121,6 +121,21 @@ public sealed class MxValueEncoderTests
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
}
+ ///
+ /// Gitea #610 review — CLR array covariance makes uint[] is int[] and ulong[] is long[] true,
+ /// so the int[]/long[] arms used to encode an unsigned array by reinterpreting its bits (4,000,000,000 →
+ /// -294,967,296). An unsigned array is refused instead, like an out-of-range unsigned scalar.
+ ///
+ [Fact]
+ public void Encode_UnsignedArrays_AreRefused_NotReinterpreted()
+ {
+ Should.Throw(() => MxValueEncoder.Encode(new uint[] { 4_000_000_000 }));
+ Should.Throw(() => MxValueEncoder.Encode(new ulong[] { ulong.MaxValue }));
+ // Positive control: the signed arrays still encode.
+ MxValueEncoder.Encode(new[] { -1 }).ArrayValue.Int32Values.Values.ShouldBe(new[] { -1 });
+ MxValueEncoder.Encode(new[] { -1L }).ArrayValue.Int64Values.Values.ShouldBe(new[] { -1L });
+ }
+
/// Verifies that encoding an unsupported type throws an exception.
[Fact]
public void Encode_UnsupportedType_Throws()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs
index 49e2078f..a47a721a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs
@@ -51,8 +51,54 @@ public sealed class ModbusArrayWriteSupportParityTests
var answers = Shapes()
.Select(s => DriverArrayWriteSupport.Supports(
DriverTypeNames.Modbus, "Int16",
- $$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}"}"""))
+ $$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
.ToHashSet();
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
+
+ ///
+ /// #610 review MINOR-1/-2 — the table must also agree with the driver on the SHAPE edges: the FC16 write cap
+ /// (the driver refuses rather than chunks an over-cap array; default 123 registers, or the configured
+ /// MaxRegistersPerWrite) and a missing / zero arrayLength (the factory maps it to a scalar tag,
+ /// whose scalar encoder refuses an array value).
+ ///
+ [Theory]
+ [InlineData("Int32", "\"arrayLength\":61", 0, 61)] // 122 registers — fits the default cap
+ [InlineData("Int32", "\"arrayLength\":62", 0, 62)] // 124 registers — over it
+ [InlineData("Float64", "\"arrayLength\":31", 0, 31)] // 124 registers
+ [InlineData("Int16", "\"arrayLength\":10", 10, 10)] // exactly a configured cap
+ [InlineData("Int16", "\"arrayLength\":11", 10, 11)] // one over it
+ [InlineData("Int16", "\"arrayLength\":0", 0, 2)] // declared length 0 ⇒ scalar to the driver
+ [InlineData("Int16", "\"isArrayLengthMissing\":true", 0, 2)]
+ public async Task Table_agrees_with_the_driver_on_write_cap_and_declared_length(
+ string dataType, string lengthProp, ushort maxRegistersPerWrite, int elements)
+ {
+ var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","isArray":true,{{lengthProp}}}""";
+ var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
+ var fake = new ModbusDriverTests.FakeTransport();
+ var options = new ModbusDriverOptions
+ {
+ Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)],
+ MaxRegistersPerWrite = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
+ };
+ var drv = new ModbusDriver(options, "modbus-array-cap-parity", _ => fake);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var value = Enumerable.Repeat