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.