Merge review R6 fixes: numeric array writes converted element-wise (checked) to the node's element type, refusing out-of-range/fractional/NaN; array-to-scalar refusal scoped to Modbus/S7; Primary-verdict pre-join window documented + real MemberUp subscription test; passive LocalDb pair half drain-gated on its mesh partner

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:43:59 -04:00
37 changed files with 764 additions and 112 deletions
+11
View File
@@ -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 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 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). 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 - **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 fault would put a pair into dual delivery exactly when its redundancy signal is
sick. sick.
+5
View File
@@ -357,6 +357,11 @@ is client-visible.
> pair (role still unknown, a driver peer Up) the gate already denies, exactly > 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 > 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). > "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 > **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** > driver is `Connecting`/`Reconnecting`, it is dropped with a debug log and **not**
+2 -2
View File
@@ -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. 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: 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. - **`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. - **`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 - **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 one that gates the *opposite* half of its pipeline. Both pair nodes serve the same address space and see
+31 -12
View File
@@ -343,17 +343,34 @@ What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements - **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on (an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch. OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array, - **The element type must match the node's DataType, or be a numeric type that converts to it
a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the as written. A **numeric** array of another numeric element type is **converted, checked, element by
check the OPC UA SDK would normally make itself: it skips its own type check on any node with an element** into the node's element type, and the converted array is what the node stores and the
`OnWriteValue` handler, which every writable equipment node has, so the node manager makes it driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge) integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder (`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
converts with a range check, so an out-of-range scalar fails the device write and is reverted. SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline. handler, which every writable equipment node has, so the node manager makes it instead. Without it a
`UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was written bit-reinterpreted
(4,000,000,000 → −294,967,296) with a Good result — that write is still refused (4,000,000,000
overflows `Int32`), while an in-range `UInt32[]` converts. The conversion is required, not a
nicety: ScadaBridge writes the CLR list it decoded from a `List` attribute, and its element types are
only Boolean/Int32/Float/Double/String/DateTime, so an exact-match rule left every `Int16`/`UInt16`/
`Byte`/`UInt32`/`Int64`/`UInt64` array node (Modbus's default `Int16` included) and a `Double` list to
a `Float` array unwritable from it. **Scalar** writes keep their looser rule — any numeric to any
numeric node, unconverted — because every driver's scalar encoder converts with a range check, so an
out-of-range scalar fails the device write and is reverted.
- An **array value to a scalar node** is refused `BadTypeMismatch` inline **only on Modbus and S7**,
whose scalar encoders cannot take one (`DriverArrayWriteSupport.RefusesArrayValueOnScalarTag`). Every
other driver keeps the pre-#610 behaviour — the write is dispatched and the driver decides. That
matters for browse-committed tags: the `/raw` commit sets the DataType but never `isArray`, so an
upstream array variable (OpcUaClient) or a Galaxy array attribute committed from the browser is a
**scalar** node into which the driver publishes an array, and writing that array back still works
(OpcUaClient forwards it to the upstream server, which arbitrates the shape; Galaxy encodes it). Set
`isArray` + `arrayLength` on such a tag to get the array-node checks above.
- A partial **IndexRange** write is - A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server `BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it. does not hold the device array authoritatively enough to read-modify-write it.
@@ -362,7 +379,9 @@ What a client sees on a writable array node:
write-outcome self-correction, unchanged). write-outcome self-correction, unchanged).
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing - Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. A
change to the array-on-scalar refusal (`ScalarRefusesArrayValue` — only a driver-type edit can flip
it) is deliberately NOT whitelisted and rebuilds.
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every > ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after > array node was read-only regardless of its authored `AccessLevel`. On the first boot after
+1 -1
View File
@@ -116,4 +116,4 @@ The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`
- **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason. - **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason.
- **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag. - **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag.
- **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write. - **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node exactly (`BadTypeMismatch` inline otherwise), and `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note. - **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (an `Int32` list to an `Int64` array arrives as `long[]`), and any unrepresentable element refuses the write `BadTypeMismatch` inline. An array written to a **scalar** Galaxy node (a browse-committed array attribute without `isArray`) is still dispatched, as before #610. Separately, `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
+8 -3
View File
@@ -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` | | Role unknown, no driver peer (single node) | open | `ONLINE` |
The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource` The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
(`Core.Abstractions`), published by `DriverHostActor` on every redundancy snapshot and **sampled on a (`Core.Abstractions`), published by `DriverHostActor` at host start, on every cluster membership change and on
1-second timer** rather than evented — a process-singleton raising events at per-instance drivers every redundancy snapshot, and **sampled on a 1-second timer** rather than evented — a process-singleton
outlives every driver it holds a reference to. 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 > **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 > and the new one connects and republishes. That is a genuine state transition — for that window there
@@ -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 > 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 > 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 > 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 > - 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. > file** are seeded into the row-version ledger at the LWW floor (HLC `0`) and actually travel.
@@ -63,8 +63,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
@@ -172,7 +172,12 @@ public interface IOpcUaAddressSpaceSink
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live /// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing /// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param> /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); /// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
/// <summary> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -273,7 +278,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
@@ -124,6 +124,26 @@ public static class DriverArrayWriteSupport
} }
} }
/// <summary>
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
/// reverting it when the scalar encoder throws.
/// <para>
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
/// </para>
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver // The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
// default (123). The key is matched case-insensitively, like the driver's DTO binding. // default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
@@ -54,8 +54,8 @@ internal static class MxValueEncoder
default: default:
throw new ArgumentException( throw new ArgumentException(
$"Cannot encode value of type {value.GetType()} as MxValue. Supported: " + $"Cannot encode value of type {value.GetType()} as MxValue. Supported: " +
"bool, int / long (and their unsigned variants), float, double, string, DateTime, " + "bool, int / long (and, as scalars only, their unsigned variants), float, double, string, " +
"and their 1-D array variants.", "DateTime, and the 1-D arrays of the signed / non-integer types (no uint[] / ulong[]).",
nameof(value)); nameof(value));
} }
} }
@@ -485,7 +485,10 @@ public sealed class AddressSpaceApplier
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) => private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) && string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) && string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal); string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal) &&
// #610 review R6: the array-on-scalar refusal is fixed at node creation (UpdateTagAttributes does not
// carry it), so a change to it recreates the mirror rather than updating it in place.
d.Previous.BackingScalarRefusesArrayValue == d.Current.BackingScalarRefusesArrayValue;
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary> /// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns> /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
@@ -993,7 +996,7 @@ public sealed class AddressSpaceApplier
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname) ? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null; : null;
var writable = RawNodeWritable(t); var writable = RawNodeWritable(t);
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++; if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength, t.ScalarRefusesArrayValue)) failed++;
} }
} }
@@ -1050,7 +1053,7 @@ public sealed class AddressSpaceApplier
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname) ? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
: null; : null;
var writable = UnsReferenceNodeWritable(v); var writable = UnsReferenceNodeWritable(v);
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength)) if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength, v.BackingScalarRefusesArrayValue))
{ {
failed++; failed++;
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway) continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
@@ -1276,9 +1279,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).</summary> /// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns> /// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; } try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
} }
@@ -143,6 +143,13 @@ public sealed record RawContainerNode(
/// <see langword="false"/> for a scalar. The materialised node is writable iff /// <see langword="false"/> for a scalar. The materialised node is writable iff
/// <c>Writable &amp;&amp; (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array /// <c>Writable &amp;&amp; (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
/// encoder stays read-only exactly as before #610. /// encoder stays read-only exactly as before #610.
/// <see cref="ScalarRefusesArrayValue"/> (#610 review R6) is true when the owning driver's SCALAR write path
/// cannot take an array value
/// (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>);
/// the node manager refuses an array write to the node inline while it is scalar-shaped. It is a fact about
/// the DRIVER, deliberately not gated on <see cref="IsArray"/>, so a surgical scalar↔array edit does not
/// flip it. It is not in the classifier's surgical whitelist, so a change to it (a driver's type edited in
/// place) rebuilds.
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close /// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom /// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added /// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
@@ -170,7 +177,8 @@ public sealed record RawTagPlan(
bool IsArray = false, bool IsArray = false,
uint? ArrayLength = null, uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw, AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false) bool ArrayWritable = false,
bool ScalarRefusesArrayValue = false)
{ {
/// <inheritdoc /> /// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -193,6 +201,7 @@ public sealed record RawTagPlan(
ArrayLength == other.ArrayLength && ArrayLength == other.ArrayLength &&
Realm == other.Realm && Realm == other.Realm &&
ArrayWritable == other.ArrayWritable && ArrayWritable == other.ArrayWritable &&
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
@@ -202,7 +211,7 @@ public sealed record RawTagPlan(
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode(); return hash.ToHashCode();
} }
@@ -236,7 +245,10 @@ public sealed record RawTagPlan(
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are /// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing /// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node /// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
/// is).</para> /// is), and <see cref="BackingScalarRefusesArrayValue"/> (#610 review R6 — the backing tag's
/// <see cref="RawTagPlan.ScalarRefusesArrayValue"/>, so a mirror refuses an array write exactly when its raw
/// node does; a change to it is treated as an identity change by the applier, so the mirror is recreated
/// rather than updated in place).</para>
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a /// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four /// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If /// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
@@ -256,7 +268,8 @@ public sealed record UnsReferenceVariable(
bool BackingIsArray = false, bool BackingIsArray = false,
uint? BackingArrayLength = null, uint? BackingArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns, AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
bool BackingArrayWritable = false); bool BackingArrayWritable = false,
bool BackingScalarRefusesArrayValue = false);
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -669,7 +682,8 @@ public static class AddressSpaceComposer
unsAreas, unsLines, equipment, topology); unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables( var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
BuildArrayWriteCapability(driverInstances, devices)); BuildArrayWriteCapability(driverInstances, devices),
BuildScalarArrayRefusal(driverInstances, devices));
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms) return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{ {
@@ -769,6 +783,7 @@ public static class AddressSpaceComposer
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism. // native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices); var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices);
var rawTags = new List<RawTagPlan>(); var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>()) foreach (var t in tags ?? Array.Empty<Tag>())
@@ -781,6 +796,7 @@ public static class AddressSpaceComposer
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty); var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig); var intent = TagConfigIntent.Parse(t.TagConfig);
var arrayWritable = intent.IsArray && arrayWritableFor(t); var arrayWritable = intent.IsArray && arrayWritableFor(t);
var scalarRefusesArray = scalarRefusesArrayFor(t);
var alarm = intent.Alarm is { } a var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null; : null;
@@ -800,7 +816,8 @@ public static class AddressSpaceComposer
HistorianTagname: intent.HistorianTagname, HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray, IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength, ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable)); ArrayWritable: arrayWritable,
ScalarRefusesArrayValue: scalarRefusesArray));
} }
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -817,6 +834,32 @@ public static class AddressSpaceComposer
/// </summary> /// </summary>
private static Func<Tag, bool> BuildArrayWriteCapability( private static Func<Tag, bool> BuildArrayWriteCapability(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices) IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{
var resolve = BuildTagOwnerResolver(driverInstances, devices);
return tag =>
{
var (driver, device) = resolve(tag);
return DriverArrayWriteSupport.Supports(
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
};
}
/// <summary>
/// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
/// (<see cref="DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>), as a per-tag predicate resolved
/// the same way as <see cref="BuildArrayWriteCapability"/>. Shared by the raw and UNS passes so a mirror
/// refuses exactly when its raw node does.
/// </summary>
private static Func<Tag, bool> BuildScalarArrayRefusal(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{
var resolve = BuildTagOwnerResolver(driverInstances, devices);
return tag => DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(resolve(tag).Driver?.DriverType);
}
// Tag → (owning driver, owning device); first row wins on a duplicate id, matching the composer's other id maps.
private static Func<Tag, (DriverInstance? Driver, Device? Device)> BuildTagOwnerResolver(
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
{ {
var driverById = driverInstances var driverById = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
@@ -828,8 +871,7 @@ public static class AddressSpaceComposer
{ {
var device = deviceById.GetValueOrDefault(tag.DeviceId); var device = deviceById.GetValueOrDefault(tag.DeviceId);
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId); var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
return DriverArrayWriteSupport.Supports( return (driver, device);
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
}; };
} }
@@ -847,7 +889,8 @@ public static class AddressSpaceComposer
IReadOnlyList<UnsLine> unsLines, IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment, IReadOnlyList<Equipment> equipment,
RawTopology topology, RawTopology topology,
Func<Tag, bool> arrayWritableFor) Func<Tag, bool> arrayWritableFor,
Func<Tag, bool> scalarRefusesArrayFor)
{ {
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>(); var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>(); if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
@@ -884,6 +927,7 @@ public static class AddressSpaceComposer
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable // Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
// exactly when its raw node is. // exactly when its raw node is.
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag); var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
var backingScalarRefusesArray = scalarRefusesArrayFor(tag);
vars.Add(new UnsReferenceVariable( vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId, UnsTagReferenceId: r.UnsTagReferenceId,
@@ -897,7 +941,8 @@ public static class AddressSpaceComposer
BackingHistorianTagname: intent.HistorianTagname, BackingHistorianTagname: intent.HistorianTagname,
BackingIsArray: intent.IsArray, BackingIsArray: intent.IsArray,
BackingArrayLength: intent.ArrayLength, BackingArrayLength: intent.ArrayLength,
BackingArrayWritable: backingArrayWritable)); BackingArrayWritable: backingArrayWritable,
BackingScalarRefusesArrayValue: backingScalarRefusesArray));
} }
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua; using Opc.Ua;
@@ -69,6 +70,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on /// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary> /// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new(); private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
/// <summary>Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
/// SCALAR tag (Modbus / S7 — <c>DriverArrayWriteSupport.RefusesArrayValueOnScalarTag</c>), so an array write to
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
/// write is dispatched and the driver decides). Set by <see cref="EnsureVariable"/>; cleared alongside
/// <see cref="_variables"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, byte> _arrayOnScalarRefused = new();
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers, /// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard). /// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can /// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
@@ -1921,8 +1928,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless // synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised // device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below. // caller) but BEFORE the optimistic dispatch below.
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node); // A numeric array of another element type is converted (checked) to the node's element type here and
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure; if (structure is not null) return structure;
value = coerced!;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`). // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
@@ -2025,18 +2038,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when /// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the /// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched /// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
/// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array /// by shape and declared length, and its element type must either match the node's DataType or be
/// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose /// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
/// (see <see cref="IsBuiltInTypeCompatible"/>). /// into the node's element type (<see cref="TryConvertNumericArray"/>) — refused whole if any element is
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
/// mismatch is refused. An array value to a scalar node is refused only when
/// <paramref name="refuseArrayOnScalar"/> says the owning driver cannot take one. A SCALAR write keeps
/// the looser numeric rule above on purpose (see <see cref="IsBuiltInTypeCompatible"/>).
/// </para> /// </para>
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static /// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
/// <c>DataType</c>, so it is unit-testable in isolation. /// <c>DataType</c>, so it is unit-testable in isolation.
/// </summary> /// </summary>
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param> /// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param> /// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
/// <param name="refuseArrayOnScalar">True when the node's owning driver cannot write an array value to a
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).</param>
/// <param name="coerced">On a <c>null</c> return, the value to store and dispatch: <paramref name="value"/>
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
/// <c>null</c> when the write is refused.</param>
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/> /// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns> /// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) internal static ServiceResult? EvaluateEquipmentWriteStructure(
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
{
coerced = null;
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
if (result is not null) coerced = null;
else coerced ??= value;
return result;
}
/// <summary>The verdict half of <see cref="EvaluateEquipmentWriteStructure(object?, NodeState, bool, out object?)"/>
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
/// Production goes through the four-argument overload, which also returns the converted value.</summary>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
{ {
// Minimum sensible check: a null payload is never a valid value write to a value variable. // Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null) if (value is null)
@@ -2073,10 +2113,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused // Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted. A // up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
// byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides // ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
// whether the node takes one). An unresolved-DataType node deferred above, as it always has. // lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[]) // writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. A byte[] is exempt: on
// the wire it is a ByteString, which is a scalar (the type check below still decides whether the node
// takes one). An unresolved-DataType node deferred above, as it always has.
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
{ {
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value"); return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
} }
@@ -2091,20 +2135,39 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// unclassifiable value ⇒ Unknown (Null). // unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType; var actual = TypeInfo.Construct(value).BuiltInType;
// Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK // Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
// runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every // because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
// writable equipment node has one, so nothing else stands between a mistyped array and the driver: a // handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
// UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote // driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
// uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16 // covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
// DataType. This restores the SDK's own rule for arrays — the element type must match, with the single // serving an int[] under an Int16 DataType.
// Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and // • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
// an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were // classifies as ByteString) proceeds unchanged.
// never client-writable before #610, so no client relies on a looser rule. // • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
// refused rather than deferred.
if (isArrayNode) if (isArrayNode)
{ {
return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString) if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
? null if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
: new ServiceResult( {
if (TryConvertNumericArray(source, expected, out var converted))
{
coerced = converted;
return null;
}
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"array element of type {actual} is not exactly representable as node data type {expected}");
}
return new ServiceResult(
StatusCodes.BadTypeMismatch, StatusCodes.BadTypeMismatch,
$"array element type {actual} does not match node data type {expected}"); $"array element type {actual} does not match node data type {expected}");
} }
@@ -2166,6 +2229,76 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double; BuiltInType.Float or BuiltInType.Double;
/// <summary>
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
/// for an integer target, or is finite yet overflows to ±Infinity for a <see cref="BuiltInType.Float"/>
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
/// accepted, as the scalar path's drivers accept it. Pure + static.
/// </summary>
/// <param name="source">The 1-D numeric array the client wrote.</param>
/// <param name="target">The node's numeric element type.</param>
/// <param name="converted">The converted array (element CLR type of <paramref name="target"/>), or null.</param>
/// <returns>True when every element converted exactly enough to be written.</returns>
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
{
converted = null;
var elementType = target switch
{
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
_ => null,
};
if (elementType is null) return false;
var result = Array.CreateInstance(elementType, source.Length);
for (var i = 0; i < source.Length; i++)
{
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
result.SetValue(element, i);
}
converted = result;
return true;
}
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
{
result = null;
if (element is null) return false;
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
if (element is float or double)
{
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
}
try
{
result = target switch
{
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
_ => null,
};
return result is not null;
}
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
{
return false;
}
}
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject /// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node /// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is /// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
@@ -2510,7 +2643,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param> /// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when /// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param> /// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName); ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -2564,6 +2697,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!; if (historized) _historizedTagnames[key] = historianTagname!;
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
} }
} }
@@ -2984,6 +3118,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_variables.Clear(); _variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear(); _historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
foreach (var alarm in _alarmConditions.Values) foreach (var alarm in _alarmConditions.Values)
{ {
@@ -3059,6 +3194,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(variable.NodeId); PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity). // Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _); _historizedTagnames.TryRemove(key, out _);
_arrayOnScalarRefused.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm); e = BuildNodesRemovedModelChange(variableNodeId, realm);
} }
ReportModelChangeOutsideLock(e, variableNodeId); ReportModelChangeOutsideLock(e, variableNodeId);
@@ -3157,6 +3293,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(v.NodeId); PredefinedNodes?.Remove(v.NodeId);
} }
_historizedTagnames.TryRemove(id, out _); _historizedTagnames.TryRemove(id, out _);
_arrayOnScalarRefused.TryRemove(id, out _);
} }
// Part 9 condition nodes (+ their native flags). // Part 9 condition nodes (+ their native flags).
@@ -58,8 +58,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
/// <inheritdoc /> /// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
@@ -472,6 +472,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary> /// </summary>
private readonly string? _replicationPeerHost; private readonly string? _replicationPeerHost;
/// <summary>
/// Review R6 MINOR-4: identifies the alarm-queue-sharing peer when <see cref="_replicationPeerHost"/> 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.
/// </summary>
private readonly Func<CommonsNodeId, bool>? _sharesAlarmQueueWithLocal;
/// <summary>Debounces the S5 "snapshot omitted this node" Warning to once per process — a persistent /// <summary>Debounces the S5 "snapshot omitted this node" Warning to once per process — a persistent
/// identity mismatch (03/S5) would otherwise log on every snapshot.</summary> /// identity mismatch (03/S5) would otherwise log on every snapshot.</summary>
private bool _warnedSnapshotMissingLocalNode; private bool _warnedSnapshotMissingLocalNode;
@@ -617,7 +625,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null, Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null, TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null, TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null) => TimeSpan? commsRecoveryAuditDelay = null,
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // 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 // 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 // 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, loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher, deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView, alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay)); sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
sharesAlarmQueueWithLocal));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary> /// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param> /// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -691,6 +701,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the /// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to /// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; a test shortens it for the same reason.</param> /// <see cref="DefaultCommsRecoveryAuditDelay"/>; a test shortens it for the same reason.</param>
/// <param name="sharesAlarmQueueWithLocal">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
/// <paramref name="replicationPeerHost"/> — the PASSIVE half of a LocalDb pair (ApiKey + SyncListenPort, no
/// PeerAddress), whose partner is its per-cluster mesh partner. Ignored when
/// <paramref name="replicationPeerHost"/> is set. Null ⇒ no such peer is known and this node never stands its
/// drain down.</param>
public DriverHostActor( public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
CommonsNodeId localNode, CommonsNodeId localNode,
@@ -720,8 +736,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null, Func<CommonsNodeId, bool>? sharesHistorizationScopeWithLocal = null,
TimeSpan? applyWedgeTimeout = null, TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null, TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null) TimeSpan? commsRecoveryAuditDelay = null,
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null)
{ {
_sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout; _applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff; _reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
_commsRecoveryAuditDelay = commsRecoveryAuditDelay ?? DefaultCommsRecoveryAuditDelay; _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 /// 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 /// snapshot names this node). A non-cluster ActorRefProvider (legacy/test harnesses) just keeps the
/// initial publish. /// initial publish.
/// <para>
/// ⚠️ <b>It MIRRORS the live gate; it does not deny from host start.</b> The host is spawned at
/// ActorSystem start, before this node has joined, when its view of <c>Cluster.State.Members</c> 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 <c>Cluster:BootstrapGuard:PartnerProbeSeconds</c> (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.
/// </para>
/// </summary> /// </summary>
private void StartPrimaryVerdictPublishing() private void StartPrimaryVerdictPublishing()
{ {
@@ -2009,6 +2040,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
} }
} }
/// <summary>Whether <paramref name="nodeId"/> 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.</summary>
private bool SharesAlarmQueue(CommonsNodeId nodeId) =>
nodeId != _localNode
&& (_replicationPeerHost is not null
? HostOf(nodeId) == _replicationPeerHost
: _sharesAlarmQueueWithLocal?.Invoke(nodeId) == true);
/// <summary>Host part of a <c>host:port</c> node id.</summary> /// <summary>Host part of a <c>host:port</c> node id.</summary>
private static string HostOf(NodeId nodeId) 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 // 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 // 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. // draining — that Primary is usually in another pair entirely, and cannot deliver our events.
var peerIsPrimary = //
_replicationPeerHost is not null // The dialling half knows its peer by PeerAddress host. The passive half (review R6 MINOR-4) knows it
&& msg.Nodes.Any(n => n.Role == RedundancyRole.Primary // only as its mesh partner — the predicate — and must be gated too, or both halves drain the same rows.
&& HostOf(n.NodeId) == _replicationPeerHost); var peerIsPrimary = msg.Nodes.Any(n => n.Role == RedundancyRole.Primary && SharesAlarmQueue(n.NodeId));
_redundancyRoleView?.Publish( _redundancyRoleView?.Publish(
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary)); PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
@@ -279,6 +279,33 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
/// <summary>
/// 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 (<c>LocalDb:Replication:PeerAddress</c>); the passive half (<c>ApiKey</c> + <c>SyncListenPort</c>)
/// 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.
/// </summary>
/// <param name="configuration">The host configuration (the LocalDb replication keys).</param>
/// <param name="roleInfo">This node's cluster role info.</param>
/// <param name="replicationPeerHost">The dialled peer's host, when this node dials one.</param>
/// <returns>
/// <c>null</c> when the node dials its peer (the host match covers it), when it shares its queue with no
/// peer (<see cref="LocalDbReplicationPolicy.SharesRowsWithPeer"/> 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".
/// </returns>
internal static Func<Commons.Types.NodeId, bool>? 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);
}
/// <summary> /// <summary>
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>: /// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/> /// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
@@ -361,7 +388,8 @@ public static class ServiceCollectionExtensions
var redundancyRoleView = resolver.GetService<IRedundancyRoleView>(); var redundancyRoleView = resolver.GetService<IRedundancyRoleView>();
// Host of this node's LocalDb replication partner: the ONLY node that holds a copy of this // 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'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 = var replicationPeerHost =
Uri.TryCreate( Uri.TryCreate(
resolver.GetService<IConfiguration>()?["LocalDb:Replication:PeerAddress"], resolver.GetService<IConfiguration>()?["LocalDb:Replication:PeerAddress"],
@@ -592,7 +620,11 @@ public static class ServiceCollectionExtensions
// Gitea #537: where this actor publishes the continuous-historization CAPTURE // Gitea #537: where this actor publishes the continuous-historization CAPTURE
// verdict, and how it decides which peer is worth standing down for. // verdict, and how it decides which peer is worth standing down for.
historizationRoleView: resolver.GetService<IHistorizationRoleView>(), historizationRoleView: resolver.GetService<IHistorizationRoleView>(),
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<IConfiguration>(), roleInfo, replicationPeerHost)),
DriverHostActorName); DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost); registry.Register<DriverHostActorKey>(driverHost);
@@ -81,7 +81,7 @@ public class AddressSpaceSinkBatchTests
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -104,7 +104,7 @@ public class AddressSpaceSinkBatchTests
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
@@ -214,7 +214,7 @@ public class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() => RebuildCalled = true; public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
@@ -232,7 +232,7 @@ public class DeferredAddressSpaceSinkTests
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -156,4 +156,19 @@ public sealed class DriverArrayWriteSupportTests
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>"); DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
} }
} }
/// <summary>#610 review R6 MINOR-1 — only Modbus and S7 have a scalar encoder that cannot take an array value;
/// every other driver (the pass-through OpcUaClient and Galaxy above all) keeps dispatching an array written to
/// a scalar-shaped tag, as before #610.</summary>
[Fact]
public void Only_modbus_and_s7_refuse_an_array_value_on_a_scalar_tag()
{
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.Modbus).ShouldBeTrue();
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.S7).ShouldBeTrue();
foreach (var driverType in DriverTypeNames.All
.Except([DriverTypeNames.Modbus, DriverTypeNames.S7]).Append("NoSuchDriver").Append(null))
{
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(driverType).ShouldBeFalse(driverType ?? "<null>");
}
}
} }
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
private const string S7ArrayUns = "filling/line-1/station-1/Levels"; private const string S7ArrayUns = "filling/line-1/station-1/Levels";
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint"; private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe"; private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{ {
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}", Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
}; };
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" }; var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
var ua = new DriverInstance
{
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
};
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
var vector = new Tag
{
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
};
var recipe = new Tag var recipe = new Tag
{ {
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32", TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
@@ -103,9 +118,9 @@ public sealed class ArrayWriteWireTests
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" }; var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
var composition = AddressSpaceComposer.Compose( var composition = AddressSpaceComposer.Compose(
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(), [area], [line], [equip], [s7, ab, gx, ua], Array.Empty<ScriptedAlarm>(),
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint], unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
rawFolders: [folder], devices: [plc1, plc2, gxDevice]); rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition).ShouldBe(0); applier.MaterialiseHierarchy(composition).ShouldBe(0);
@@ -118,6 +133,7 @@ public sealed class ArrayWriteWireTests
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
return rig; return rig;
} }
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
/// <summary> /// <summary>
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler, /// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
/// so the node manager must refuse a mistyped array before dispatch: an <c>Int32[]</c> to an S7 Int16 array /// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
/// (else the node serves an int[] under an Int16 DataType), and a <c>UInt32[]</c> to a Galaxy Int32 array /// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
/// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good). /// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
/// -294,967,296, Good).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver() public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
@@ -264,7 +281,7 @@ public sealed class ArrayWriteWireTests
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite); (await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
(await WriteAsync(session, s7, new[] { 1, 2, 3, 4 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "Int32[] to an Int16 array"); (await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array"); (await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 }); (await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 }); ((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
@@ -275,7 +292,53 @@ public sealed class ArrayWriteWireTests
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 }); lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
} }
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.</summary> /// <summary>
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
/// </summary>
[Fact]
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-convert", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var s7 = Raw(session, S7ArrayRaw);
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
lock (gateway.Writes)
{
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
}
}
/// <summary>
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
/// </summary>
[Fact]
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-to-ua-scalar", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
gateway.Count.ShouldBe(1);
}
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
/// one (S7) is refused before dispatch.</summary>
[Fact] [Fact]
public async Task An_array_value_to_a_scalar_node_is_refused_inline() public async Task An_array_value_to_a_scalar_node_is_refused_inline()
{ {
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
} }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
} }
@@ -350,7 +350,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary> /// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
@@ -486,7 +486,7 @@ public sealed class AddressSpaceApplierRawUnsTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm)); => Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength)); => Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
@@ -2033,7 +2033,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2077,7 +2077,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>No-op folder creation call.</summary> /// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary> /// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Records a rebuild address space call.</summary> /// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -2128,7 +2128,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>No-op rebuild address space call.</summary> /// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary> /// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -306,6 +306,14 @@ public sealed class AddressSpaceComposerDualNamespaceTests
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
// #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip
// keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror.
c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse();
} }
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the /// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
@@ -84,6 +84,22 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV"); call.HistorianTagname.ShouldBe("MyTag.PV");
} }
/// <summary>#610 review R6 — the array-on-scalar refusal flag reaches the inner sink. Actors inject THIS wrapper,
/// so a dropped flag would leave every Modbus/S7 scalar node dispatching array writes (the reflection guard
/// only proves the call arrives, not its arguments).</summary>
[Fact]
public void EnsureVariable_forwards_refuseArrayOnScalar_to_inner_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.EnsureVariable("v-1", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw, refuseArrayOnScalar: true);
deferred.EnsureVariable("v-2", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw);
inner.RefuseArrayOnScalarCalls.ShouldBe(new[] { ("v-1", true), ("v-2", false) });
}
/// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a /// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a
/// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every /// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every
/// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the /// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the
@@ -226,6 +242,10 @@ public sealed class DeferredAddressSpaceSinkTests
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new(); public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary> /// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <summary>Gets the (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public ConcurrentQueue<(string NodeId, bool Refuse)> RefuseArrayOnScalarQueue { get; } = new();
/// <summary>Gets the list of (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, bool Refuse)> RefuseArrayOnScalarCalls => RefuseArrayOnScalarQueue.ToList();
/// <inheritdoc /> /// <inheritdoc />
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
@@ -241,10 +261,11 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"EF:{folderNodeId}"); => CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
CallQueue.Enqueue($"EV:{variableNodeId}"); CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname)); HistorianQueue.Enqueue((variableNodeId, historianTagname));
RefuseArrayOnScalarQueue.Enqueue((variableNodeId, refuseArrayOnScalar));
} }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
@@ -297,7 +318,7 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <inheritdoc />
@@ -214,9 +214,10 @@ public sealed class EquipmentWriteGateTests
} }
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array /// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array
/// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a /// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric
/// <c>uint[]</c> to an Int32 node would reach MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret /// element the node's type cannot represent exactly (a <c>uint[]</c> element above Int32.MaxValue would reach
/// its bits). The matching element type is the positive control.</summary> /// MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret its bits) is refused. The matching element
/// type is the positive control.</summary>
[Theory] [Theory]
[MemberData(nameof(MistypedArrays))] [MemberData(nameof(MistypedArrays))]
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped) public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
@@ -229,28 +230,103 @@ public sealed class EquipmentWriteGateTests
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new() public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
{ {
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16 { DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 70_000 } }, // Int32[] → Int16, out of range
{ DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy) { DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy)
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy) { DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy)
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float { DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 1e300 } }, // Double[] → Float, overflows
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1.5d, 2d } }, // fractional → integer
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { double.NaN, 2d } }, // NaN → integer
{ DataTypeIds.Int64, new long[] { 1, 2 }, new[] { double.PositiveInfinity, 2d } },// ∞ → integer
{ DataTypeIds.UInt16, new ushort[] { 1, 2 }, new[] { -1, 2 } }, // negative → unsigned
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { true, false } }, // Boolean[] → Int32
{ DataTypeIds.String, new[] { "a", "b" }, new[] { 1, 2 } }, // Int32[] → String
{ DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean { DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused { DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
}; };
/// <summary>#610 review R6 MAJOR-1 — ScadaBridge writes the CLR list it decoded from a List attribute, whose
/// element type can only be Boolean/Int32/Float/Double/String/DateTime. A numeric array of another element type
/// is CONVERTED (checked) to the node's element type, and the converted array — not the client's — is what the
/// production overload hands back to be stored and dispatched.</summary>
[Theory]
[MemberData(nameof(ConvertibleArrays))]
public void Structure_numeric_array_of_another_element_type_is_converted_to_the_node_type(NodeId dataType, Array written, Array expected)
{
var node = ArrayNode(dataType, length: (uint)written.Length);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldNotBeNull().GetType().ShouldBe(expected.GetType());
coerced.ShouldBe(expected);
}
public static TheoryData<NodeId, Array, Array> ConvertibleArrays() => new()
{
{ DataTypeIds.Int16, new[] { 1, -2, 32_767 }, new short[] { 1, -2, 32_767 } }, // Int32 list → Modbus default
{ DataTypeIds.Float, new[] { 1.5d, -2.25d }, new[] { 1.5f, -2.25f } }, // Double list → Float array
{ DataTypeIds.Int64, new[] { 1, -2 }, new long[] { 1, -2 } }, // Int32 list → Galaxy Int64
{ DataTypeIds.UInt16, new[] { 0, 65_535 }, new ushort[] { 0, 65_535 } },
{ DataTypeIds.UInt32, new[] { 0, int.MaxValue }, new uint[] { 0, int.MaxValue } },
{ DataTypeIds.Byte, new[] { 0, 255 }, new byte[] { 0, 255 } },
{ DataTypeIds.Int16, new[] { 2.0d, -3.0d }, new short[] { 2, -3 } }, // integral doubles convert
{ DataTypeIds.Int32, new uint[] { 5, 2_147_483_647 }, new[] { 5, int.MaxValue } }, // in-range UInt32 → Int32
{ DataTypeIds.Double, new[] { 1f, 2.5f }, new[] { 1d, 2.5d } },
};
/// <summary>An exact element-type match is passed through unconverted (the same instance).</summary>
[Fact]
public void Structure_matching_array_is_passed_through_unchanged()
{
var written = new short[] { 1, 2 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, ArrayNode(DataTypeIds.Int16, 2), refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
}
/// <summary>A refused write hands back no value to store.</summary>
[Fact]
public void Structure_refused_array_returns_no_coerced_value()
{
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { 70_000 }, ArrayNode(DataTypeIds.Int16, 1), refuseArrayOnScalar: true, out var coerced)
.ShouldNotBeNull();
coerced.ShouldBeNull();
}
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK /// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
/// itself honours for array nodes.</summary> /// itself honours for array nodes. An SByte array is numeric, so it converts when every element fits (R6) and is
/// refused when one does not.</summary>
[Fact] [Fact]
public void Structure_byte_array_node_takes_a_byte_array() public void Structure_byte_array_node_takes_a_byte_array()
{ {
var node = ArrayNode(DataTypeIds.Byte, length: 2); var node = ArrayNode(DataTypeIds.Byte, length: 2);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull();
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node)!.StatusCode OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBe(new byte[] { 1, 2 });
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { -1, 2 }, node)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); .ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
} }
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node is refused up front. A /// <summary>#610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy:
/// byte[] (a ByteString on the wire) is a scalar and still reaches the type check; an unresolved-DataType node /// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is
/// defers as before.</summary> /// dispatched and the driver decides. The Modbus/S7 refusal is the control.</summary>
[Fact]
public void Structure_scalar_node_of_a_pass_through_driver_accepts_an_array_value()
{
var node = ValueNode(DataTypeIds.Int32);
var written = new[] { 1, 2, 3 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: false, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
// The element-type check still applies on the pass-through path: a String[] to an Int32 node is refused.
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { "a" }, node, refuseArrayOnScalar: false, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
}
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node of a driver that cannot take
/// one (Modbus / S7 — the two-argument overload's assumption) is refused up front. A byte[] (a ByteString on the
/// wire) is a scalar and still reaches the type check; an unresolved-DataType node defers as before.</summary>
[Fact] [Fact]
public void Structure_scalar_node_refuses_an_array_value() public void Structure_scalar_node_refuses_an_array_value()
{ {
@@ -66,6 +66,31 @@ public sealed class DriverHostActorPrimaryRoleSourceTests : RuntimeActorTestBase
AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout); AwaitAssert(() => source.Published.ShouldBe([true, false]), duration: Timeout);
} }
/// <summary>
/// Review R6 MINOR-3 — the same flip driven by a real cluster MEMBER EVENT rather than a direct
/// <see cref="DriverHostActor.RepublishPrimaryVerdict"/> tell, so it fails if the host's
/// <c>Cluster.Subscribe(… IMemberEvent)</c> or its <c>IMemberEvent</c> 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
/// <c>Cluster.Subscribe</c> subscriber — and to nothing else. Re-published inside the wait because the
/// subscription is registered asynchronously by the publisher actor after <c>PreStart</c>.
/// </summary>
[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<int> driverMemberCount) => private IActorRef SpawnHost(IDriverPrimaryRoleSource source, Func<int> driverMemberCount) =>
Sys.ActorOf(DriverHostActor.Props( Sys.ActorOf(DriverHostActor.Props(
NewInMemoryDbFactory(), NewInMemoryDbFactory(),
@@ -135,6 +135,43 @@ public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase
AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout); AwaitAssert(() => view.Published.ShouldBe([true, false]), duration: Timeout);
} }
/// <summary>
/// Review R6 MINOR-4 — the PASSIVE half of a pair dials nobody (no <c>PeerAddress</c>, 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
/// <c>true</c> 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.
/// </summary>
[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 ---------------- // ---------------- helpers ----------------
/// <summary> /// <summary>
@@ -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;
/// <summary>
/// Review R6 MINOR-4 — how the PASSIVE half of a LocalDb pair (<c>ApiKey</c> + <c>SyncListenPort</c>, no
/// <c>PeerAddress</c>) 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.
/// </summary>
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");
/// <summary>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.</summary>
[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();
}
/// <summary>The dialling half is matched by its PeerAddress host instead — no predicate.</summary>
[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();
/// <summary>A node sharing its queue with nobody must never stand down, whatever its mesh says.</summary>
[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();
/// <summary>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).</summary>
[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<string, string?>
{
["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<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
public string? ClusterRole => clusterRole;
public string? ClusterId => clusterRole?["cluster-".Length..];
public bool HasRole(string role) => LocalRoles.Contains(role) || role == clusterRole;
public IReadOnlyList<NodeId> MembersWithRole(string role) => role == clusterRole ? [self, partner] : [];
public NodeId? RoleLeader(string role) => null;
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
{
add { _ = value; }
remove { _ = value; }
}
}
}
@@ -220,7 +220,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds address space (recorded via span).</summary> /// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ } public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary> /// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
@@ -138,7 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -153,7 +153,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -172,7 +172,7 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -1044,7 +1044,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{ {
if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal)) if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal))
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}"); throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -772,7 +772,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param> /// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param> /// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param> /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) =>
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild call.</summary> /// <summary>Records a rebuild call.</summary>