fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)

MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:31:46 -04:00
parent a702875d41
commit 52708bff52
27 changed files with 509 additions and 97 deletions
+31 -12
View File
@@ -343,17 +343,34 @@ 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 - **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on (an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch. 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, - **The element type must match the node's DataType, or be a numeric type that converts to it
a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the as written. A **numeric** array of another numeric element type is **converted, checked, element by
check the OPC UA SDK would normally make itself: it skips its own type check on any node with an element** into the node's element type, and the converted array is what the node stores and the
`OnWriteValue` handler, which every writable equipment node has, so the node manager makes it driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge) integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder (`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
converts with a range check, so an out-of-range scalar fails the device write and is reverted. SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline. 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 — that write is still refused (4,000,000,000
overflows `Int32`), while an in-range `UInt32[]` converts. The conversion is required, not a
nicety: ScadaBridge writes the CLR list it decoded from a `List` attribute, and its element types are
only Boolean/Int32/Float/Double/String/DateTime, so an exact-match rule left every `Int16`/`UInt16`/
`Byte`/`UInt32`/`Int64`/`UInt64` array node (Modbus's default `Int16` included) and a `Double` list to
a `Float` array unwritable from it. **Scalar** writes keep their looser rule — any numeric to any
numeric node, unconverted — because 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 refused `BadTypeMismatch` inline **only on Modbus and S7**,
whose scalar encoders cannot take one (`DriverArrayWriteSupport.RefusesArrayValueOnScalarTag`). Every
other driver keeps the pre-#610 behaviour — the write is dispatched and the driver decides. That
matters for browse-committed tags: the `/raw` commit sets the DataType but never `isArray`, so an
upstream array variable (OpcUaClient) or a Galaxy array attribute committed from the browser is a
**scalar** node into which the driver publishes an array, and writing that array back still works
(OpcUaClient forwards it to the upstream server, which arbitrates the shape; Galaxy encodes it). Set
`isArray` + `arrayLength` on such a tag to get the array-node checks above.
- A partial **IndexRange** write is - A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server `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. does not hold the device array authoritatively enough to read-modify-write it.
@@ -362,7 +379,9 @@ What a client sees on a writable array node:
write-outcome self-correction, unchanged). write-outcome self-correction, unchanged).
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing - Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on 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`. the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. A
change to the array-on-scalar refusal (`ScalarRefusesArrayValue` — only a driver-type edit can flip
it) is deliberately NOT whitelisted and rebuilds.
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every > ⚠️ **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 > array node was read-only regardless of its authored `AccessLevel`. On the first boot after
+1 -1
View File
@@ -116,4 +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. - **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. - **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. - **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. - **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 or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (an `Int32` list to an `Int64` array arrives as `long[]`), and any unrepresentable element refuses the write `BadTypeMismatch` inline. An array written to a **scalar** Galaxy node (a browse-committed array attribute without `isArray`) is still dispatched, as before #610. Separately, `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.
@@ -63,8 +63,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
@@ -172,7 +172,12 @@ public interface IOpcUaAddressSpaceSink
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live /// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing /// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param> /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); /// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
/// <summary> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -273,7 +278,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
@@ -124,6 +124,26 @@ public static class DriverArrayWriteSupport
} }
} }
/// <summary>
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
/// reverting it when the scalar encoder throws.
/// <para>
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
/// </para>
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver // 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. // default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
@@ -54,8 +54,8 @@ internal static class MxValueEncoder
default: default:
throw new ArgumentException( throw new ArgumentException(
$"Cannot encode value of type {value.GetType()} as MxValue. Supported: " + $"Cannot encode value of type {value.GetType()} as MxValue. Supported: " +
"bool, int / long (and their unsigned variants), float, double, string, DateTime, " + "bool, int / long (and, as scalars only, their unsigned variants), float, double, string, " +
"and their 1-D array variants.", "DateTime, and the 1-D arrays of the signed / non-integer types (no uint[] / ulong[]).",
nameof(value)); nameof(value));
} }
} }
@@ -485,7 +485,10 @@ public sealed class AddressSpaceApplier
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) => private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) && string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) && string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal); string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal) &&
// #610 review R6: the array-on-scalar refusal is fixed at node creation (UpdateTagAttributes does not
// carry it), so a change to it recreates the mirror rather than updating it in place.
d.Previous.BackingScalarRefusesArrayValue == d.Current.BackingScalarRefusesArrayValue;
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns> /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
@@ -993,7 +996,7 @@ public sealed class AddressSpaceApplier
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname) ? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null; : null;
var writable = RawNodeWritable(t); var writable = RawNodeWritable(t);
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++; if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength, t.ScalarRefusesArrayValue)) failed++;
} }
} }
@@ -1050,7 +1053,7 @@ public sealed class AddressSpaceApplier
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname) ? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
: null; : null;
var writable = UnsReferenceNodeWritable(v); var writable = UnsReferenceNodeWritable(v);
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength)) if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength, v.BackingScalarRefusesArrayValue))
{ {
failed++; failed++;
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway) continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
@@ -1276,9 +1279,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).</summary> /// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; } try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
} }
@@ -143,6 +143,13 @@ public sealed record RawContainerNode(
/// <see langword="false"/> for a scalar. The materialised node is writable iff /// <see langword="false"/> for a scalar. The materialised node is writable iff
/// <c>Writable &amp;&amp; (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array /// <c>Writable &amp;&amp; (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
/// encoder stays read-only exactly as before #610. /// encoder stays read-only exactly as before #610.
/// <see cref="ScalarRefusesArrayValue"/> (#610 review R6) is true when the owning driver's SCALAR write path
/// cannot take an array value
/// (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>);
/// the node manager refuses an array write to the node inline while it is scalar-shaped. It is a fact about
/// the DRIVER, deliberately not gated on <see cref="IsArray"/>, so a surgical scalar↔array edit does not
/// flip it. It is not in the classifier's surgical whitelist, so a change to it (a driver's type edited in
/// place) rebuilds.
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close /// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom /// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added /// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
@@ -170,7 +177,8 @@ public sealed record RawTagPlan(
bool IsArray = false, bool IsArray = false,
uint? ArrayLength = null, uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw, AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false) bool ArrayWritable = false,
bool ScalarRefusesArrayValue = false)
{ {
/// <inheritdoc /> /// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -193,6 +201,7 @@ public sealed record RawTagPlan(
ArrayLength == other.ArrayLength && ArrayLength == other.ArrayLength &&
Realm == other.Realm && Realm == other.Realm &&
ArrayWritable == other.ArrayWritable && ArrayWritable == other.ArrayWritable &&
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
@@ -202,7 +211,7 @@ public sealed record RawTagPlan(
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode(); return hash.ToHashCode();
} }
@@ -236,7 +245,10 @@ public sealed record RawTagPlan(
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are /// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing /// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node /// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
/// is).</para> /// is), and <see cref="BackingScalarRefusesArrayValue"/> (#610 review R6 — the backing tag's
/// <see cref="RawTagPlan.ScalarRefusesArrayValue"/>, so a mirror refuses an array write exactly when its raw
/// node does; a change to it is treated as an identity change by the applier, so the mirror is recreated
/// rather than updated in place).</para>
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a /// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four /// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If /// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
@@ -256,7 +268,8 @@ public sealed record UnsReferenceVariable(
bool BackingIsArray = false, bool BackingIsArray = false,
uint? BackingArrayLength = null, uint? BackingArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns, AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
bool BackingArrayWritable = false); bool BackingArrayWritable = false,
bool BackingScalarRefusesArrayValue = false);
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -669,7 +682,8 @@ public static class AddressSpaceComposer
unsAreas, unsLines, equipment, topology); unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables( var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
BuildArrayWriteCapability(driverInstances, devices)); BuildArrayWriteCapability(driverInstances, devices),
BuildScalarArrayRefusal(driverInstances, devices));
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms) return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{ {
@@ -769,6 +783,7 @@ public static class AddressSpaceComposer
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism. // native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices); var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices);
var rawTags = new List<RawTagPlan>(); var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>()) foreach (var t in tags ?? Array.Empty<Tag>())
@@ -781,6 +796,7 @@ public static class AddressSpaceComposer
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty); var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig); var intent = TagConfigIntent.Parse(t.TagConfig);
var arrayWritable = intent.IsArray && arrayWritableFor(t); var arrayWritable = intent.IsArray && arrayWritableFor(t);
var scalarRefusesArray = scalarRefusesArrayFor(t);
var alarm = intent.Alarm is { } a var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null; : null;
@@ -800,7 +816,8 @@ public static class AddressSpaceComposer
HistorianTagname: intent.HistorianTagname, HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray, IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength, ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable)); ArrayWritable: arrayWritable,
ScalarRefusesArrayValue: scalarRefusesArray));
} }
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -817,6 +834,32 @@ public static class AddressSpaceComposer
/// </summary> /// </summary>
private static Func<Tag, bool> BuildArrayWriteCapability( private static Func<Tag, bool> BuildArrayWriteCapability(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices) IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{
var resolve = BuildTagOwnerResolver(driverInstances, devices);
return tag =>
{
var (driver, device) = resolve(tag);
return DriverArrayWriteSupport.Supports(
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
};
}
/// <summary>
/// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
/// (<see cref="DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>), as a per-tag predicate resolved
/// the same way as <see cref="BuildArrayWriteCapability"/>. Shared by the raw and UNS passes so a mirror
/// refuses exactly when its raw node does.
/// </summary>
private static Func<Tag, bool> BuildScalarArrayRefusal(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{
var resolve = BuildTagOwnerResolver(driverInstances, devices);
return tag => DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(resolve(tag).Driver?.DriverType);
}
// Tag → (owning driver, owning device); first row wins on a duplicate id, matching the composer's other id maps.
private static Func<Tag, (DriverInstance? Driver, Device? Device)> BuildTagOwnerResolver(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{ {
var driverById = driverInstances var driverById = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
@@ -828,8 +871,7 @@ public static class AddressSpaceComposer
{ {
var device = deviceById.GetValueOrDefault(tag.DeviceId); var device = deviceById.GetValueOrDefault(tag.DeviceId);
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId); var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
return DriverArrayWriteSupport.Supports( return (driver, device);
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
}; };
} }
@@ -847,7 +889,8 @@ public static class AddressSpaceComposer
IReadOnlyList<UnsLine> unsLines, IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment, IReadOnlyList<Equipment> equipment,
RawTopology topology, RawTopology topology,
Func<Tag, bool> arrayWritableFor) Func<Tag, bool> arrayWritableFor,
Func<Tag, bool> scalarRefusesArrayFor)
{ {
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>(); var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>(); if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
@@ -884,6 +927,7 @@ public static class AddressSpaceComposer
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable // Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is. // exactly when its raw node is.
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag); var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
var backingScalarRefusesArray = scalarRefusesArrayFor(tag);
vars.Add(new UnsReferenceVariable( vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId, UnsTagReferenceId: r.UnsTagReferenceId,
@@ -897,7 +941,8 @@ public static class AddressSpaceComposer
BackingHistorianTagname: intent.HistorianTagname, BackingHistorianTagname: intent.HistorianTagname,
BackingIsArray: intent.IsArray, BackingIsArray: intent.IsArray,
BackingArrayLength: intent.ArrayLength, BackingArrayLength: intent.ArrayLength,
BackingArrayWritable: backingArrayWritable)); BackingArrayWritable: backingArrayWritable,
BackingScalarRefusesArrayValue: backingScalarRefusesArray));
} }
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua; using Opc.Ua;
@@ -69,6 +70,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on /// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary> /// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new(); private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
/// <summary>Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
/// SCALAR tag (Modbus / S7 — <c>DriverArrayWriteSupport.RefusesArrayValueOnScalarTag</c>), so an array write to
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
/// write is dispatched and the driver decides). Set by <see cref="EnsureVariable"/>; cleared alongside
/// <see cref="_variables"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, byte> _arrayOnScalarRefused = new();
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers, /// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard). /// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can /// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
@@ -1921,8 +1928,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless // synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised // device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below. // caller) but BEFORE the optimistic dispatch below.
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node); // A numeric array of another element type is converted (checked) to the node's element type here and
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure; if (structure is not null) return structure;
value = coerced!;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`). // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
@@ -2025,18 +2038,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when /// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the /// the node has NO <c>OnWriteValue</c> 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 /// 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 /// by shape and declared length, and its element type must either match the node's DataType or be
/// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose /// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
/// (see <see cref="IsBuiltInTypeCompatible"/>). /// into the node's element type (<see cref="TryConvertNumericArray"/>) — refused whole if any element is
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
/// mismatch is refused. An array value to a scalar node is refused only when
/// <paramref name="refuseArrayOnScalar"/> says the owning driver cannot take one. A SCALAR write keeps
/// the looser numeric rule above on purpose (see <see cref="IsBuiltInTypeCompatible"/>).
/// </para> /// </para>
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static /// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
/// <c>DataType</c>, so it is unit-testable in isolation. /// <c>DataType</c>, so it is unit-testable in isolation.
/// </summary> /// </summary>
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param> /// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param> /// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
/// <param name="refuseArrayOnScalar">True when the node's owning driver cannot write an array value to a
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).</param>
/// <param name="coerced">On a <c>null</c> return, the value to store and dispatch: <paramref name="value"/>
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
/// <c>null</c> when the write is refused.</param>
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/> /// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns> /// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) internal static ServiceResult? EvaluateEquipmentWriteStructure(
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
{
coerced = null;
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
if (result is not null) coerced = null;
else coerced ??= value;
return result;
}
/// <summary>The verdict half of <see cref="EvaluateEquipmentWriteStructure(object?, NodeState, bool, out object?)"/>
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
/// Production goes through the four-argument overload, which also returns the converted value.</summary>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
{ {
// Minimum sensible check: a null payload is never a valid value write to a value variable. // Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null) if (value is null)
@@ -2073,10 +2113,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard 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 // 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 // up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
// byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides // ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
// whether the node takes one). An unresolved-DataType node deferred above, as it always has. // lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[]) // writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. 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 (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
{ {
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value"); return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
} }
@@ -2091,22 +2135,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// unclassifiable value ⇒ Unknown (Null). // unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType; var actual = TypeInfo.Construct(value).BuiltInType;
// Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK // Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
// runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every // because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
// writable equipment node has one, so nothing else stands between a mistyped array and the driver: a // handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
// UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote // driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
// uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16 // covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
// DataType. This restores the SDK's own rule for arrays — the element type must match, with the single // serving an int[] under an Int16 DataType.
// Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and // • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
// an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were // classifies as ByteString) proceeds unchanged.
// never client-writable before #610, so no client relies on a looser rule. // • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
// refused rather than deferred.
if (isArrayNode) if (isArrayNode)
{ {
return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString) if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
? null if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
: new ServiceResult( {
if (TryConvertNumericArray(source, expected, out var converted))
{
coerced = converted;
return null;
}
return new ServiceResult(
StatusCodes.BadTypeMismatch, StatusCodes.BadTypeMismatch,
$"array element type {actual} does not match node data type {expected}"); $"array element of type {actual} is not exactly representable as node data type {expected}");
}
return 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 (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
@@ -2166,6 +2229,76 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double; BuiltInType.Float or BuiltInType.Double;
/// <summary>
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
/// for an integer target, or is finite yet overflows to ±Infinity for a <see cref="BuiltInType.Float"/>
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
/// accepted, as the scalar path's drivers accept it. Pure + static.
/// </summary>
/// <param name="source">The 1-D numeric array the client wrote.</param>
/// <param name="target">The node's numeric element type.</param>
/// <param name="converted">The converted array (element CLR type of <paramref name="target"/>), or null.</param>
/// <returns>True when every element converted exactly enough to be written.</returns>
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
{
converted = null;
var elementType = target switch
{
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
_ => null,
};
if (elementType is null) return false;
var result = Array.CreateInstance(elementType, source.Length);
for (var i = 0; i < source.Length; i++)
{
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
result.SetValue(element, i);
}
converted = result;
return true;
}
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
{
result = null;
if (element is null) return false;
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
if (element is float or double)
{
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
}
try
{
result = target switch
{
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
_ => null,
};
return result is not null;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
return false;
}
}
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject /// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node /// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is /// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
@@ -2510,7 +2643,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param> /// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when /// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param> /// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -2564,6 +2697,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!; if (historized) _historizedTagnames[key] = historianTagname!;
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
} }
} }
@@ -2984,6 +3118,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_variables.Clear(); _variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear(); _historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
foreach (var alarm in _alarmConditions.Values) foreach (var alarm in _alarmConditions.Values)
{ {
@@ -3059,6 +3194,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(variable.NodeId); PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity). // Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _); _historizedTagnames.TryRemove(key, out _);
_arrayOnScalarRefused.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm); e = BuildNodesRemovedModelChange(variableNodeId, realm);
} }
ReportModelChangeOutsideLock(e, variableNodeId); ReportModelChangeOutsideLock(e, variableNodeId);
@@ -3157,6 +3293,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(v.NodeId); PredefinedNodes?.Remove(v.NodeId);
} }
_historizedTagnames.TryRemove(id, out _); _historizedTagnames.TryRemove(id, out _);
_arrayOnScalarRefused.TryRemove(id, out _);
} }
// Part 9 condition nodes (+ their native flags). // Part 9 condition nodes (+ their native flags).
@@ -58,8 +58,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
@@ -81,7 +81,7 @@ public class AddressSpaceSinkBatchTests
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -104,7 +104,7 @@ public class AddressSpaceSinkBatchTests
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -214,7 +214,7 @@ public class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() => RebuildCalled = true; public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
@@ -232,7 +232,7 @@ public class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -156,4 +156,19 @@ public sealed class DriverArrayWriteSupportTests
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>"); DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
} }
} }
/// <summary>#610 review R6 MINOR-1 — only Modbus and S7 have a scalar encoder that cannot take an array value;
/// every other driver (the pass-through OpcUaClient and Galaxy above all) keeps dispatching an array written to
/// a scalar-shaped tag, as before #610.</summary>
[Fact]
public void Only_modbus_and_s7_refuse_an_array_value_on_a_scalar_tag()
{
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.Modbus).ShouldBeTrue();
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.S7).ShouldBeTrue();
foreach (var driverType in DriverTypeNames.All
.Except([DriverTypeNames.Modbus, DriverTypeNames.S7]).Append("NoSuchDriver").Append(null))
{
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(driverType).ShouldBeFalse(driverType ?? "<null>");
}
}
} }
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
private const string S7ArrayUns = "filling/line-1/station-1/Levels"; private const string S7ArrayUns = "filling/line-1/station-1/Levels";
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint"; private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe"; private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{ {
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}", Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
}; };
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" }; var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
var ua = new DriverInstance
{
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
};
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
var vector = new Tag
{
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
};
var recipe = new Tag var recipe = new Tag
{ {
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32", TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
@@ -103,9 +118,9 @@ public sealed class ArrayWriteWireTests
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" }; var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
var composition = AddressSpaceComposer.Compose( var composition = AddressSpaceComposer.Compose(
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(), [area], [line], [equip], [s7, ab, gx, ua], Array.Empty<ScriptedAlarm>(),
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint], unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
rawFolders: [folder], devices: [plc1, plc2, gxDevice]); rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition).ShouldBe(0); applier.MaterialiseHierarchy(composition).ShouldBe(0);
@@ -118,6 +133,7 @@ public sealed class ArrayWriteWireTests
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
return rig; return rig;
} }
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
/// <summary> /// <summary>
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler, /// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
/// so the node manager must refuse a mistyped array before dispatch: an <c>Int32[]</c> to an S7 Int16 array /// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
/// (else the node serves an int[] under an Int16 DataType), and a <c>UInt32[]</c> to a Galaxy Int32 array /// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
/// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good). /// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
/// -294,967,296, Good).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver() public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
@@ -264,7 +281,7 @@ public sealed class ArrayWriteWireTests
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite); (await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
(await WriteAsync(session, s7, new[] { 1, 2, 3, 4 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "Int32[] to an Int16 array"); (await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array"); (await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 }); (await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 }); ((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
@@ -275,7 +292,53 @@ public sealed class ArrayWriteWireTests
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 }); lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
} }
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.</summary> /// <summary>
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
/// </summary>
[Fact]
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-convert", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var s7 = Raw(session, S7ArrayRaw);
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
lock (gateway.Writes)
{
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
}
}
/// <summary>
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
/// </summary>
[Fact]
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-to-ua-scalar", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
gateway.Count.ShouldBe(1);
}
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
/// one (S7) is refused before dispatch.</summary>
[Fact] [Fact]
public async Task An_array_value_to_a_scalar_node_is_refused_inline() public async Task An_array_value_to_a_scalar_node_is_refused_inline()
{ {
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
} }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
} }
@@ -350,7 +350,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary> /// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
@@ -486,7 +486,7 @@ public sealed class AddressSpaceApplierRawUnsTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm)); => Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength)); => Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
@@ -2033,7 +2033,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2077,7 +2077,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>No-op folder creation call.</summary> /// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary> /// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Records a rebuild address space call.</summary> /// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -2128,7 +2128,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>No-op rebuild address space call.</summary> /// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -306,6 +306,14 @@ public sealed class AddressSpaceComposerDualNamespaceTests
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
// #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip
// keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror.
c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse();
} }
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the /// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
@@ -84,6 +84,22 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV"); call.HistorianTagname.ShouldBe("MyTag.PV");
} }
/// <summary>#610 review R6 — the array-on-scalar refusal flag reaches the inner sink. Actors inject THIS wrapper,
/// so a dropped flag would leave every Modbus/S7 scalar node dispatching array writes (the reflection guard
/// only proves the call arrives, not its arguments).</summary>
[Fact]
public void EnsureVariable_forwards_refuseArrayOnScalar_to_inner_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.EnsureVariable("v-1", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw, refuseArrayOnScalar: true);
deferred.EnsureVariable("v-2", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw);
inner.RefuseArrayOnScalarCalls.ShouldBe(new[] { ("v-1", true), ("v-2", false) });
}
/// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a /// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a
/// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every /// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every
/// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the /// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the
@@ -226,6 +242,10 @@ public sealed class DeferredAddressSpaceSinkTests
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new(); public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary> /// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <summary>Gets the (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public ConcurrentQueue<(string NodeId, bool Refuse)> RefuseArrayOnScalarQueue { get; } = new();
/// <summary>Gets the list of (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, bool Refuse)> RefuseArrayOnScalarCalls => RefuseArrayOnScalarQueue.ToList();
/// <inheritdoc /> /// <inheritdoc />
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
@@ -241,10 +261,11 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"EF:{folderNodeId}"); => CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
CallQueue.Enqueue($"EV:{variableNodeId}"); CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
RefuseArrayOnScalarQueue.Enqueue((variableNodeId, refuseArrayOnScalar));
} }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
@@ -297,7 +318,7 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <inheritdoc />
@@ -214,9 +214,10 @@ public sealed class EquipmentWriteGateTests
} }
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array /// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array
/// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a /// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric
/// <c>uint[]</c> to an Int32 node would reach MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret /// element the node's type cannot represent exactly (a <c>uint[]</c> element above Int32.MaxValue would reach
/// its bits). The matching element type is the positive control.</summary> /// MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret its bits) is refused. The matching element
/// type is the positive control.</summary>
[Theory] [Theory]
[MemberData(nameof(MistypedArrays))] [MemberData(nameof(MistypedArrays))]
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped) public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
@@ -229,28 +230,103 @@ public sealed class EquipmentWriteGateTests
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new() public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
{ {
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16 { DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 70_000 } }, // Int32[] → Int16, out of range
{ DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy) { DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy)
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy) { DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy)
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float { DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 1e300 } }, // Double[] → Float, overflows
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1.5d, 2d } }, // fractional → integer
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { double.NaN, 2d } }, // NaN → integer
{ DataTypeIds.Int64, new long[] { 1, 2 }, new[] { double.PositiveInfinity, 2d } },// ∞ → integer
{ DataTypeIds.UInt16, new ushort[] { 1, 2 }, new[] { -1, 2 } }, // negative → unsigned
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { true, false } }, // Boolean[] → Int32
{ DataTypeIds.String, new[] { "a", "b" }, new[] { 1, 2 } }, // Int32[] → String
{ DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean { DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused { DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
}; };
/// <summary>#610 review R6 MAJOR-1 — ScadaBridge writes the CLR list it decoded from a List attribute, whose
/// element type can only be Boolean/Int32/Float/Double/String/DateTime. A numeric array of another element type
/// is CONVERTED (checked) to the node's element type, and the converted array — not the client's — is what the
/// production overload hands back to be stored and dispatched.</summary>
[Theory]
[MemberData(nameof(ConvertibleArrays))]
public void Structure_numeric_array_of_another_element_type_is_converted_to_the_node_type(NodeId dataType, Array written, Array expected)
{
var node = ArrayNode(dataType, length: (uint)written.Length);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldNotBeNull().GetType().ShouldBe(expected.GetType());
coerced.ShouldBe(expected);
}
public static TheoryData<NodeId, Array, Array> ConvertibleArrays() => new()
{
{ DataTypeIds.Int16, new[] { 1, -2, 32_767 }, new short[] { 1, -2, 32_767 } }, // Int32 list → Modbus default
{ DataTypeIds.Float, new[] { 1.5d, -2.25d }, new[] { 1.5f, -2.25f } }, // Double list → Float array
{ DataTypeIds.Int64, new[] { 1, -2 }, new long[] { 1, -2 } }, // Int32 list → Galaxy Int64
{ DataTypeIds.UInt16, new[] { 0, 65_535 }, new ushort[] { 0, 65_535 } },
{ DataTypeIds.UInt32, new[] { 0, int.MaxValue }, new uint[] { 0, int.MaxValue } },
{ DataTypeIds.Byte, new[] { 0, 255 }, new byte[] { 0, 255 } },
{ DataTypeIds.Int16, new[] { 2.0d, -3.0d }, new short[] { 2, -3 } }, // integral doubles convert
{ DataTypeIds.Int32, new uint[] { 5, 2_147_483_647 }, new[] { 5, int.MaxValue } }, // in-range UInt32 → Int32
{ DataTypeIds.Double, new[] { 1f, 2.5f }, new[] { 1d, 2.5d } },
};
/// <summary>An exact element-type match is passed through unconverted (the same instance).</summary>
[Fact]
public void Structure_matching_array_is_passed_through_unchanged()
{
var written = new short[] { 1, 2 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, ArrayNode(DataTypeIds.Int16, 2), refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
}
/// <summary>A refused write hands back no value to store.</summary>
[Fact]
public void Structure_refused_array_returns_no_coerced_value()
{
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { 70_000 }, ArrayNode(DataTypeIds.Int16, 1), refuseArrayOnScalar: true, out var coerced)
.ShouldNotBeNull();
coerced.ShouldBeNull();
}
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK /// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
/// itself honours for array nodes.</summary> /// itself honours for array nodes. An SByte array is numeric, so it converts when every element fits (R6) and is
/// refused when one does not.</summary>
[Fact] [Fact]
public void Structure_byte_array_node_takes_a_byte_array() public void Structure_byte_array_node_takes_a_byte_array()
{ {
var node = ArrayNode(DataTypeIds.Byte, length: 2); var node = ArrayNode(DataTypeIds.Byte, length: 2);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull();
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node)!.StatusCode OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBe(new byte[] { 1, 2 });
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { -1, 2 }, node)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
} }
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node is refused up front. A /// <summary>#610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy:
/// byte[] (a ByteString on the wire) is a scalar and still reaches the type check; an unresolved-DataType node /// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is
/// defers as before.</summary> /// dispatched and the driver decides. The Modbus/S7 refusal is the control.</summary>
[Fact]
public void Structure_scalar_node_of_a_pass_through_driver_accepts_an_array_value()
{
var node = ValueNode(DataTypeIds.Int32);
var written = new[] { 1, 2, 3 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: false, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
// The element-type check still applies on the pass-through path: a String[] to an Int32 node is refused.
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { "a" }, node, refuseArrayOnScalar: false, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
}
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node of a driver that cannot take
/// one (Modbus / S7 — the two-argument overload's assumption) is refused up front. A byte[] (a ByteString on the
/// wire) is a scalar and still reaches the type check; an unresolved-DataType node defers as before.</summary>
[Fact] [Fact]
public void Structure_scalar_node_refuses_an_array_value() public void Structure_scalar_node_refuses_an_array_value()
{ {
@@ -220,7 +220,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds address space (recorded via span).</summary> /// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ } public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
@@ -138,7 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -153,7 +153,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -172,7 +172,7 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -1044,7 +1044,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal)) if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal))
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}"); throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -772,7 +772,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) =>
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild call.</summary> /// <summary>Records a rebuild call.</summary>