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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 && (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
|
/// <c>Writable && (!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()
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+8
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+25
@@ -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>
|
||||||
|
|||||||
+83
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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}");
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user