diff --git a/docs/AlarmHistorian.md b/docs/AlarmHistorian.md
index 991d01b2..7f29303f 100644
--- a/docs/AlarmHistorian.md
+++ b/docs/AlarmHistorian.md
@@ -103,6 +103,17 @@ Two postures are deliberate:
a key with no dial and no listener (e.g. a fleet-wide secret on an unpaired
node) captures changes but shares them with nobody, and gating it would park
its events behind a Primary that does not hold them (review R1 m3).
+- **A gated node stands down only for the peer that holds its rows — and BOTH
+ halves of a pair know it.** The dialling half identifies its peer by the host
+ of `LocalDb:Replication:PeerAddress`. The passive half (`ApiKey` +
+ `SyncListenPort`, no `PeerAddress`) identifies it as its **per-cluster mesh
+ partner** (the other member of its `cluster-{ClusterId}` role). Before review
+ R6 MINOR-4 the passive half knew no peer at all, so as Secondary it kept
+ draining beside its Primary partner and the historian received every alarm
+ event twice. ⚠️ A passive half with **no** cluster-scoped role (a legacy,
+ pre-Phase-6 node) still cannot identify its partner and keeps draining — it
+ cannot prove which Primary holds its rows, and deferring to the wrong one
+ would strand them.
- **A gate that throws means "not now", never permission.** Draining on a gate
fault would put a pair into dual delivery exactly when its redundancy signal is
sick.
diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index afc82cc2..dca77b43 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -357,6 +357,11 @@ is client-visible.
> pair (role still unknown, a driver peer Up) the gate already denies, exactly
> as the route does (review R1 m2 — previously the view sat on its single-node
> "allow" seed until the first snapshot, reopening the flap for that window).
+> It mirrors, it does not deny from start: until this node has **joined** and
+> sees its peer Up, its member count is 0 and both the published verdict and the
+> live route **allow** (seconds normally; up to the bootstrap guard's 25 s
+> partner probe). The two agree there, so the flap cannot occur, but a native
+> ack in that pre-join window is serviced (review R6 MINOR-2).
> **Best-effort during reconnect.** If a native ack arrives while the owning
> driver is `Connecting`/`Reconnecting`, it is dropped with a debug log and **not**
diff --git a/docs/Redundancy.md b/docs/Redundancy.md
index c6e73f8b..d031b520 100644
--- a/docs/Redundancy.md
+++ b/docs/Redundancy.md
@@ -526,13 +526,13 @@ Three data-plane surfaces are **Primary-gated** so a warm standby never touches
Before archreview 03/S4 the unknown-role case defaulted to *allow* unconditionally, opening a dual-primary window: a freshly-booted secondary on a multi-node cluster serviced shared-device writes and emitted duplicate fleet-wide alerts for up to the ~10 s `RedundancyStateActor` heartbeat (and indefinitely if the snapshot's NodeId never matched this node). Membership is authoritative much earlier than DPS delivery, so an unknown role on a **multi-driver** cluster now default-**denies**. The driver member count is read live from `Cluster.State.Members` (Up members with the `driver` role); a non-cluster ActorRefProvider yields 0 ⇒ single-node posture. If a snapshot arrives that never mentions this node while a driver peer exists (the 03/S5 identity-mismatch shape), `DriverHostActor` logs a one-time Warning so the silent mismatch is diagnosable.
-**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown) — and since review R1 m2 a native OPC UA Acknowledge is refused up front (`BadInvalidState`) in the same window: `DriverHostActor` publishes its Primary verdict to `IDriverPrimaryRoleSource` (the native-ack gate, and the MQTT Sparkplug primary-host claim) once at start and again on every cluster membership change, not only on redundancy snapshots, so the published verdict denies as soon as a driver peer is Up rather than sitting on the single-node "allow" seed until the first snapshot. The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
+**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown) — and since review R1 m2 a native OPC UA Acknowledge is refused up front (`BadInvalidState`) in the same window: `DriverHostActor` publishes its Primary verdict to `IDriverPrimaryRoleSource` (the native-ack gate, and the MQTT Sparkplug primary-host claim) once at start and again on every cluster membership change, not only on redundancy snapshots, so the published verdict denies as soon as a driver peer is Up rather than sitting on the single-node "allow" seed until the first snapshot. ⚠️ "As soon as a driver peer is Up" means **in this node's own cluster view**, i.e. after it has joined: the host is spawned before the join, when the view holds no members, and a member count of 0 takes the single-node *allow* posture — for the published verdict and the live gate alike. So a node cold-starting into a pair whose peer is already Primary allows until its own join completes (seconds normally; up to `Cluster:BootstrapGuard:PartnerProbeSeconds`, 25 s, under the bootstrap guard), and in that window the MQTT Sparkplug primary-host claim can be held on both nodes. Native ack is not exposed (the view and the route agree, so there is no acked → unacked revert). Denying until self is Up was not adopted: it would also deny writes/acks during a single-node cluster's own join (review R6 MINOR-2). The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
Under warm/hot redundancy both cluster nodes run `ScriptedAlarmHostActor` and evaluate scripted alarms, keeping each node's address space and engine state warm for instant failover. To avoid duplicate rows on `/alerts` and duplicate historian writes, only the Primary node publishes externally:
- **`alerts` topic emission** — `ScriptedAlarmHostActor` and `DriverHostActor.ForwardNativeAlarm` subscribe to the `redundancy-state` DPS topic and cache the local node's `RedundancyRole`, then gate the cluster `alerts` publish through `PrimaryGatePolicy` (table above). The OPC UA condition-node write and inbound ack/shelve command processing remain **ungated** on both nodes so the secondary is always ready to serve clients after a failover.
- **`HistorianAdapterActor` historization** — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to the `alerts` DPS topic and translates each `AlarmTransitionEvent` → `AlarmHistorianEvent` before enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size.
-- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. The gate applies only to a node whose queue a peer actually holds — `LocalDbReplicationPolicy.SharesRowsWithPeer`: `LocalDb:Replication:ApiKey` AND (`PeerAddress` OR `LocalDb:SyncListenPort` > 0); any other node always drains (review R1 m3). Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
+- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. The gate applies only to a node whose queue a peer actually holds — `LocalDbReplicationPolicy.SharesRowsWithPeer`: `LocalDb:Replication:ApiKey` AND (`PeerAddress` OR `LocalDb:SyncListenPort` > 0); any other node always drains (review R1 m3). Within a pair BOTH halves stand down for their partner: the dialling half knows it by the `PeerAddress` host, the passive half (no `PeerAddress`) as its per-cluster mesh partner (review R6 MINOR-4 — before that the passive half never stood down, so both halves drained and every event reached the historian twice; a passive half with no `cluster-{ClusterId}` role still cannot identify its partner and keeps draining). Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
- **Continuous-historization value CAPTURE** (Gitea #537) — a third gate on the same role rule, and the
one that gates the *opposite* half of its pipeline. Both pair nodes serve the same address space and see
diff --git a/docs/Uns.md b/docs/Uns.md
index 575fe19e..8f59101e 100644
--- a/docs/Uns.md
+++ b/docs/Uns.md
@@ -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
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
-- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array,
- a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused
- inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the
- check the OPC UA SDK would normally make itself: it skips its own type check on any node with an
- `OnWriteValue` handler, which every writable equipment node has, so the node manager makes it
- instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was
- written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep
- their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge)
- write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder
- converts with a range check, so an out-of-range scalar fails the device write and is reverted.
-- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline.
+- **The element type must match the node's DataType, or be a numeric type that converts to it
+ exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
+ as written. A **numeric** array of another numeric element type is **converted, checked, element by
+ element** into the node's element type, and the converted array is what the node stores and the
+ driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
+ write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
+ target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
+ integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
+ (`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
+ SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
+ handler, which every writable equipment node has, so the node manager makes it instead. Without it a
+ `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was written bit-reinterpreted
+ (4,000,000,000 → −294,967,296) with a Good result — 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
`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.
@@ -362,7 +379,9 @@ What a client sees on a writable array node:
write-outcome self-correction, unchanged).
- 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
- 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
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index 07869fe3..0de6653c 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -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.
- **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag.
- **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
-- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node exactly (`BadTypeMismatch` inline otherwise), and `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
+- **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.
diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index bfbb3dc1..9b0a0ba3 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -363,9 +363,14 @@ Hence: bind the claim to a connection, and rebuild the connection on role change
| Role unknown, no driver peer (single node) | open | `ONLINE` |
The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
-(`Core.Abstractions`), published by `DriverHostActor` on every redundancy snapshot and **sampled on a
-1-second timer** rather than evented — a process-singleton raising events at per-instance drivers
-outlives every driver it holds a reference to.
+(`Core.Abstractions`), published by `DriverHostActor` at host start, on every cluster membership change and on
+every redundancy snapshot, and **sampled on a 1-second timer** rather than evented — a process-singleton
+raising events at per-instance drivers outlives every driver it holds a reference to.
+
+> ⚠️ **"Driver peer present" is this node's own cluster view.** Before the node has joined, it sees no
+> members, which is the single-node row: a node cold-starting into a pair whose peer is already Primary
+> can open the claim until its join completes (seconds; up to the bootstrap guard's 25 s partner probe),
+> so both nodes may briefly hold it (review R6 MINOR-2).
> **Accepted trade: a brief edge-visible `OFFLINE` during failover**, while the old Primary stands down
> and the new one connects and republishes. That is a genuine state transition — for that window there
diff --git a/docs/operations/2026-07-20-localdb-pair-replication.md b/docs/operations/2026-07-20-localdb-pair-replication.md
index b61a94ed..5db75b75 100644
--- a/docs/operations/2026-07-20-localdb-pair-replication.md
+++ b/docs/operations/2026-07-20-localdb-pair-replication.md
@@ -94,7 +94,10 @@ Replication has two knobs, both under `LocalDb`:
> the passive node has no `PeerAddress`; `SyncListenPort` is deliberately not part of it, since a
> port with no key is fail-closed and syncs with nobody). The alarm-historian drain gate uses the
> stricter `ApiKey` AND (`PeerAddress` OR `SyncListenPort > 0`) — so set the key **and** the port/peer
-> together, or the node keeps draining its own queue. Consequences at enablement time:
+> together, or the node keeps draining its own queue. Once gated, the passive half stands down for
+> its **per-cluster mesh partner** (it has no `PeerAddress` to name one) — so it must carry the pair's
+> `cluster-{ClusterId}` role, or as Secondary it keeps draining beside its Primary and the historian
+> gets every alarm event twice (review R6 MINOR-4). Consequences at enablement time:
>
> - A configured node registers with **`baselineExistingRows: true`**, so the rows **already in the
> file** are seeded into the row-version ledger at the LWW floor (HLC `0`) and actually travel.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index b7f477f0..b53aa8b0 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -63,8 +63,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, 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)
- => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
+ 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, refuseArrayOnScalar);
///
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index 6cb319bd..1573e05a 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -172,7 +172,12 @@ public interface IOpcUaAddressSpaceSink
/// The namespace realm the variable (and its ) live
/// in. A historized UNS reference node registers the SAME as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.
- void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
+ /// Gitea #610 review (R6): when true and the node is SCALAR, a client write
+ /// of an array value is refused inline (BadTypeMismatch, 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.
+ 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);
///
/// Tear down + repopulate the address space. Called by OpcUaPublishActor after a
@@ -273,7 +278,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
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() { }
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
index 00267ccd..1130a061 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
@@ -124,6 +124,26 @@ public static class DriverArrayWriteSupport
}
}
+ ///
+ /// Gitea #610 review (R6 MINOR-1) — whether a driver of is known to REFUSE an
+ /// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
+ /// (BadTypeMismatch, nothing dispatched) instead of storing it optimistically, dispatching it, and
+ /// reverting it when the scalar encoder throws.
+ ///
+ /// Only Modbus and S7 qualify: their scalar encoders Convert.ToXxx 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 /raw commit never sets isArray), the driver publishes an array into
+ /// it, and writing that array back worked before #610 — OpcUaClient forwards the
+ /// Variant unchanged (the upstream server arbitrates shape) and Galaxy's MxValueEncoder
+ /// encodes the array. An unknown / null driver type is not listed ⇒ .
+ ///
+ ///
+ /// The owning driver instance's DriverType.
+ /// only when the driver's scalar write path cannot take an array value.
+ 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
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
index 6f1a9720..778cab21 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs
@@ -54,8 +54,8 @@ internal static class MxValueEncoder
default:
throw new ArgumentException(
$"Cannot encode value of type {value.GetType()} as MxValue. Supported: " +
- "bool, int / long (and their unsigned variants), float, double, string, DateTime, " +
- "and their 1-D array variants.",
+ "bool, int / long (and, as scalars only, their unsigned variants), float, double, string, " +
+ "DateTime, and the 1-D arrays of the signed / non-integer types (no uint[] / ulong[]).",
nameof(value));
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index 2086e878..2ddc48df 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -485,7 +485,10 @@ public sealed class AddressSpaceApplier
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, 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;
/// Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).
/// true when the node was removed; false when unknown or the sink threw.
@@ -993,7 +996,7 @@ public sealed class AddressSpaceApplier
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null;
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)
: null;
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++;
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, false when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).
/// true when the variable was ensured; false when the sink threw.
- 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; }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index 28854354..ff6c9b90 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -143,6 +143,13 @@ public sealed record RawContainerNode(
/// for a scalar. The materialised node is writable iff
/// Writable && (!IsArray || ArrayWritable), so an array tag on a driver with no array
/// encoder stays read-only exactly as before #610.
+/// (#610 review R6) is true when the owning driver's SCALAR write path
+/// cannot take an array value
+/// ();
+/// 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 , 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.
/// ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members. Because forces a custom
/// Equals/GetHashCode, the compiler's positional equality is suppressed, so a field added
@@ -170,7 +177,8 @@ public sealed record RawTagPlan(
bool IsArray = false,
uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
- bool ArrayWritable = false)
+ bool ArrayWritable = false,
+ bool ScalarRefusesArrayValue = false)
{
///
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -193,6 +201,7 @@ public sealed record RawTagPlan(
ArrayLength == other.ArrayLength &&
Realm == other.Realm &&
ArrayWritable == other.ArrayWritable &&
+ ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
///
@@ -202,7 +211,7 @@ public sealed record RawTagPlan(
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(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);
return hash.ToHashCode();
}
@@ -236,7 +245,10 @@ public sealed record RawTagPlan(
/// / were already inherited onto the record and are
/// covered by the same mechanism, as is (Gitea #610 — the backing
/// tag's , so a mirror is writable exactly when its raw node
-/// is).
+/// is), and (#610 review R6 — the backing tag's
+/// , 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).
/// Equality is the COMPILER-GENERATED positional-record equality: every member is a
/// value/string type (no IReadOnlyList member, unlike ), so the four
/// new fields are compared by value with no hand-written Equals/GetHashCode needed. If
@@ -256,7 +268,8 @@ public sealed record UnsReferenceVariable(
bool BackingIsArray = false,
uint? BackingArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
- bool BackingArrayWritable = false);
+ bool BackingArrayWritable = false,
+ bool BackingScalarRefusesArrayValue = false);
public sealed record UnsAreaProjection(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);
var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
- BuildArrayWriteCapability(driverInstances, devices));
+ BuildArrayWriteCapability(driverInstances, devices),
+ BuildScalarArrayRefusal(driverInstances, devices));
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.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
+ var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices);
var rawTags = new List();
foreach (var t in tags ?? Array.Empty())
@@ -781,6 +796,7 @@ public static class AddressSpaceComposer
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
var arrayWritable = intent.IsArray && arrayWritableFor(t);
+ var scalarRefusesArray = scalarRefusesArrayFor(t);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
@@ -800,7 +816,8 @@ public static class AddressSpaceComposer
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength,
- ArrayWritable: arrayWritable));
+ ArrayWritable: arrayWritable,
+ ScalarRefusesArrayValue: scalarRefusesArray));
}
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -817,6 +834,32 @@ public static class AddressSpaceComposer
///
private static Func BuildArrayWriteCapability(
IReadOnlyList driverInstances, IReadOnlyList? 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);
+ };
+ }
+
+ ///
+ /// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
+ /// (), as a per-tag predicate resolved
+ /// the same way as . Shared by the raw and UNS passes so a mirror
+ /// refuses exactly when its raw node does.
+ ///
+ private static Func BuildScalarArrayRefusal(
+ IReadOnlyList driverInstances, IReadOnlyList? 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 BuildTagOwnerResolver(
+ IReadOnlyList driverInstances, IReadOnlyList? devices)
{
var driverById = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
@@ -828,8 +871,7 @@ public static class AddressSpaceComposer
{
var device = deviceById.GetValueOrDefault(tag.DeviceId);
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
- return DriverArrayWriteSupport.Supports(
- driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
+ return (driver, device);
};
}
@@ -847,7 +889,8 @@ public static class AddressSpaceComposer
IReadOnlyList unsLines,
IReadOnlyList equipment,
RawTopology topology,
- Func arrayWritableFor)
+ Func arrayWritableFor,
+ Func scalarRefusesArrayFor)
{
var refs = unsTagReferences ?? Array.Empty();
if (refs.Count == 0) return Array.Empty();
@@ -884,6 +927,7 @@ public static class AddressSpaceComposer
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is.
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
+ var backingScalarRefusesArray = scalarRefusesArrayFor(tag);
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,
@@ -897,7 +941,8 @@ public static class AddressSpaceComposer
BackingHistorianTagname: intent.HistorianTagname,
BackingIsArray: intent.IsArray,
BackingArrayLength: intent.ArrayLength,
- BackingArrayWritable: backingArrayWritable));
+ BackingArrayWritable: backingArrayWritable,
+ BackingScalarRefusesArrayValue: backingScalarRefusesArray));
}
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 2db42517..91cca9b0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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
/// .
private readonly ConcurrentDictionary _historizedTagnames = new();
+ /// Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
+ /// SCALAR tag (Modbus / S7 — DriverArrayWriteSupport.RefusesArrayValueOnScalarTag), 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 ; cleared alongside
+ /// .
+ private readonly ConcurrentDictionary _arrayOnScalarRefused = new();
/// Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual so 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
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// 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;
+ value = coerced!;
// 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`).
@@ -2025,18 +2038,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// BaseVariableState.WriteValueAttribute runs TypeInfo.IsInstanceOfDataType only when
/// the node has NO OnWriteValue handler; with one (every writable equipment node) it checks the
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
- /// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array
- /// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose
- /// (see ).
+ /// by shape and declared length, and its element type must either match the node's DataType or be
+ /// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
+ /// into the node's element type () — 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
+ /// says the owning driver cannot take one. A SCALAR write keeps
+ /// the looser numeric rule above on purpose (see ).
///
/// Pure (no SDK server / Lock needed): reads only and the node's static
/// DataType, so it is unit-testable in isolation.
///
/// The value the client wrote (the SDK's pre-coercion payload).
/// The target node (expected to be a writable ).
+ /// 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).
+ /// On a null return, the value to store and dispatch:
+ /// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
+ /// null when the write is refused.
/// null to proceed; otherwise the veto
/// (BadTypeMismatch for a null write or a confidently-detected built-in-type mismatch).
- 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;
+ }
+
+ /// The verdict half of
+ /// 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.
+ 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.
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
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
- // up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted. A
- // byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides
- // whether the node takes one). An unresolved-DataType node deferred above, as it always has.
- if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
+ // up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
+ // ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
+ // lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
+ // 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");
}
@@ -2091,22 +2135,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
- // Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK
- // runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every
- // writable equipment node has one, so nothing else stands between a mistyped array and the driver: a
- // UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote
- // uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16
- // DataType. This restores the SDK's own rule for arrays — the element type must match, with the single
- // Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and
- // an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were
- // never client-writable before #610, so no client relies on a looser rule.
+ // Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
+ // because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
+ // handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
+ // driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
+ // covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
+ // serving an int[] under an Int16 DataType.
+ // • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
+ // classifies as ByteString) proceeds unchanged.
+ // • 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)
{
- return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)
- ? null
- : new ServiceResult(
+ if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
+ if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
+ {
+ if (TryConvertNumericArray(source, expected, out var converted))
+ {
+ coerced = converted;
+ return null;
+ }
+ return new ServiceResult(
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
@@ -2166,6 +2229,76 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double;
+ ///
+ /// #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
+ /// 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.
+ ///
+ /// The 1-D numeric array the client wrote.
+ /// The node's numeric element type.
+ /// The converted array (element CLR type of ), or null.
+ /// True when every element converted exactly enough to be written.
+ 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;
+ }
+ }
+
/// The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
@@ -2510,7 +2643,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// — rank + dimensions carry the array-ness.
/// Phase 4c: the declared length of the 1-D array when
/// is true; ignored for scalars. Null ⇒ length 0.
- 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(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
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!;
+ if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
}
}
@@ -2984,6 +3118,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
+ _arrayOnScalarRefused.Clear();
foreach (var alarm in _alarmConditions.Values)
{
@@ -3059,6 +3194,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _);
+ _arrayOnScalarRefused.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm);
}
ReportModelChangeOutsideLock(e, variableNodeId);
@@ -3157,6 +3293,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
+ _arrayOnScalarRefused.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index ab2bd589..3d4dda2c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -58,8 +58,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, 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)
- => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
+ 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, refuseArrayOnScalar);
///
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index f9c3b292..33ed440a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -472,6 +472,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
///
private readonly string? _replicationPeerHost;
+ ///
+ /// Review R6 MINOR-4: identifies the alarm-queue-sharing peer when is
+ /// unknown — the passive (listening) half of a pair, which replicates the same rows but dials nobody. Before
+ /// this the passive half had no peer to defer to, so as Secondary it kept draining alongside its Primary
+ /// partner and every alarm event reached the historian twice. Null ⇒ no peer known ⇒ never stand down.
+ ///
+ private readonly Func? _sharesAlarmQueueWithLocal;
+
/// Debounces the S5 "snapshot omitted this node" Warning to once per process — a persistent
/// identity mismatch (03/S5) would otherwise log on every snapshot.
private bool _warnedSnapshotMissingLocalNode;
@@ -617,7 +625,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null,
- TimeSpan? commsRecoveryAuditDelay = null) =>
+ TimeSpan? commsRecoveryAuditDelay = null,
+ Func? sharesAlarmQueueWithLocal = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -629,7 +638,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
- sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay));
+ sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
+ sharesAlarmQueueWithLocal));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -691,6 +701,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched BadNoCommunication. Defaults to
/// ; a test shortens it for the same reason.
+ /// Review R6 MINOR-4: tests whether a node named in a redundancy
+ /// snapshot holds this node's replicated alarm queue, for a node that knows no
+ /// — the PASSIVE half of a LocalDb pair (ApiKey + SyncListenPort, no
+ /// PeerAddress), whose partner is its per-cluster mesh partner. Ignored when
+ /// is set. Null ⇒ no such peer is known and this node never stands its
+ /// drain down.
public DriverHostActor(
IDbContextFactory? dbFactory,
CommonsNodeId localNode,
@@ -720,8 +736,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null,
- TimeSpan? commsRecoveryAuditDelay = null)
+ TimeSpan? commsRecoveryAuditDelay = null,
+ Func? sharesAlarmQueueWithLocal = null)
{
+ _sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay;
@@ -802,6 +820,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// membership changes (a driver peer coming Up turns an unknown role from allow into deny before any
/// snapshot names this node). A non-cluster ActorRefProvider (legacy/test harnesses) just keeps the
/// initial publish.
+ ///
+ /// ⚠️ It MIRRORS the live gate; it does not deny from host start. The host is spawned at
+ /// ActorSystem start, before this node has joined, when its view of Cluster.State.Members is
+ /// empty — a member count of 0, which the single-node posture ALLOWS. So the verdict published here is
+ /// allow, and it flips to deny only once this node's own cluster state shows a second Up driver member:
+ /// normally seconds, up to Cluster:BootstrapGuard:PartnerProbeSeconds (25 s) under the
+ /// bootstrap guard. In that pre-join window the Sparkplug primary-host claim can be held on both nodes of
+ /// a pair. Native ack is NOT exposed by it: the live route gate reads the same count and also allows, so
+ /// the view and the route still agree and the acked → unacked revert cannot occur (review R6 MINOR-2).
+ /// Denying until self is Up was considered and NOT done: it would change the live data-plane gate (the
+ /// two must stay one decision) and so delay writes and acks on a genuinely single-node cluster during its
+ /// own join, and this actor has no reliable way to know that a partner is configured.
+ ///
///
private void StartPrimaryVerdictPublishing()
{
@@ -2009,6 +2040,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
+ /// Whether holds this node's replicated alarm queue: the PeerAddress host when
+ /// this node dials, else the passive-half predicate (review R6 MINOR-4). This node itself never counts.
+ private bool SharesAlarmQueue(CommonsNodeId nodeId) =>
+ nodeId != _localNode
+ && (_replicationPeerHost is not null
+ ? HostOf(nodeId) == _replicationPeerHost
+ : _sharesAlarmQueueWithLocal?.Invoke(nodeId) == true);
+
/// Host part of a host:port node id.
private static string HostOf(NodeId nodeId)
{
@@ -2058,10 +2097,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Defer ONLY to the node that holds a copy of our rows. The redundancy election is
// cluster-wide and the queue is pair-local, so "somebody is Primary" is not a licence to stop
// draining — that Primary is usually in another pair entirely, and cannot deliver our events.
- var peerIsPrimary =
- _replicationPeerHost is not null
- && msg.Nodes.Any(n => n.Role == RedundancyRole.Primary
- && HostOf(n.NodeId) == _replicationPeerHost);
+ //
+ // The dialling half knows its peer by PeerAddress host. The passive half (review R6 MINOR-4) knows it
+ // only as its mesh partner — the predicate — and must be gated too, or both halves drain the same rows.
+ var peerIsPrimary = msg.Nodes.Any(n => n.Role == RedundancyRole.Primary && SharesAlarmQueue(n.NodeId));
_redundancyRoleView?.Publish(
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index c50deff9..516be128 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -279,6 +279,33 @@ public static class ServiceCollectionExtensions
return services;
}
+ ///
+ /// Review R6 MINOR-4 — "does this snapshot node hold my replicated alarm queue?" for a node that does not
+ /// dial its peer. Both halves of a LocalDb pair hold the same rows, but only the dialling half knows its
+ /// partner (LocalDb:Replication:PeerAddress); the passive half (ApiKey + SyncListenPort)
+ /// knew nobody, so as Secondary it never stood its drain down and both nodes delivered every alarm event.
+ /// Its partner is its per-cluster mesh partner, which a cluster-scoped role identifies.
+ ///
+ /// The host configuration (the LocalDb replication keys).
+ /// This node's cluster role info.
+ /// The dialled peer's host, when this node dials one.
+ ///
+ /// null when the node dials its peer (the host match covers it), when it shares its queue with no
+ /// peer ( false — it must never stand down), or
+ /// when it carries no cluster-scoped role (a legacy node cannot prove which node is its partner, and
+ /// standing down for the wrong Primary would strand its rows); otherwise "is a member of my own mesh other
+ /// than me".
+ ///
+ internal static Func? BuildAlarmQueuePeerPredicate(
+ IConfiguration? configuration, IClusterRoleInfo? roleInfo, string? replicationPeerHost)
+ {
+ if (replicationPeerHost is not null || configuration is null || roleInfo is null) return null;
+ if (!LocalDbReplicationPolicy.SharesRowsWithPeer(configuration)) return null;
+ if (roleInfo.ClusterRole is not { } meshRole) return null;
+ var local = roleInfo.LocalNode;
+ return nodeId => nodeId != local && roleInfo.MembersWithRole(meshRole).Contains(nodeId);
+ }
+
///
/// Spawns the per-node driver-role actors on the host's :
/// (one per node),
@@ -361,7 +388,8 @@ public static class ServiceCollectionExtensions
var redundancyRoleView = resolver.GetService();
// Host of this node's LocalDb replication partner: the ONLY node that holds a copy of this
// node's alarm queue, and so the only node it may stand down in favour of. Null when this
- // node dials nobody, which correctly means "never stand down".
+ // node dials nobody — unpaired (never stand down), or the PASSIVE half of a pair, which is
+ // told its partner through BuildAlarmQueuePeerPredicate instead (review R6 MINOR-4).
var replicationPeerHost =
Uri.TryCreate(
resolver.GetService()?["LocalDb:Replication:PeerAddress"],
@@ -592,7 +620,11 @@ public static class ServiceCollectionExtensions
// Gitea #537: where this actor publishes the continuous-historization CAPTURE
// verdict, and how it decides which peer is worth standing down for.
historizationRoleView: resolver.GetService(),
- sharesHistorizationScopeWithLocal: historizationScopePeer),
+ sharesHistorizationScopeWithLocal: historizationScopePeer,
+ // Review R6 MINOR-4: the passive half of a LocalDb pair has no PeerAddress, so it is told
+ // its queue-sharing peer as its mesh partner instead — otherwise it never stands down.
+ sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
+ resolver.GetService(), roleInfo, replicationPeerHost)),
DriverHostActorName);
registry.Register(driverHost);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/AddressSpaceSinkBatchTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/AddressSpaceSinkBatchTests.cs
index ec1b5a5a..316ee97c 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/AddressSpaceSinkBatchTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/AddressSpaceSinkBatchTests.cs
@@ -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 WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
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 WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
index ac9e11c0..36a0ed58 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
@@ -214,7 +214,7 @@ public class DeferredAddressSpaceSinkTests
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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList 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 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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs
index e84550d7..2da30f96 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverArrayWriteSupportTests.cs
@@ -156,4 +156,19 @@ public sealed class DriverArrayWriteSupportTests
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "");
}
}
+
+ /// #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.
+ [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 ?? "");
+ }
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs
index 4c11c62f..3d8f0168 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ArrayWriteWireTests.cs
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
+ private const string UaScalarRaw = "Plant/Ua1/up/Vector";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
};
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
{
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 composition = AddressSpaceComposer.Compose(
- [area], [line], [equip], [s7, ab, gx], Array.Empty(),
- unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
- rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
+ [area], [line], [equip], [s7, ab, gx, ua], Array.Empty(),
+ unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
+ rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
var applier = new AddressSpaceApplier(rig.Sink, NullLogger.Instance);
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(GalaxyArrayRaw, new[] { 11, 12 }, 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;
}
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
///
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an OnWriteValue handler,
- /// so the node manager must refuse a mistyped array before dispatch: an Int32[] to an S7 Int16 array
- /// (else the node serves an int[] under an Int16 DataType), and a UInt32[] to a Galaxy Int32 array
- /// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good).
+ /// so the node manager must refuse an array its node type cannot represent before dispatch: an Int32[]
+ /// element outside Int16 to an S7 Int16 array, and a UInt32[] element above Int32.MaxValue to a Galaxy
+ /// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
+ /// -294,967,296, Good).
///
[Fact]
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 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 ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
((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 });
}
- /// #610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.
+ ///
+ /// #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 short[], and the driver receives short[].
+ ///
+ [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();
+ dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
+ }
+ }
+
+ ///
+ /// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
+ /// from /raw without isArray) 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.
+ ///
+ [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);
+ }
+
+ /// #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.
[Fact]
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
index d4b97578..08076fb1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
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");
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
index 10577cac..ca0f2a10 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
@@ -350,7 +350,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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) { }
/// Rebuilds the address space (stub implementation for testing).
public void RebuildAddressSpace() { }
/// Announces a NodeAdded model-change (stub implementation for testing).
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
index ce8b3789..036a642d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
@@ -486,7 +486,7 @@ public sealed class AddressSpaceApplierRawUnsTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm 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));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
index c7ae412a..f3e61370 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
@@ -2033,7 +2033,7 @@ public sealed class AddressSpaceApplierTests
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2077,7 +2077,7 @@ public sealed class AddressSpaceApplierTests
/// No-op folder creation call.
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// No-op variable creation call.
- 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) { }
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// No-op NodeAdded model-change announcement.
@@ -2128,7 +2128,7 @@ public sealed class AddressSpaceApplierTests
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
/// No-op NodeAdded model-change announcement.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs
index ec68f8d6..0698f2e8 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs
@@ -306,6 +306,14 @@ public sealed class AddressSpaceComposerDualNamespaceTests
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 == "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();
}
/// #610 review MINOR-1 — the composer hands the table the owning driver's DriverConfig AND the
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index 9a119938..7e70b9fd 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -84,6 +84,22 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV");
}
+ /// #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).
+ [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) });
+ }
+
/// F10b regression: the deferred wrapper MUST forward the surgical capability to a
/// surgical inner sink — otherwise AddressSpaceApplier (which injects THIS wrapper on every
/// driver-role host, not the inner SdkAddressSpaceSink) never sees the capability and the
@@ -226,6 +242,10 @@ public sealed class DeferredAddressSpaceSinkTests
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
+ /// Gets the (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.
+ public ConcurrentQueue<(string NodeId, bool Refuse)> RefuseArrayOnScalarQueue { get; } = new();
+ /// Gets the list of (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.
+ public List<(string NodeId, bool Refuse)> RefuseArrayOnScalarCalls => RefuseArrayOnScalarQueue.ToList();
///
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)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
///
- 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}");
HistorianQueue.Enqueue((variableNodeId, historianTagname));
+ RefuseArrayOnScalarQueue.Enqueue((variableNodeId, refuseArrayOnScalar));
}
///
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
@@ -297,7 +318,7 @@ public sealed class DeferredAddressSpaceSinkTests
///
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() { }
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs
index 73c07f11..b201055b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EquipmentWriteGateTests.cs
@@ -214,9 +214,10 @@ public sealed class EquipmentWriteGateTests
}
/// #610 review MAJOR-1 — the SDK skips its own type check when OnWriteValue is set, so an array
- /// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a
- /// uint[] to an Int32 node would reach MxValueEncoder's int[] arm by covariance and reinterpret
- /// its bits). The matching element type is the positive control.
+ /// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric
+ /// element the node's type cannot represent exactly (a uint[] element above Int32.MaxValue would reach
+ /// MxValueEncoder's int[] arm by covariance and reinterpret its bits) is refused. The matching element
+ /// type is the positive control.
[Theory]
[MemberData(nameof(MistypedArrays))]
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 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.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy)
- { DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float
+ { DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy)
+ { 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.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
};
+ /// #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.
+ [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 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 } },
+ };
+
+ /// An exact element-type match is passed through unconverted (the same instance).
+ [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);
+ }
+
+ /// A refused write hands back no value to store.
+ [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();
+ }
+
/// A Byte array decodes as byte[], which classifies as ByteString — the one equivalence the SDK
- /// itself honours for array nodes.
+ /// 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.
[Fact]
public void Structure_byte_array_node_takes_a_byte_array()
{
var node = ArrayNode(DataTypeIds.Byte, length: 2);
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);
}
- /// #610 review MINOR-5 — the mirror case: an array value to a scalar node 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.
+ /// #610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy:
+ /// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is
+ /// dispatched and the driver decides. The Modbus/S7 refusal is the control.
+ [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);
+ }
+
+ /// #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.
[Fact]
public void Structure_scalar_node_refuses_an_array_value()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryRoleSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryRoleSourceTests.cs
index b846dea9..6043f7dd 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryRoleSourceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryRoleSourceTests.cs
@@ -66,6 +66,31 @@ public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
}
+ ///
+ /// Review R6 MINOR-3 — the same flip driven by a real cluster MEMBER EVENT rather than a direct
+ /// tell, so it fails if the host's
+ /// Cluster.Subscribe(… IMemberEvent) or its IMemberEvent handler is deleted. The event is published
+ /// on the ActorSystem EventStream, which is exactly how the cluster's domain-event publisher delivers it to a
+ /// Cluster.Subscribe subscriber — and to nothing else. Re-published inside the wait because the
+ /// subscription is registered asynchronously by the publisher actor after PreStart.
+ ///
+ [Fact]
+ public void A_cluster_member_event_flips_the_published_verdict_without_a_direct_republish()
+ {
+ var source = new RecordingPrimaryRoleSource();
+ var members = 1;
+ SpawnHost(source, () => Volatile.Read(ref members));
+ AwaitAssert(() => source.Published.ShouldBe([true]), duration: Timeout);
+
+ Volatile.Write(ref members, 2);
+ var memberUp = new Akka.Cluster.ClusterEvent.MemberUp(Akka.Cluster.Cluster.Get(Sys).SelfMember);
+ AwaitAssert(() =>
+ {
+ Sys.EventStream.Publish(memberUp);
+ source.Published.ShouldBe([true, false]);
+ }, duration: Timeout, interval: TimeSpan.FromMilliseconds(100));
+ }
+
private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func driverMemberCount) =>
Sys.ActorOf(DriverHostActor.Props(
NewInMemoryDbFactory(),
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs
index 0e0ec0ce..92d376e9 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorRoleViewTests.cs
@@ -135,6 +135,43 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout);
}
+ ///
+ /// Review R6 MINOR-4 — the PASSIVE half of a pair dials nobody (no PeerAddress, so no peer host) but
+ /// holds the same replicated rows, so as Secondary it must stand down for its Primary partner too, which it
+ /// identifies through the queue-sharing predicate (its mesh partner). Before the fix it published
+ /// true here and both halves drained. A Primary the predicate does NOT name is the control: the
+ /// passive half keeps draining, so the deny comes from identifying the partner, not from being Secondary.
+ ///
+ [Fact]
+ public void A_passive_secondary_closes_the_view_for_its_mesh_partner_and_only_for_it()
+ {
+ var partner = NodeId.Parse("site-a-1:4053");
+ foreach (var (primary, expected) in new[] { (partner, false), (NodeId.Parse("site-b-1:4053"), true) })
+ {
+ var view = new RecordingRoleView();
+ var host = Sys.ActorOf(DriverHostActor.Props(
+ NewInMemoryDbFactory(),
+ TestNode,
+ ackRouter: null,
+ driverMemberCountProvider: () => 2,
+ redundancyRoleView: view,
+ replicationPeerHost: null,
+ sharesAlarmQueueWithLocal: n => n == partner));
+
+ host.Tell(new RedundancyStateChanged(
+ [
+ new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
+ IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow),
+ new NodeRedundancyState(primary, RedundancyRole.Primary,
+ IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow),
+ ],
+ CorrelationId.NewId()));
+
+ AwaitAssert(() => view.Published.ShouldBe([expected]), duration: Timeout);
+ Sys.Stop(host);
+ }
+ }
+
// ---------------- helpers ----------------
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmQueuePeerPredicateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmQueuePeerPredicateTests.cs
new file mode 100644
index 00000000..6f087e36
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmQueuePeerPredicateTests.cs
@@ -0,0 +1,83 @@
+using Microsoft.Extensions.Configuration;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
+
+///
+/// Review R6 MINOR-4 — how the PASSIVE half of a LocalDb pair (ApiKey + SyncListenPort, no
+/// PeerAddress) learns which node holds its replicated alarm queue: its per-cluster mesh partner.
+/// Before this it had no peer at all, so as Secondary it kept draining beside its Primary partner and the
+/// historian received every alarm event twice.
+///
+public sealed class AlarmQueuePeerPredicateTests
+{
+ private static readonly NodeId Self = NodeId.Parse("site-a-2:4053");
+ private static readonly NodeId Partner = NodeId.Parse("site-a-1:4053");
+ private static readonly NodeId OtherPair = NodeId.Parse("site-b-1:4053");
+
+ /// The passive half (key + listener) with a mesh role: its mesh partner is its queue peer, itself and
+ /// a node from another mesh are not.
+ [Fact]
+ public void The_passive_half_identifies_its_mesh_partner()
+ {
+ var predicate = ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
+ Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner), replicationPeerHost: null);
+
+ predicate.ShouldNotBeNull();
+ predicate(Partner).ShouldBeTrue();
+ predicate(Self).ShouldBeFalse();
+ predicate(OtherPair).ShouldBeFalse();
+ }
+
+ /// The dialling half is matched by its PeerAddress host instead — no predicate.
+ [Fact]
+ public void The_dialling_half_needs_no_predicate() =>
+ ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
+ Config(apiKey: "k", peerAddress: "http://site-a-2:4057"), new MeshRoleInfo("cluster-SITE-A", Self, Partner),
+ replicationPeerHost: "site-a-2").ShouldBeNull();
+
+ /// A node sharing its queue with nobody must never stand down, whatever its mesh says.
+ [Theory]
+ [InlineData(null, "4057")] // listener without the key — fail-closed, shares nothing
+ [InlineData("k", null)] // key without a path — shares nothing
+ [InlineData(null, null)] // unreplicated
+ public void A_node_that_shares_no_rows_gets_no_predicate(string? apiKey, string? syncListenPort) =>
+ ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
+ Config(apiKey: apiKey, syncListenPort: syncListenPort), new MeshRoleInfo("cluster-SITE-A", Self, Partner),
+ replicationPeerHost: null).ShouldBeNull();
+
+ /// A legacy node with no cluster-scoped role cannot prove which node is its partner; standing down for
+ /// the wrong Primary would strand its rows, so it gets none (and keeps draining).
+ [Fact]
+ public void A_node_without_a_mesh_role_gets_no_predicate() =>
+ ServiceCollectionExtensions.BuildAlarmQueuePeerPredicate(
+ Config(apiKey: "k", syncListenPort: "4057"), new MeshRoleInfo(null, Self, Partner), replicationPeerHost: null)
+ .ShouldBeNull();
+
+ private static IConfiguration Config(string? apiKey = null, string? peerAddress = null, string? syncListenPort = null) =>
+ new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["LocalDb:Replication:ApiKey"] = apiKey,
+ ["LocalDb:Replication:PeerAddress"] = peerAddress,
+ ["LocalDb:SyncListenPort"] = syncListenPort,
+ }).Build();
+
+ private sealed class MeshRoleInfo(string? clusterRole, NodeId self, NodeId partner) : IClusterRoleInfo
+ {
+ public NodeId LocalNode => self;
+ public IReadOnlySet LocalRoles { get; } = new HashSet(["driver"]);
+ public string? ClusterRole => clusterRole;
+ public string? ClusterId => clusterRole?["cluster-".Length..];
+ public bool HasRole(string role) => LocalRoles.Contains(role) || role == clusterRole;
+ public IReadOnlyList MembersWithRole(string role) => role == clusterRole ? [self, partner] : [];
+ public NodeId? RoleLeader(string role) => null;
+ public event EventHandler? RoleLeaderChanged
+ {
+ add { _ = value; }
+ remove { _ = value; }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
index a822db4a..c833bead 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
@@ -220,7 +220,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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) { }
/// Rebuilds address space (recorded via span).
public void RebuildAddressSpace() { /* recorded via span */ }
/// Announces a NodeAdded model-change (stub implementation).
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs
index f113ea06..8d1fd67e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs
@@ -138,7 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList 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 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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs
index 08052bb7..f6443f2e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs
@@ -172,7 +172,7 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
index 5851527b..94453d9a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
@@ -1044,7 +1044,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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))
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorStatusBatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorStatusBatchTests.cs
index 1174e5f9..c7690397 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorStatusBatchTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorStatusBatchTests.cs
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
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 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index 6f05839a..3da4d951 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -772,7 +772,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- 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));
/// Records a rebuild call.