Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4b378c5cd | |||
| e41ffe7655 | |||
| 51022c3952 | |||
| 21eb81c915 | |||
| b95efb0b28 | |||
| 1badc10445 | |||
| e6607ad5ab | |||
| b8208b3312 | |||
| e959423323 | |||
| 3cf3576c75 | |||
| 90e52a4415 | |||
| 1e9454582e | |||
| 8ebc712eff | |||
| 9d09523675 | |||
| 2e0743ad25 | |||
| 77c39bf02d | |||
| 3efcf8014b | |||
| 8b0b627f81 | |||
| e95615cef3 | |||
| 0dbdee003b | |||
| 4807aa7edd | |||
| 5534698d70 | |||
| f4f3e17e3e | |||
| a1a56e22bb | |||
| f8f3b82ed6 |
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
|
|||||||
gRPC session. The gateway owns the COM apartment + STA pump
|
gRPC session. The gateway owns the COM apartment + STA pump
|
||||||
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
||||||
exclusively.
|
exclusively.
|
||||||
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
|
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
|
||||||
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
|
(see below). Galaxy tags are bound by `TagConfig.FullName`
|
||||||
reads/writes/subscriptions are translated to that reference for MXAccess.
|
(`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
|
||||||
|
reference for MXAccess.
|
||||||
|
|
||||||
|
### v3 OPC UA Address Space (Batch 4): dual namespace
|
||||||
|
|
||||||
|
The server exposes **two OPC UA namespaces** (replacing the single
|
||||||
|
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
|
||||||
|
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
|
||||||
|
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
|
||||||
|
|
||||||
|
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||||
|
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||||
|
|
||||||
|
- **Single source, fanned to both.** Every device value has exactly one source —
|
||||||
|
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
|
||||||
|
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
|
||||||
|
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
|
||||||
|
to the raw NodeId AND every referencing UNS NodeId with identical
|
||||||
|
value/quality/timestamp — the mux key stays single (RawPath).
|
||||||
|
- **Writes via either NodeId** route to the same driver ref under the same
|
||||||
|
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
|
||||||
|
closed); a failed device write reverts both NodeIds via the shared fan-out
|
||||||
|
(write-outcome self-correction).
|
||||||
|
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||||
|
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||||
|
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
|
||||||
|
`ConditionId = RawPath`.
|
||||||
|
- **Historian dual-registration.** Both NodeIds register the **same** historian
|
||||||
|
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
|
||||||
|
through either NodeId.
|
||||||
|
|
||||||
|
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
|
||||||
|
the single-namespace `EquipmentTags` materialization path are gone. See
|
||||||
|
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
|
||||||
|
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
|
||||||
|
|
||||||
### Key Concept: Tag Name and FullName
|
### Key Concept: Tag Name and FullName
|
||||||
|
|
||||||
@@ -219,6 +255,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
|
|||||||
|
|
||||||
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
|
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
|
||||||
|
|
||||||
|
**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces** — `https://zb.com/otopcua/raw` (Raw device tree, `s=<RawPath>`) + `https://zb.com/otopcua/uns` (UNS tree, `s=<Area>/<Line>/<Equipment>/<EffectiveName>`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||||
|
|
||||||
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
||||||
|
|
||||||
## Scripting / Script Editor
|
## Scripting / Script Editor
|
||||||
|
|||||||
@@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record
|
|||||||
because it carries the full operator + raise-time + category metadata
|
because it carries the full operator + raise-time + category metadata
|
||||||
that the value-driven path collapses.
|
that the value-driven path collapses.
|
||||||
|
|
||||||
|
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
|
||||||
|
|
||||||
|
Under the v3 dual-namespace address space, a native driver alarm is authored on a
|
||||||
|
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
|
||||||
|
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
|
||||||
|
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
|
||||||
|
same condition is wired as an **event notifier of every referencing equipment folder**
|
||||||
|
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
|
||||||
|
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||||
|
`EnsureFolderIsEventNotifier(equipFolder)`.
|
||||||
|
|
||||||
|
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
||||||
|
every referencing equipment folder, and up to the Server object. A subscriber at any one
|
||||||
|
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
||||||
|
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
||||||
|
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
||||||
|
Server-object dedup + Part 9 ack correlation).
|
||||||
|
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
||||||
|
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
|
||||||
|
inverse-notifier entries never leak across redeploys.
|
||||||
|
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
|
||||||
|
which notifier root the operator subscribed at — an ack issued from an equipment-folder
|
||||||
|
subscription resolves to the same raw condition.
|
||||||
|
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||||
|
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||||
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||||
|
|
||||||
## Galaxy driver path (driver-native)
|
## Galaxy driver path (driver-native)
|
||||||
|
|
||||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||||
|
|||||||
+23
-6
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
|
|||||||
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
||||||
OPC UA client can discover historized capability from the node's attributes.
|
OPC UA client can discover historized capability from the node's attributes.
|
||||||
|
|
||||||
|
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
|
||||||
|
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
|
||||||
|
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
|
||||||
|
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
|
||||||
|
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
|
||||||
|
> returns the same series. The historization intent is single-sourced — the mux's
|
||||||
|
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
|
||||||
|
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
|
||||||
|
|
||||||
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
||||||
owns at least one alarm condition is already an event notifier; the server registers a
|
owns at least one alarm condition is already an event notifier; the server registers a
|
||||||
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
||||||
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
|
|||||||
The `historyread` command reads historical data from any node. Supply start and end times in
|
The `historyread` command reads historical data from any node. Supply start and end times in
|
||||||
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
||||||
|
|
||||||
|
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
|
||||||
|
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
|
||||||
|
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
|
||||||
|
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
|
||||||
|
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
|
||||||
|
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
|
||||||
|
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Raw history for a historized Galaxy tag (last 24 hours by default)
|
# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
||||||
|
|
||||||
# Limit to 100 values
|
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=3;s=filling/line1/station1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
--max 100
|
--max 100
|
||||||
|
|
||||||
# 1-hour average aggregate
|
# 1-hour average aggregate
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
--aggregate Average --interval 3600000
|
--aggregate Average --interval 3600000
|
||||||
|
|
||||||
# Authenticated read (ReadOnly role or higher required)
|
# Authenticated read (ReadOnly role or higher required)
|
||||||
otopcua-cli historyread \
|
otopcua-cli historyread \
|
||||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||||
-U reader -P password
|
-U reader -P password
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own
|
|||||||
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
|
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
|
||||||
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
||||||
|
|
||||||
|
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
|
||||||
|
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
|
||||||
|
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
|
||||||
|
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
|
||||||
|
> scripts key off the single value source regardless of the UNS projection. The dual
|
||||||
|
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
|
||||||
|
|
||||||
### Literal gate
|
### Literal gate
|
||||||
|
|
||||||
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
||||||
|
|||||||
+21
-4
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
|
|||||||
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
||||||
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
||||||
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
||||||
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy
|
blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
|
||||||
(`DeploymentArtifact`) paths.
|
(`DeploymentArtifact`) paths.
|
||||||
|
|
||||||
|
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
|
||||||
|
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
|
||||||
|
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
|
||||||
|
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
|
||||||
|
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
|
||||||
|
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||||
|
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
|
||||||
|
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
|
||||||
|
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
|
||||||
|
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
|
||||||
|
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
|
||||||
|
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
|
||||||
|
> `/alerts` row lists the referencing equipment paths. See
|
||||||
|
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
|
||||||
|
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||||
|
|
||||||
### TagConfig alarm fields
|
### TagConfig alarm fields
|
||||||
|
|
||||||
| Field | Values | Default |
|
| Field | Values | Default |
|
||||||
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
|
|||||||
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
||||||
for failover.
|
for failover.
|
||||||
|
|
||||||
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
|
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
|
||||||
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
|
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
|
||||||
other configuration is required.
|
tags). No other configuration is required: any equipment that references the raw tag
|
||||||
|
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
|
||||||
|
|
||||||
### Native-alarm OPC UA operator operations
|
### Native-alarm OPC UA operator operations
|
||||||
|
|
||||||
|
|||||||
+35
@@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
|
|||||||
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
||||||
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
|
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
|
||||||
|
|
||||||
|
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
|
||||||
|
|
||||||
|
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
|
||||||
|
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
|
||||||
|
>
|
||||||
|
> | Namespace URI | Subtree | NodeId `s=` scheme |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||||
|
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||||
|
|
||||||
|
Every device value has **exactly one source** — the raw tag's node in the Raw
|
||||||
|
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
|
||||||
|
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
|
||||||
|
(the display-name override else the raw tag's `Name`). The UNS variable does not
|
||||||
|
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
|
||||||
|
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
|
||||||
|
raw NodeId AND every referencing UNS NodeId with identical value / quality /
|
||||||
|
source-timestamp — the two NodeIds never drift.
|
||||||
|
|
||||||
|
- **Reads / subscriptions** work through either NodeId and return the same data.
|
||||||
|
- **Writes** route through either NodeId to the same backing driver ref under the
|
||||||
|
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
|
||||||
|
than the raw write it fans from); a failed device write reverts both NodeIds via
|
||||||
|
the shared fan-out.
|
||||||
|
- **HistoryRead** works through either NodeId and returns the same series — both
|
||||||
|
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
|
||||||
|
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||||
|
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||||
|
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
|
||||||
|
|
||||||
|
Note the two distinct identity strings: the wire **NodeId** is the path
|
||||||
|
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
|
||||||
|
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
|
||||||
|
space the guards enforce). They are related but not the same string.
|
||||||
|
|
||||||
### Virtual tags
|
### Virtual tags
|
||||||
|
|
||||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
|
||||||
|
|
||||||
|
Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1–WP5), executed via the
|
||||||
|
`/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
|
||||||
|
code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate**
|
||||||
|
on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
- **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single
|
||||||
|
`…/ns`), `V3NodeIds` (raw = `s=<RawPath>`, UNS = `s=<Area>/<Line>/<Equipment>/<EffectiveName>`), and the
|
||||||
|
`AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam.
|
||||||
|
- **Wave A** (2 agents ∥):
|
||||||
|
- **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag,
|
||||||
|
tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable
|
||||||
|
`(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes`
|
||||||
|
UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment
|
||||||
|
paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped
|
||||||
|
`EquipmentNamespaceMaterializationTests`.
|
||||||
|
- **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method
|
||||||
|
(maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized
|
||||||
|
UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through
|
||||||
|
`DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new
|
||||||
|
`AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint
|
||||||
|
no-op, forwarded + reflection-covered).
|
||||||
|
- **Wave B** (1 agent — the delicate one):
|
||||||
|
- **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId
|
||||||
|
registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS
|
||||||
|
NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds;
|
||||||
|
`EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**:
|
||||||
|
**H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS
|
||||||
|
path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node
|
||||||
|
self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity
|
||||||
|
round-trip test.
|
||||||
|
- **Wave C** (2 agents ∥):
|
||||||
|
- **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw
|
||||||
|
device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy —
|
||||||
|
proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on
|
||||||
|
rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
|
||||||
|
`AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery
|
||||||
|
test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
|
||||||
|
- **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node
|
||||||
|
harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`,
|
||||||
|
`docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`).
|
||||||
|
- **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s
|
||||||
|
`AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit).
|
||||||
|
- **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved
|
||||||
|
live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan
|
||||||
|
`VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad
|
||||||
|
forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node
|
||||||
|
reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips
|
||||||
|
historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).)
|
||||||
|
|
||||||
|
## Reviewer verdict per wave
|
||||||
|
|
||||||
|
No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded
|
||||||
|
methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed.
|
||||||
|
Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent
|
||||||
|
+ teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real,
|
||||||
|
dedup/02-S13 semantics intact; M1/M2/LOW-3 closed.
|
||||||
|
|
||||||
|
## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code)
|
||||||
|
|
||||||
|
1. **Browse both namespaces** — `ns=2` Raw tree (`pymodbus/plc/HR200`, `s=<RawPath>`) + `ns=3` UNS tree
|
||||||
|
(`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`.
|
||||||
|
2. **Single-source fan-out** — `MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp;
|
||||||
|
Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix).
|
||||||
|
3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the
|
||||||
|
raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write
|
||||||
|
dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig).
|
||||||
|
4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same
|
||||||
|
historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`.
|
||||||
|
5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag
|
||||||
|
(ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving
|
||||||
|
it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery
|
||||||
|
(exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire
|
||||||
|
`NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one)
|
||||||
|
so a live transition can't be tripped on the rig.
|
||||||
|
6. **Rename cascade** — renamed `HR200`→`HR200X` + deployed: old raw NodeId gone, new present; the UNS
|
||||||
|
reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the
|
||||||
|
renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad
|
||||||
|
0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live
|
||||||
|
driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.)
|
||||||
|
7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary
|
||||||
|
differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the
|
||||||
|
alarm-emit gate is Primary-only).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests
|
||||||
|
dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green
|
||||||
|
(the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy
|
||||||
|
`EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).
|
||||||
|
|
||||||
|
## Documented follow-ups (non-blocking)
|
||||||
|
|
||||||
|
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).**
|
||||||
|
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
|
||||||
|
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
|
||||||
|
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
|
||||||
|
`EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted
|
||||||
|
alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real
|
||||||
|
transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression).
|
||||||
|
Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk**
|
||||||
|
(a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1
|
||||||
|
historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded`
|
||||||
|
(same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`,
|
||||||
|
**never touching the `alerts` topic**.
|
||||||
|
- Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate).
|
||||||
|
- Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works).
|
||||||
|
- Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=<RawPath>` /
|
||||||
|
UNS `s=<Area>/<Line>/<Equipment>/<EffectiveName>`, retired `EquipmentNodeIds`) + the umbrella index
|
||||||
|
`../scadaproj/CLAUDE.md` OtOpcUa entry.
|
||||||
|
|
||||||
|
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
|
||||||
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
|||||||
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
||||||
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
||||||
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
||||||
|
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
|
||||||
public sealed record AlarmTransitionEvent(
|
public sealed record AlarmTransitionEvent(
|
||||||
string AlarmId,
|
string AlarmId,
|
||||||
string EquipmentPath,
|
string EquipmentPath,
|
||||||
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
|
|||||||
DateTime TimestampUtc,
|
DateTime TimestampUtc,
|
||||||
string AlarmTypeName = "AlarmCondition",
|
string AlarmTypeName = "AlarmCondition",
|
||||||
string? Comment = null,
|
string? Comment = null,
|
||||||
bool? HistorizeToAveva = null);
|
bool? HistorizeToAveva = null,
|
||||||
|
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
|
||||||
|
/// node's <c>s=</c> identifier at every address-space sink seam so the namespace is chosen at
|
||||||
|
/// the call site, never parsed back out of the id string (explicit beats inferred).
|
||||||
|
/// <see cref="V3NodeIds.NamespaceUri"/> maps each realm to its namespace URI.
|
||||||
|
/// </summary>
|
||||||
|
public enum AddressSpaceRealm
|
||||||
|
{
|
||||||
|
/// <summary>The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
|
||||||
|
/// <c>s=</c> id is its <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.RawPaths">RawPath</see>.</summary>
|
||||||
|
Raw,
|
||||||
|
|
||||||
|
/// <summary>The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's <c>s=</c>
|
||||||
|
/// id is the slash-joined <c>Area/Line/Equipment[/EffectiveName]</c>.</summary>
|
||||||
|
Uns,
|
||||||
|
}
|
||||||
@@ -23,30 +23,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||||
|
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
|
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||||
|
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||||
@@ -55,9 +66,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// Without this forward the surgical optimization is inert on every driver-role host, because
|
// Without this forward the surgical optimization is inert on every driver-role host, because
|
||||||
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
|
||||||
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||||
@@ -65,9 +76,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||||
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||||
// actors inject THIS wrapper, not the inner sink.
|
// actors inject THIS wrapper, not the inner sink.
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||||
|
|
||||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||||
@@ -75,14 +86,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
|||||||
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
|
||||||
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
|
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveVariableNode(string variableNodeId)
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
|
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
|
|
||||||
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
|
|
||||||
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
|
|
||||||
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
|
|
||||||
/// three agree on the exact NodeId a variable was placed at.
|
|
||||||
/// </summary>
|
|
||||||
public static class EquipmentNodeIds
|
|
||||||
{
|
|
||||||
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
|
|
||||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
|
||||||
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
|
|
||||||
/// <returns>The sub-folder NodeId string.</returns>
|
|
||||||
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
|
|
||||||
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="equipmentId">The owning equipment's NodeId.</param>
|
|
||||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
|
||||||
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
|
|
||||||
/// <returns>The folder-scoped variable NodeId string.</returns>
|
|
||||||
public static string Variable(string equipmentId, string? folderPath, string name)
|
|
||||||
{
|
|
||||||
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
|
|
||||||
return $"{parent}/{name}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,11 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The quality status of the value.</param>
|
/// <param name="quality">The quality status of the value.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
|
||||||
|
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
|
||||||
|
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
|
||||||
|
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
|
||||||
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
||||||
@@ -25,7 +29,9 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||||
/// <param name="state">The full condition state to project onto the node.</param>
|
/// <param name="state">The full condition state to project onto the node.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc);
|
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
|
||||||
|
/// the condition was materialised under).</param>
|
||||||
|
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||||
@@ -41,7 +47,31 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
||||||
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
|
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
|
||||||
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
|
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
|
||||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
|
/// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
|
||||||
|
/// live in.</param>
|
||||||
|
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
|
||||||
|
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
|
||||||
|
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
|
||||||
|
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
|
||||||
|
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
|
||||||
|
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
|
||||||
|
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
|
||||||
|
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
|
||||||
|
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
|
||||||
|
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
|
||||||
|
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
|
||||||
|
/// across redeploys).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
|
||||||
|
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
|
||||||
|
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
|
||||||
|
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
|
||||||
|
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
|
||||||
|
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||||
|
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
||||||
@@ -52,7 +82,8 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
/// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
|
||||||
|
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||||
@@ -77,7 +108,10 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// rank + dimensions carry the array-ness.</param>
|
/// rank + dimensions carry the array-ness.</param>
|
||||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
|
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
|
||||||
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
|
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
|
||||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
/// <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
|
||||||
|
/// 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);
|
||||||
|
|
||||||
/// <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
|
||||||
@@ -91,7 +125,28 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||||
void RaiseNodesAddedModelChange(string affectedNodeId);
|
/// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
|
||||||
|
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
|
||||||
|
/// target node, both realm-qualified. Used by <c>AddressSpaceApplier</c> to link each UNS reference
|
||||||
|
/// Variable (source, <see cref="AddressSpaceRealm.Uns"/>) to its backing Raw node (target,
|
||||||
|
/// <see cref="AddressSpaceRealm.Raw"/>) with an <c>Organizes</c> edge, so the UNS variable
|
||||||
|
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
|
||||||
|
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
|
||||||
|
/// <b>Idempotent</b> (an existing edge is not duplicated); a <b>missing endpoint is a no-op</b>
|
||||||
|
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceNodeId">The source node's <c>s=</c> id (the referencing node — e.g. the UNS variable).</param>
|
||||||
|
/// <param name="sourceRealm">The namespace realm <paramref name="sourceNodeId"/> lives in.</param>
|
||||||
|
/// <param name="targetNodeId">The target node's <c>s=</c> id (the referenced node — e.g. the backing Raw node).</param>
|
||||||
|
/// <param name="targetRealm">The namespace realm <paramref name="targetNodeId"/> lives in.</param>
|
||||||
|
/// <param name="referenceType">The hierarchical reference type name — <c>Organizes</c> (default),
|
||||||
|
/// <c>HasComponent</c>, or <c>HasProperty</c>; unknown names fall back to <c>Organizes</c>.</param>
|
||||||
|
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
|
||||||
|
string targetNodeId, AddressSpaceRealm targetRealm,
|
||||||
|
string referenceType = "Organizes");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
||||||
@@ -106,23 +161,29 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
private NullOpcUaAddressSpaceSink() { }
|
private NullOpcUaAddressSpaceSink() { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
|
/// <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) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
|
|||||||
{
|
{
|
||||||
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
||||||
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
||||||
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
|
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
|
||||||
/// <param name="value">The value the client wrote.</param>
|
/// <param name="value">The value the client wrote.</param>
|
||||||
|
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
|
||||||
|
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
|
||||||
|
/// routing: a raw <c>s=<RawPath></c> and a UNS <c>s=<Area/Line/Equip/Eff></c> can collide as bare
|
||||||
|
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
|
||||||
|
/// operator write route to the WRONG driver ref.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>A task resolving to the device-write outcome.</returns>
|
/// <returns>A task resolving to the device-write outcome.</returns>
|
||||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
|
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
||||||
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
|
|||||||
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
||||||
private NullOpcUaNodeWriteGateway() { }
|
private NullOpcUaNodeWriteGateway() { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||||
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
|
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
|
||||||
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
||||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
|
||||||
|
/// namespace index from the realm rather than parsing it out of the id string.</param>
|
||||||
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
||||||
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength);
|
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
||||||
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
||||||
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// should rebuild instead).</summary>
|
/// should rebuild instead).</summary>
|
||||||
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
||||||
/// <param name="displayName">The new display name to apply.</param>
|
/// <param name="displayName">The new display name to apply.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
||||||
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
|
bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
|
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
|
||||||
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
|
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
|
||||||
@@ -43,8 +46,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
|
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
|
||||||
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
|
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
|
||||||
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
|
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveVariableNode(string variableNodeId);
|
bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
|
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
|
||||||
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
|
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
|
||||||
@@ -52,8 +56,9 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
|
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
|
||||||
/// rebuilds).</summary>
|
/// rebuilds).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
|
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveAlarmConditionNode(string alarmNodeId);
|
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
|
||||||
|
|
||||||
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
|
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
|
||||||
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
|
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
|
||||||
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
|
|||||||
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
||||||
/// rebuilds).</summary>
|
/// rebuilds).</summary>
|
||||||
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
|
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
|
||||||
|
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
|
||||||
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
|
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
|
||||||
bool RemoveEquipmentSubtree(string equipmentNodeId);
|
bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
|
||||||
|
/// underlying values under two identity schemes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
|
||||||
|
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
|
||||||
|
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
|
||||||
|
/// on its own RawPath prefix).</item>
|
||||||
|
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
|
||||||
|
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
|
||||||
|
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
|
||||||
|
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
|
||||||
|
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
|
||||||
|
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
|
||||||
|
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
|
||||||
|
/// </summary>
|
||||||
|
public static class V3NodeIds
|
||||||
|
{
|
||||||
|
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
|
||||||
|
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
|
||||||
|
|
||||||
|
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
|
||||||
|
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
|
||||||
|
|
||||||
|
/// <summary>The namespace URI for a realm.</summary>
|
||||||
|
/// <param name="realm">The address-space realm.</param>
|
||||||
|
/// <returns>The realm's namespace URI.</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
|
||||||
|
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
|
||||||
|
{
|
||||||
|
AddressSpaceRealm.Raw => RawNamespaceUri,
|
||||||
|
AddressSpaceRealm.Uns => UnsNamespaceUri,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----- Raw realm -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
|
||||||
|
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
|
||||||
|
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
|
||||||
|
/// string; the argument is expected to already be a RawPaths-built path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawPath">The (already-built) RawPath.</param>
|
||||||
|
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
|
||||||
|
public static string Raw(string rawPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(rawPath);
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Uns realm -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
|
||||||
|
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
|
||||||
|
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
|
||||||
|
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||||
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||||
|
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
|
||||||
|
public static string Uns(params string[] segments) => UnsFromSegments(segments);
|
||||||
|
|
||||||
|
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
|
||||||
|
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
|
||||||
|
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
|
||||||
|
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
|
||||||
|
/// EffectiveName under an Equipment) to an already-built UNS path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
|
||||||
|
/// <param name="childSegment">The child segment to append.</param>
|
||||||
|
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
|
||||||
|
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
|
||||||
|
public static string UnsChild(string parentUnsPath, string childSegment)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
|
||||||
|
var error = RawPaths.ValidateSegment(childSegment);
|
||||||
|
if (error is not null)
|
||||||
|
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
|
||||||
|
|
||||||
|
return parentUnsPath + RawPaths.Separator + childSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnsFromSegments(IEnumerable<string> segments)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(segments);
|
||||||
|
var list = segments as IReadOnlyList<string> ?? segments.ToList();
|
||||||
|
if (list.Count == 0)
|
||||||
|
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
|
||||||
|
|
||||||
|
for (var i = 0; i < list.Count; i++)
|
||||||
|
{
|
||||||
|
var error = RawPaths.ValidateSegment(list[i]);
|
||||||
|
if (error is not null)
|
||||||
|
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(RawPaths.Separator, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,22 @@ else
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||||
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
||||||
<td><span class="mono small">@e.EquipmentPath</span></td>
|
<td>
|
||||||
|
<span class="mono small">@e.EquipmentPath</span>
|
||||||
|
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
|
||||||
|
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
|
||||||
|
when the producer populated it (native alarms whose raw tag ≥1 equipment
|
||||||
|
references); scripted alarms + unreferenced native alarms leave it empty. *@
|
||||||
|
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
|
||||||
|
{
|
||||||
|
<div class="text-muted small" title="Referencing equipment">
|
||||||
|
@foreach (var p in refs)
|
||||||
|
{
|
||||||
|
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
||||||
<td class="num">@e.Severity</td>
|
<td class="num">@e.Severity</td>
|
||||||
<td>@e.User</td>
|
<td>@e.User</td>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
|
|||||||
var failedNodes = 0;
|
var failedNodes = 0;
|
||||||
foreach (var eq in plan.RemovedEquipment)
|
foreach (var eq in plan.RemovedEquipment)
|
||||||
{
|
{
|
||||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++;
|
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||||
removedCount++;
|
removedCount++;
|
||||||
}
|
}
|
||||||
foreach (var alarm in plan.RemovedAlarms)
|
foreach (var alarm in plan.RemovedAlarms)
|
||||||
{
|
{
|
||||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++;
|
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||||
removedCount++;
|
removedCount++;
|
||||||
}
|
}
|
||||||
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
|
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
|
||||||
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
|
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
|
||||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
|
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
|
||||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
|
// removed raw containers/tags + UNS reference variables are likewise real removals.
|
||||||
|
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||||
|
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
|
||||||
|
|
||||||
var changedCount =
|
var changedCount =
|
||||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||||
plan.ChangedEquipmentTags.Count +
|
plan.ChangedEquipmentTags.Count +
|
||||||
plan.ChangedEquipmentVirtualTags.Count +
|
plan.ChangedEquipmentVirtualTags.Count +
|
||||||
|
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
|
||||||
// A UNS Area/Line rename is an in-place change to an existing folder node.
|
// A UNS Area/Line rename is an in-place change to an existing folder node.
|
||||||
plan.RenamedFolders.Count;
|
plan.RenamedFolders.Count;
|
||||||
var addedCount =
|
var addedCount =
|
||||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||||
plan.AddedEquipmentTags.Count +
|
plan.AddedEquipmentTags.Count +
|
||||||
plan.AddedEquipmentVirtualTags.Count;
|
plan.AddedEquipmentVirtualTags.Count +
|
||||||
|
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
|
||||||
|
|
||||||
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||||
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
||||||
@@ -172,7 +177,7 @@ public sealed class AddressSpaceApplier
|
|||||||
foreach (var rename in renamedFolders)
|
foreach (var rename in renamedFolders)
|
||||||
{
|
{
|
||||||
bool ok;
|
bool ok;
|
||||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
|
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
|
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
|
||||||
@@ -185,13 +190,13 @@ public sealed class AddressSpaceApplier
|
|||||||
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
||||||
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
||||||
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
|
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
|
||||||
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||||
var historian = d.Current.IsHistorized
|
var historian = d.Current.IsHistorized
|
||||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||||
: null;
|
: null;
|
||||||
bool ok;
|
bool ok;
|
||||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); }
|
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
||||||
@@ -263,77 +268,108 @@ public sealed class AddressSpaceApplier
|
|||||||
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
||||||
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||||
{
|
{
|
||||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||||
if (tag.Alarm is not null)
|
if (tag.Alarm is not null)
|
||||||
{
|
{
|
||||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||||
// closed), then remove the condition in place.
|
// closed), then remove the condition in place.
|
||||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
|
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||||
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
|
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||||
{
|
{
|
||||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
|
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
|
||||||
// written by the top-of-Apply removal block; here we tear the condition node down in place.
|
// written by the top-of-Apply removal block; here we tear the condition node down in place.
|
||||||
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
|
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
|
||||||
{
|
{
|
||||||
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false;
|
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
|
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
|
||||||
// top-of-Apply block already wrote the equipment id's terminal condition state.
|
// top-of-Apply block already wrote the equipment id's terminal condition state.
|
||||||
foreach (var eq in plan.RemovedEquipment)
|
foreach (var eq in plan.RemovedEquipment)
|
||||||
{
|
{
|
||||||
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false;
|
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
|
||||||
|
// in-place remove. A backing-raw rename is a re-point (Changed, → rebuild), NOT a removal, so a UNS
|
||||||
|
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
|
||||||
|
foreach (var v in plan.RemovedUnsReferenceVariables)
|
||||||
|
{
|
||||||
|
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||||
|
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
|
||||||
|
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
|
||||||
|
// + RemoveVariableNode).
|
||||||
|
foreach (var t in plan.RemovedRawTags)
|
||||||
|
{
|
||||||
|
if (t.Alarm is not null)
|
||||||
|
{
|
||||||
|
if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++;
|
||||||
|
if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
|
||||||
|
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the
|
||||||
|
// sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so
|
||||||
|
// the cheap variable/condition removals above still run in place when only tags were removed.
|
||||||
|
if (plan.RemovedRawContainers.Count > 0) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId)
|
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { return surgical.RemoveVariableNode(nodeId); }
|
try { return surgical.RemoveVariableNode(nodeId, realm); }
|
||||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
/// <summary>Remove an alarm-condition 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>
|
||||||
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId)
|
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { return surgical.RemoveAlarmConditionNode(nodeId); }
|
try { return surgical.RemoveAlarmConditionNode(nodeId, realm); }
|
||||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||||
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
|
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||||
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId)
|
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { return surgical.RemoveEquipmentSubtree(nodeId); }
|
try { return surgical.RemoveEquipmentSubtree(nodeId, realm); }
|
||||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
|
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
|
||||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
|
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
|
||||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||||
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { _sink.WriteValue(nodeId, value, quality, ts); return true; }
|
try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier
|
|||||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
||||||
/// <returns>The materialise-parent node id.</returns>
|
/// <returns>The materialise-parent node id.</returns>
|
||||||
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
|
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
|
||||||
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath);
|
||||||
|
|
||||||
|
// ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) -----
|
||||||
|
// Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the
|
||||||
|
// discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}),
|
||||||
|
// now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go.
|
||||||
|
// These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated
|
||||||
|
// by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name
|
||||||
|
// NodeId directly and are NOT built here.)
|
||||||
|
private static string UnsSubFolder(string equipmentId, string folderPath) =>
|
||||||
|
V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)));
|
||||||
|
|
||||||
|
private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) =>
|
||||||
|
string.IsNullOrWhiteSpace(folderPath)
|
||||||
|
? V3NodeIds.Uns(equipmentId, name)
|
||||||
|
: V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
|
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
|
||||||
@@ -389,7 +440,7 @@ public sealed class AddressSpaceApplier
|
|||||||
{
|
{
|
||||||
foreach (var id in ComputeAddAnnouncements(plan))
|
foreach (var id in ComputeAddAnnouncements(plan))
|
||||||
{
|
{
|
||||||
try { _sink.RaiseNodesAddedModelChange(id); }
|
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
List<HistorianTagProvisionRequest>? requests = null;
|
List<HistorianTagProvisionRequest>? requests = null;
|
||||||
foreach (var tag in plan.AddedEquipmentTags)
|
// v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags
|
||||||
|
// materialise as Part 9 conditions (never historized value variables), so mirror the materialiser
|
||||||
|
// and skip them.
|
||||||
|
foreach (var tag in plan.AddedRawTags)
|
||||||
{
|
{
|
||||||
// Only historized value variables are provisioned. Native-alarm tags materialise as
|
|
||||||
// Part 9 condition nodes (never historized value variables) — the materialiser resolves
|
|
||||||
// a historian tagname only for the non-alarm branch, so mirror that and skip them.
|
|
||||||
if (!tag.IsHistorized || tag.Alarm is not null) continue;
|
if (!tag.IsHistorized || tag.Alarm is not null) continue;
|
||||||
|
|
||||||
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
|
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
|
||||||
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
|
// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
|
||||||
// override falls back to the driver-side FullName.
|
// falls back to the RawPath (== NodeId).
|
||||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
|
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||||
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
|
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
|
||||||
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
|
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
|
||||||
}
|
}
|
||||||
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
|
|||||||
List<HistorizedTagRef>? added = null;
|
List<HistorizedTagRef>? added = null;
|
||||||
List<HistorizedTagRef>? removed = null;
|
List<HistorizedTagRef>? removed = null;
|
||||||
|
|
||||||
// Added historized value variables → new interest.
|
// v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
|
||||||
foreach (var tag in plan.AddedEquipmentTags)
|
// stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the
|
||||||
|
// node manager but does NOT add a second mux ref (no double historization). So this feed emits raw
|
||||||
|
// refs only.
|
||||||
|
// Added historized raw value tags → new interest.
|
||||||
|
foreach (var tag in plan.AddedRawTags)
|
||||||
{
|
{
|
||||||
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
|
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed historized value variables → drop interest.
|
// Removed historized raw value tags → drop interest.
|
||||||
foreach (var tag in plan.RemovedEquipmentTags)
|
foreach (var tag in plan.RemovedRawTags)
|
||||||
{
|
{
|
||||||
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
|
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName
|
// Changed raw tags: the historized ref may have flipped on/off or the historian override changed
|
||||||
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
|
// (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
|
||||||
// mux ref and the historian name) — an unchanged pair is a no-op.
|
// unchanged pair is a no-op.
|
||||||
foreach (var d in plan.ChangedEquipmentTags)
|
foreach (var d in plan.ChangedRawTags)
|
||||||
{
|
{
|
||||||
var prev = HistorizedRef(d.Previous);
|
var prev = HistorizedRef(d.Previous);
|
||||||
var cur = HistorizedRef(d.Current);
|
var cur = HistorizedRef(d.Current);
|
||||||
@@ -540,13 +595,15 @@ public sealed class AddressSpaceApplier
|
|||||||
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
|
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
|
||||||
/// value variable (not historized, or a native-alarm condition node).
|
/// value variable (not historized, or a native-alarm condition node).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tag">The equipment tag to resolve a historized ref for.</param>
|
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
|
||||||
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
|
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
|
||||||
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
|
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
|
||||||
tag.IsHistorized && tag.Alarm is null
|
tag.IsHistorized && tag.Alarm is null
|
||||||
|
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
|
||||||
|
// default historian tagname; an override diverges only the historian name.
|
||||||
? new HistorizedTagRef(
|
? new HistorizedTagRef(
|
||||||
tag.FullName,
|
tag.NodeId,
|
||||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
|
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
|
||||||
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
|
|||||||
var failed = 0;
|
var failed = 0;
|
||||||
foreach (var area in composition.UnsAreas)
|
foreach (var area in composition.UnsAreas)
|
||||||
{
|
{
|
||||||
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++;
|
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
foreach (var line in composition.UnsLines)
|
foreach (var line in composition.UnsLines)
|
||||||
{
|
{
|
||||||
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++;
|
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
foreach (var equipment in composition.EquipmentNodes)
|
foreach (var equipment in composition.EquipmentNodes)
|
||||||
{
|
{
|
||||||
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
||||||
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
|
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
|
||||||
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++;
|
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -604,6 +661,153 @@ public sealed class AddressSpaceApplier
|
|||||||
return failed;
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — materialise the <b>Raw</b> device-oriented subtree (<c>ns=Raw</c>): the container
|
||||||
|
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
|
||||||
|
/// then the tag Variable nodes keyed <c>s=<RawPath></c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
|
||||||
|
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
|
||||||
|
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
|
||||||
|
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
|
||||||
|
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
|
||||||
|
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
|
||||||
|
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="composition">The composition carrying the Raw subtree.</param>
|
||||||
|
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||||
|
public int MaterialiseRawSubtree(AddressSpaceComposition composition)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(composition);
|
||||||
|
if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0;
|
||||||
|
|
||||||
|
var failed = 0;
|
||||||
|
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
|
||||||
|
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
|
||||||
|
foreach (var c in composition.RawContainers)
|
||||||
|
{
|
||||||
|
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
|
||||||
|
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
|
||||||
|
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
|
||||||
|
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
|
||||||
|
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
|
||||||
|
|
||||||
|
foreach (var t in composition.RawTags)
|
||||||
|
{
|
||||||
|
if (t.Alarm is not null)
|
||||||
|
{
|
||||||
|
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
|
||||||
|
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
|
||||||
|
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
else if (t.ReferencingEquipmentPaths.Count > 0)
|
||||||
|
{
|
||||||
|
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
|
||||||
|
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
|
||||||
|
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
|
||||||
|
// (the id scheme MaterialiseHierarchy created the equipment folders under).
|
||||||
|
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
|
||||||
|
var equipFolderNodeIds = t.ReferencingEquipmentPaths
|
||||||
|
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id))
|
||||||
|
.Select(id => id!)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
if (equipFolderNodeIds.Count == 0)
|
||||||
|
{
|
||||||
|
// Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved
|
||||||
|
// to an EquipmentId folder — the native fan-out to those equipment is silently absent.
|
||||||
|
// Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead
|
||||||
|
// of swallowing zero operator signal. Today this only happens on a broken UNS ancestry /
|
||||||
|
// an invalid segment (the composer would have dropped the equipment too); the latent
|
||||||
|
// risk is a future editable Area/Line display-name feature breaking the path↔id coupling
|
||||||
|
// (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests).
|
||||||
|
_logger.LogWarning(
|
||||||
|
"AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})",
|
||||||
|
t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths));
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
|
||||||
|
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
|
||||||
|
string? historianTagname = t.IsHistorized
|
||||||
|
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
|
||||||
|
: null;
|
||||||
|
var writable = t.Writable && !t.IsArray;
|
||||||
|
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})",
|
||||||
|
composition.RawContainers.Count, composition.RawTags.Count, failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — materialise the <b>UNS</b> reference Variable nodes (<c>ns=UNS</c>): one per
|
||||||
|
/// <see cref="UnsReferenceVariable"/>, keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>,
|
||||||
|
/// parented under its equipment folder (the logical <see cref="UnsReferenceVariable.EquipmentId"/> node
|
||||||
|
/// <see cref="MaterialiseHierarchy"/> already created), THEN an <c>Organizes</c> reference
|
||||||
|
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
|
||||||
|
/// tagname from its backing RAW tag (looked up by <see cref="UnsReferenceVariable.BackingRawPath"/>), so a
|
||||||
|
/// historized raw tag's UNS reference registers the SAME tagname (both NodeIds → one tagname → HistoryRead
|
||||||
|
/// works against either). The UNS node binds NO driver — the driver publish path fans values to it (WP3
|
||||||
|
/// runtime binding). Every sink call passes <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge
|
||||||
|
/// passes both realms EXPLICITLY. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="composition">The composition carrying the UNS reference variables + Raw tags (for inherited shape).</param>
|
||||||
|
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||||
|
public int MaterialiseUnsReferences(AddressSpaceComposition composition)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(composition);
|
||||||
|
if (composition.UnsReferenceVariables.Count == 0) return 0;
|
||||||
|
|
||||||
|
// Backing raw tag shape/historian by RawPath — a UNS reference inherits these so its node matches the
|
||||||
|
// raw node it mirrors (a historized raw tag registers the SAME tagname on the UNS node for HistoryRead).
|
||||||
|
var rawByPath = new Dictionary<string, RawTagPlan>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in composition.RawTags) rawByPath[t.NodeId] = t;
|
||||||
|
|
||||||
|
var failed = 0;
|
||||||
|
foreach (var v in composition.UnsReferenceVariables)
|
||||||
|
{
|
||||||
|
var backing = rawByPath.GetValueOrDefault(v.BackingRawPath);
|
||||||
|
var isArray = backing?.IsArray ?? false;
|
||||||
|
uint? arrayLength = backing?.ArrayLength;
|
||||||
|
// A UNS reference to a historized raw tag registers the SAME effective historian tagname (override
|
||||||
|
// else the backing RawPath) so HistoryRead resolves against the UNS NodeId too. The mux ref stays
|
||||||
|
// single (keyed by RawPath) — FeedHistorizedRefs emits raw refs only.
|
||||||
|
string? historianTagname = backing is { IsHistorized: true }
|
||||||
|
? (string.IsNullOrWhiteSpace(backing.HistorianTagname) ? backing.NodeId : backing.HistorianTagname)
|
||||||
|
: null;
|
||||||
|
var writable = v.Writable && !isArray;
|
||||||
|
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
|
||||||
|
}
|
||||||
|
// Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing
|
||||||
|
// endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy.
|
||||||
|
try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})",
|
||||||
|
composition.UnsReferenceVariables.Count, failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Materialise Equipment-namespace tags from a composition snapshot.
|
/// Materialise Equipment-namespace tags from a composition snapshot.
|
||||||
/// For each <see cref="EquipmentTagPlan"/>,
|
/// For each <see cref="EquipmentTagPlan"/>,
|
||||||
@@ -635,9 +839,9 @@ public sealed class AddressSpaceApplier
|
|||||||
foreach (var tag in composition.EquipmentTags)
|
foreach (var tag in composition.EquipmentTags)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||||
var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++;
|
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
|
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
|
||||||
@@ -650,13 +854,13 @@ public sealed class AddressSpaceApplier
|
|||||||
{
|
{
|
||||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||||
? tag.EquipmentId
|
? tag.EquipmentId
|
||||||
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||||
if (tag.Alarm is not null)
|
if (tag.Alarm is not null)
|
||||||
{
|
{
|
||||||
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
|
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
|
||||||
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
|
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
|
||||||
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++;
|
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -670,7 +874,7 @@ public sealed class AddressSpaceApplier
|
|||||||
// even if authored ReadWrite, so a client write cannot reach the driver write path which
|
// even if authored ReadWrite, so a client write cannot reach the driver write path which
|
||||||
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
|
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
|
||||||
var writable = tag.Writable && !tag.IsArray;
|
var writable = tag.Writable && !tag.IsArray;
|
||||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,17 +912,17 @@ public sealed class AddressSpaceApplier
|
|||||||
var failed = 0;
|
var failed = 0;
|
||||||
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
|
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
|
||||||
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
|
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
|
||||||
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++;
|
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||||
|
|
||||||
foreach (var v in variables)
|
foreach (var v in variables)
|
||||||
{
|
{
|
||||||
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
|
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
|
||||||
var writable = v.Writable && !v.IsArray;
|
var writable = v.Writable && !v.IsArray;
|
||||||
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
|
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
|
||||||
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
|
AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
|
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
|
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
|
||||||
@@ -754,9 +958,9 @@ public sealed class AddressSpaceApplier
|
|||||||
foreach (var v in composition.EquipmentVirtualTags)
|
foreach (var v in composition.EquipmentVirtualTags)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
||||||
var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++;
|
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
|
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
|
||||||
@@ -769,10 +973,10 @@ public sealed class AddressSpaceApplier
|
|||||||
{
|
{
|
||||||
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
||||||
? v.EquipmentId
|
? v.EquipmentId
|
||||||
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
: UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||||
// VirtualTags are computed outputs — read-only nodes (no inbound write).
|
// VirtualTags are computed outputs — read-only nodes (no inbound write).
|
||||||
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++;
|
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -805,7 +1009,7 @@ public sealed class AddressSpaceApplier
|
|||||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||||
{
|
{
|
||||||
if (!alarm.Enabled) continue;
|
if (!alarm.Enabled) continue;
|
||||||
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++;
|
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
|
||||||
materialised++;
|
materialised++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,9 +1026,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 so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
|
/// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
|
||||||
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
|
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
|
||||||
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; }
|
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,9 +1036,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, 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)
|
||||||
{
|
{
|
||||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; }
|
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,9 +1063,9 @@ public sealed class AddressSpaceApplier
|
|||||||
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
|
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
|
||||||
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
|
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
|
||||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||||
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
|
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; }
|
try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,11 +1073,70 @@ public sealed class AddressSpaceApplier
|
|||||||
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
|
/// Returns <c>true</c> 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>
|
/// their pass's failed-node count (archreview 01/S-1).</summary>
|
||||||
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
|
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
|
||||||
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
|
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm)
|
||||||
{
|
{
|
||||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; }
|
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
|
||||||
|
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> 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>
|
||||||
|
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
|
||||||
|
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
|
{
|
||||||
|
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
|
||||||
|
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
|
||||||
|
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
|
||||||
|
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
|
||||||
|
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
|
||||||
|
/// construction EXACTLY — <b>path↔id coupling invariant:</b> the composer builds the path from the
|
||||||
|
/// Area/Line/Equipment <c>Name</c>; this inverts it via the projected <c>DisplayName</c>, which the
|
||||||
|
/// composer sets to that same <c>Name</c> at every level (verified by
|
||||||
|
/// <c>AddressSpaceComposerPathParityTests</c>). A future editable Area/Line display-name feature that
|
||||||
|
/// lets <c>DisplayName != Name</c> would silently break this inversion — that test is the tripwire.
|
||||||
|
/// An invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same drop the composer
|
||||||
|
/// applies), so an entry the composer produced always resolves here.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
|
||||||
|
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
|
||||||
|
private IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
|
||||||
|
{
|
||||||
|
var areaName = composition.UnsAreas
|
||||||
|
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
|
||||||
|
var lineByid = composition.UnsLines
|
||||||
|
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var e in composition.EquipmentNodes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
|
||||||
|
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
|
||||||
|
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
|
||||||
|
string path;
|
||||||
|
try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); }
|
||||||
|
catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ }
|
||||||
|
// Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id
|
||||||
|
// (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the
|
||||||
|
// composer's own last-write-wins spot) rather than silently pick one — a collision here would send
|
||||||
|
// the alarm's notifier to the wrong equipment folder.
|
||||||
|
if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this",
|
||||||
|
path, existing, e.EquipmentId);
|
||||||
|
}
|
||||||
|
map[path] = e.EquipmentId;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
|
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
|
||||||
|
|||||||
@@ -53,10 +53,24 @@ public static class AddressSpaceChangeClassifier
|
|||||||
|
|
||||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||||
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
|
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
|
||||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
|
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
|
||||||
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
// Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point /
|
||||||
|
// display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical
|
||||||
|
// in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change
|
||||||
|
// alongside pure adds still rebuilds.
|
||||||
|
//
|
||||||
|
// WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier
|
||||||
|
// wiring correct today. A native alarm's set of referencing equipment lives in
|
||||||
|
// RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference
|
||||||
|
// makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers
|
||||||
|
// cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm
|
||||||
|
// notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus
|
||||||
|
// an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events.
|
||||||
if (plan.ChangedEquipment.Count > 0 ||
|
if (plan.ChangedEquipment.Count > 0 ||
|
||||||
plan.ChangedAlarms.Count > 0 ||
|
plan.ChangedAlarms.Count > 0 ||
|
||||||
|
plan.ChangedRawContainers.Count > 0 ||
|
||||||
|
plan.ChangedRawTags.Count > 0 ||
|
||||||
|
plan.ChangedUnsReferenceVariables.Count > 0 ||
|
||||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||||
{
|
{
|
||||||
@@ -68,10 +82,14 @@ public static class AddressSpaceChangeClassifier
|
|||||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||||
var hasAdds =
|
var hasAdds =
|
||||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0;
|
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
|
||||||
|
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
||||||
|
plan.AddedUnsReferenceVariables.Count > 0;
|
||||||
var hasRemoves =
|
var hasRemoves =
|
||||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0;
|
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||||
|
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
|
||||||
|
plan.RemovedUnsReferenceVariables.Count > 0;
|
||||||
|
|
||||||
// 3 — additions AND removals.
|
// 3 — additions AND removals.
|
||||||
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
|
|||||||
/// constructor + call site keeps compiling unchanged.
|
/// constructor + call site keeps compiling unchanged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||||
|
|
||||||
|
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
|
||||||
|
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
|
||||||
|
/// carries its <c>s=<RawPath></c> NodeId and its parent NodeId (<see langword="null"/> = cluster
|
||||||
|
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
|
||||||
|
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
|
||||||
|
/// + call site keeps compiling unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
|
||||||
|
/// <c>(realm=Raw, s=<RawPath>)</c>. The RawPath IS the node identity + the single value source
|
||||||
|
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
|
||||||
|
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
|
||||||
|
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
|
||||||
|
/// multi-notifier alarm). Init-only, defaults empty.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
|
||||||
|
/// <c>(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>)</c>. Each carries
|
||||||
|
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
|
||||||
|
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
|
||||||
|
/// out from the raw node. Init-only, defaults empty.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
|
||||||
|
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
|
||||||
|
public enum RawNodeKind
|
||||||
|
{
|
||||||
|
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
|
||||||
|
Folder,
|
||||||
|
|
||||||
|
/// <summary>A <see cref="DriverInstance"/>.</summary>
|
||||||
|
Driver,
|
||||||
|
|
||||||
|
/// <summary>A <see cref="Device"/>.</summary>
|
||||||
|
Device,
|
||||||
|
|
||||||
|
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
|
||||||
|
TagGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
|
||||||
|
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
|
||||||
|
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
|
||||||
|
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
|
||||||
|
/// call site rather than inferring it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
|
||||||
|
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
|
||||||
|
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
|
||||||
|
/// <param name="Kind">Which container this is.</param>
|
||||||
|
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
|
||||||
|
public sealed record RawContainerNode(
|
||||||
|
string NodeId,
|
||||||
|
string? ParentNodeId,
|
||||||
|
string DisplayName,
|
||||||
|
RawNodeKind Kind,
|
||||||
|
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
|
||||||
|
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
|
||||||
|
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
|
||||||
|
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
|
||||||
|
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
|
||||||
|
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
|
||||||
|
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
|
||||||
|
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
|
||||||
|
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
|
||||||
|
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
|
||||||
|
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
||||||
|
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
||||||
|
/// later, not here).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RawTagPlan(
|
||||||
|
string TagId,
|
||||||
|
string NodeId,
|
||||||
|
string? ParentNodeId,
|
||||||
|
string DriverInstanceId,
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
bool Writable,
|
||||||
|
EquipmentTagAlarmInfo? Alarm,
|
||||||
|
IReadOnlyList<string> ReferencingEquipmentPaths,
|
||||||
|
bool IsHistorized = false,
|
||||||
|
string? HistorianTagname = null,
|
||||||
|
bool IsArray = false,
|
||||||
|
uint? ArrayLength = null,
|
||||||
|
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||||
|
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
|
||||||
|
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
|
||||||
|
// EquipmentVirtualTagPlan).
|
||||||
|
public bool Equals(RawTagPlan? other) =>
|
||||||
|
other is not null &&
|
||||||
|
TagId == other.TagId &&
|
||||||
|
NodeId == other.NodeId &&
|
||||||
|
ParentNodeId == other.ParentNodeId &&
|
||||||
|
DriverInstanceId == other.DriverInstanceId &&
|
||||||
|
Name == other.Name &&
|
||||||
|
DataType == other.DataType &&
|
||||||
|
Writable == other.Writable &&
|
||||||
|
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
|
||||||
|
IsHistorized == other.IsHistorized &&
|
||||||
|
HistorianTagname == other.HistorianTagname &&
|
||||||
|
IsArray == other.IsArray &&
|
||||||
|
ArrayLength == other.ArrayLength &&
|
||||||
|
Realm == other.Realm &&
|
||||||
|
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
||||||
|
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||||
|
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||||
|
hash.Add(Realm);
|
||||||
|
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
|
||||||
|
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
|
||||||
|
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
|
||||||
|
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
|
||||||
|
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
|
||||||
|
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
|
||||||
|
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
|
||||||
|
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
|
||||||
|
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UnsReferenceVariable(
|
||||||
|
string UnsTagReferenceId,
|
||||||
|
string EquipmentId,
|
||||||
|
string NodeId,
|
||||||
|
string EffectiveName,
|
||||||
|
string BackingRawPath,
|
||||||
|
string DataType,
|
||||||
|
bool Writable,
|
||||||
|
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the address space build plan from the v3 configuration entities.
|
/// Composes the address space build plan from the v3 configuration entities.
|
||||||
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
|
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
|
||||||
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
|
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
|
||||||
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
|
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
|
||||||
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
|
/// <c>s=<RawPath></c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
|
||||||
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
|
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
|
||||||
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
|
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>, carrying its backing RawPath
|
||||||
|
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
|
||||||
|
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
|
||||||
|
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
|
||||||
|
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
|
||||||
|
/// unchanged.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="unsAreas">The UNS areas.</param>
|
/// <param name="unsAreas">The UNS areas.</param>
|
||||||
/// <param name="unsLines">The UNS lines.</param>
|
/// <param name="unsLines">The UNS lines.</param>
|
||||||
@@ -366,15 +527,19 @@ public static class AddressSpaceComposer
|
|||||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
|
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
|
||||||
|
// every value (see RawTags / UnsReferenceVariables below).
|
||||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||||
|
|
||||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
|
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
|
||||||
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
|
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
|
||||||
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
|
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
|
||||||
// use, so the three agree byte-for-byte on the resolved RawPath.
|
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
|
||||||
var referenceMapByEquip = BuildEquipmentReferenceMap(
|
|
||||||
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
|
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
|
||||||
|
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
|
||||||
|
// artifact-decode mirror + the draft validator use.
|
||||||
|
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
|
||||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -451,45 +616,38 @@ public static class AddressSpaceComposer
|
|||||||
Enabled: a.Enabled));
|
Enabled: a.Enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
|
||||||
|
var (rawContainers, rawTags) = BuildRawSubtree(
|
||||||
|
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||||
|
unsAreas, unsLines, equipment, topology);
|
||||||
|
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||||
|
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
|
||||||
|
|
||||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||||
{
|
{
|
||||||
EquipmentTags = equipmentTags,
|
EquipmentTags = equipmentTags,
|
||||||
EquipmentVirtualTags = equipmentVirtualTags,
|
EquipmentVirtualTags = equipmentVirtualTags,
|
||||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||||
|
RawContainers = rawContainers,
|
||||||
|
RawTags = rawTags,
|
||||||
|
UnsReferenceVariables = unsReferenceVariables,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
||||||
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
|
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
|
||||||
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
|
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
|
||||||
/// are supplied (every test / earlier caller that omits the reference data).</summary>
|
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
||||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||||
IReadOnlyList<Tag>? tags,
|
IReadOnlyList<Tag>? tags,
|
||||||
IReadOnlyList<RawFolder>? rawFolders,
|
RawPathResolver resolver)
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
|
||||||
IReadOnlyList<Device>? devices,
|
|
||||||
IReadOnlyList<TagGroup>? tagGroups)
|
|
||||||
{
|
{
|
||||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||||
var tagRows = tags ?? Array.Empty<Tag>();
|
var tagRows = tags ?? Array.Empty<Tag>();
|
||||||
if (refs.Count == 0 || tagRows.Count == 0)
|
if (refs.Count == 0 || tagRows.Count == 0)
|
||||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
var folders = (rawFolders ?? Array.Empty<RawFolder>())
|
|
||||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
|
||||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
||||||
var drivers = driverInstances
|
|
||||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
||||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
||||||
var deviceMap = (devices ?? Array.Empty<Device>())
|
|
||||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
|
||||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
|
||||||
var groups = (tagGroups ?? Array.Empty<TagGroup>())
|
|
||||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
|
||||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
|
||||||
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
|
|
||||||
|
|
||||||
var tagsById = tagRows
|
var tagsById = tagRows
|
||||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
@@ -503,4 +661,363 @@ public static class AddressSpaceComposer
|
|||||||
tagsById,
|
tagsById,
|
||||||
resolver);
|
resolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
|
||||||
|
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
|
||||||
|
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
|
||||||
|
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
|
||||||
|
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
|
||||||
|
/// </summary>
|
||||||
|
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
|
||||||
|
IReadOnlyList<RawFolder>? rawFolders,
|
||||||
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
|
IReadOnlyList<Device>? devices,
|
||||||
|
IReadOnlyList<TagGroup>? tagGroups,
|
||||||
|
IReadOnlyList<Tag>? tags,
|
||||||
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
|
IReadOnlyList<Equipment> equipment,
|
||||||
|
RawTopology topology)
|
||||||
|
{
|
||||||
|
var containers = new List<RawContainerNode>();
|
||||||
|
|
||||||
|
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
|
||||||
|
{
|
||||||
|
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
|
||||||
|
var parent = f.ParentRawFolderId is not null
|
||||||
|
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
|
||||||
|
: null;
|
||||||
|
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var d in driverInstances)
|
||||||
|
{
|
||||||
|
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
|
||||||
|
var parent = d.RawFolderId is not null
|
||||||
|
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
|
||||||
|
: null;
|
||||||
|
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dev in devices ?? Array.Empty<Device>())
|
||||||
|
{
|
||||||
|
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
|
||||||
|
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
|
||||||
|
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
|
||||||
|
{
|
||||||
|
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
|
||||||
|
var parent = g.ParentTagGroupId is not null
|
||||||
|
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
|
||||||
|
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
|
||||||
|
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
||||||
|
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||||
|
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||||
|
|
||||||
|
var rawTags = new List<RawTagPlan>();
|
||||||
|
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||||
|
{
|
||||||
|
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||||
|
if (nodeId is null) continue;
|
||||||
|
var parent = t.TagGroupId is not null
|
||||||
|
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
|
||||||
|
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
||||||
|
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||||
|
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||||
|
var alarm = intent.Alarm is { } a
|
||||||
|
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||||
|
: null;
|
||||||
|
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
|
||||||
|
|
||||||
|
rawTags.Add(new RawTagPlan(
|
||||||
|
TagId: t.TagId,
|
||||||
|
NodeId: nodeId,
|
||||||
|
ParentNodeId: parent,
|
||||||
|
DriverInstanceId: driverInstanceId,
|
||||||
|
Name: t.Name,
|
||||||
|
DataType: t.DataType,
|
||||||
|
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||||
|
Alarm: alarm,
|
||||||
|
ReferencingEquipmentPaths: refEquipPaths,
|
||||||
|
IsHistorized: intent.IsHistorized,
|
||||||
|
HistorianTagname: intent.HistorianTagname,
|
||||||
|
IsArray: intent.IsArray,
|
||||||
|
ArrayLength: intent.ArrayLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||||
|
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||||
|
return (containers, rawTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||||
|
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
|
||||||
|
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
|
||||||
|
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
|
||||||
|
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
|
||||||
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||||
|
IReadOnlyList<Tag>? tags,
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
|
IReadOnlyList<Equipment> equipment,
|
||||||
|
RawTopology topology)
|
||||||
|
{
|
||||||
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||||
|
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||||
|
|
||||||
|
var tagsById = (tags ?? Array.Empty<Tag>())
|
||||||
|
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||||
|
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||||
|
|
||||||
|
var vars = new List<UnsReferenceVariable>();
|
||||||
|
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
||||||
|
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||||
|
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||||
|
if (rawPath is null) continue;
|
||||||
|
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
||||||
|
|
||||||
|
string nodeId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars.Add(new UnsReferenceVariable(
|
||||||
|
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||||
|
EquipmentId: r.EquipmentId,
|
||||||
|
NodeId: nodeId,
|
||||||
|
EffectiveName: effectiveName,
|
||||||
|
BackingRawPath: rawPath,
|
||||||
|
DataType: tag.DataType,
|
||||||
|
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite));
|
||||||
|
}
|
||||||
|
|
||||||
|
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
|
||||||
|
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
|
||||||
|
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
|
IReadOnlyList<Equipment> equipment)
|
||||||
|
{
|
||||||
|
var areaName = unsAreas
|
||||||
|
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||||
|
var line = unsLines
|
||||||
|
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var e in equipment)
|
||||||
|
{
|
||||||
|
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
|
||||||
|
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byEquip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
|
||||||
|
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
|
||||||
|
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
|
||||||
|
/// resolvable reference is absent (⇒ an empty list at the call site).
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
|
||||||
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
|
IReadOnlyList<Equipment> equipment)
|
||||||
|
{
|
||||||
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||||
|
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||||
|
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||||
|
foreach (var r in refs)
|
||||||
|
{
|
||||||
|
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||||
|
if (!byTag.TryGetValue(r.TagId, out var set))
|
||||||
|
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
|
||||||
|
set.Add(equipPath);
|
||||||
|
}
|
||||||
|
return byTag.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => (IReadOnlyList<string>)kv.Value.ToList(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
|
||||||
|
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
|
||||||
|
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
|
||||||
|
/// reference map so every RawPath is produced by the single authority.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RawTopology
|
||||||
|
{
|
||||||
|
public required RawPathResolver Resolver { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
|
||||||
|
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
|
||||||
|
|
||||||
|
public static RawTopology Build(
|
||||||
|
IReadOnlyList<RawFolder>? rawFolders,
|
||||||
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
|
IReadOnlyList<Device>? devices,
|
||||||
|
IReadOnlyList<TagGroup>? tagGroups)
|
||||||
|
{
|
||||||
|
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
|
||||||
|
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||||
|
var driverRows = driverInstances
|
||||||
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||||
|
var deviceRows = (devices ?? Array.Empty<Device>())
|
||||||
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||||
|
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
|
||||||
|
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
|
||||||
|
|
||||||
|
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
|
||||||
|
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var id in folderRows.Keys)
|
||||||
|
ResolveFolder(id, folderRows, folderPaths, 0);
|
||||||
|
|
||||||
|
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var id in driverRows.Keys)
|
||||||
|
{
|
||||||
|
var p = resolver.TryBuildDriverPrefix(id);
|
||||||
|
if (p is not null) driverPaths[id] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var (id, dev) in deviceRows)
|
||||||
|
{
|
||||||
|
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
|
||||||
|
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
|
||||||
|
catch (ArgumentException) { /* invalid device name — dropped */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
|
||||||
|
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
|
||||||
|
foreach (var id in groupRows.Keys)
|
||||||
|
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
|
||||||
|
|
||||||
|
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return new RawTopology
|
||||||
|
{
|
||||||
|
Resolver = resolver,
|
||||||
|
FolderPaths = folderPaths,
|
||||||
|
DriverPaths = driverPaths,
|
||||||
|
DevicePaths = devicePaths,
|
||||||
|
GroupPaths = groupPaths,
|
||||||
|
DriverIdByDevice = driverIdByDevice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MaxDepth = 256;
|
||||||
|
|
||||||
|
private static string? ResolveFolder(
|
||||||
|
string id,
|
||||||
|
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||||
|
Dictionary<string, string> memo,
|
||||||
|
int depth)
|
||||||
|
{
|
||||||
|
if (depth > MaxDepth) return null;
|
||||||
|
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||||
|
if (!rows.TryGetValue(id, out var row)) return null;
|
||||||
|
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
|
||||||
|
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = RawPaths.Combine(parentPath, row.Name);
|
||||||
|
memo[id] = path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (ArgumentException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveGroup(
|
||||||
|
string id,
|
||||||
|
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||||
|
IReadOnlyDictionary<string, string> groupDeviceById,
|
||||||
|
IReadOnlyDictionary<string, string> devicePaths,
|
||||||
|
Dictionary<string, string> memo,
|
||||||
|
int depth)
|
||||||
|
{
|
||||||
|
if (depth > MaxDepth) return null;
|
||||||
|
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||||
|
if (!rows.TryGetValue(id, out var row)) return null;
|
||||||
|
|
||||||
|
string? parentPath;
|
||||||
|
if (row.ParentId is not null)
|
||||||
|
{
|
||||||
|
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
|
||||||
|
if (parentPath is null) return null; // broken parent chain
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Root group: parent is the owning device's RawPath.
|
||||||
|
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|
||||||
|
|| !devicePaths.TryGetValue(deviceId, out parentPath))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = RawPaths.Combine(parentPath, row.Name);
|
||||||
|
memo[id] = path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch (ArgumentException) { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,46 @@ public sealed record AddressSpacePlan(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — the Raw subtree's <b>container</b> diff (Folder/Driver/Device/TagGroup nodes),
|
||||||
|
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
|
||||||
|
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
|
||||||
|
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
|
||||||
|
/// unchanged (consistent with the EquipmentTag diff sets above).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||||
|
/// <inheritdoc cref="AddedRawContainers"/>
|
||||||
|
public IReadOnlyList<RawContainerNode> RemovedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||||
|
/// <inheritdoc cref="AddedRawContainers"/>
|
||||||
|
public IReadOnlyList<RawContainerDelta> ChangedRawContainers { get; init; } = Array.Empty<RawContainerDelta>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — the Raw subtree's <b>tag</b> diff, keyed by <see cref="RawTagPlan.NodeId"/> (the
|
||||||
|
/// RawPath). A raw tag <b>rename</b> changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
|
||||||
|
/// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
|
||||||
|
/// the same NodeId ⇒ a <see cref="RawTagDelta"/>. Init-only, defaulting empty.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<RawTagPlan> AddedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||||
|
/// <inheritdoc cref="AddedRawTags"/>
|
||||||
|
public IReadOnlyList<RawTagPlan> RemovedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||||
|
/// <inheritdoc cref="AddedRawTags"/>
|
||||||
|
public IReadOnlyList<RawTagDelta> ChangedRawTags { get; init; } = Array.Empty<RawTagDelta>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
|
||||||
|
/// <see cref="UnsReferenceVariable.UnsTagReferenceId"/> (the stable row id, NOT the NodeId). Keying on
|
||||||
|
/// the row id is what makes a backing-raw-tag rename a <b>re-point</b> (same reference row, same
|
||||||
|
/// effective name ⇒ same UNS NodeId, but <see cref="UnsReferenceVariable.BackingRawPath"/> — the
|
||||||
|
/// <c>Organizes</c> target + fan-out source — moves) rather than a remove+add; and a
|
||||||
|
/// display-name-override edit a <see cref="UnsReferenceDelta"/> (same row id, NodeId + effective name
|
||||||
|
/// change) with NO raw-side change. Init-only, defaulting empty.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<UnsReferenceVariable> AddedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||||
|
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||||
|
public IReadOnlyList<UnsReferenceVariable> RemovedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||||
|
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||||
|
public IReadOnlyList<UnsReferenceDelta> ChangedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceDelta>();
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
|
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
|
||||||
public bool IsEmpty =>
|
public bool IsEmpty =>
|
||||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||||
@@ -76,6 +116,9 @@ public sealed record AddressSpacePlan(
|
|||||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
||||||
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
||||||
|
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
|
||||||
|
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
|
||||||
|
AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
|
||||||
RenamedFolders.Count == 0;
|
RenamedFolders.Count == 0;
|
||||||
|
|
||||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||||
@@ -84,6 +127,16 @@ public sealed record AddressSpacePlan(
|
|||||||
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
||||||
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
||||||
|
|
||||||
|
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
|
||||||
|
public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
|
||||||
|
/// <summary>A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
|
||||||
|
/// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.</summary>
|
||||||
|
public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
|
||||||
|
/// <summary>A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
|
||||||
|
/// field — a backing-tag re-point (<c>BackingRawPath</c> moved) or a display-name-override edit (NodeId +
|
||||||
|
/// effective name changed).</summary>
|
||||||
|
public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);
|
||||||
|
|
||||||
/// <summary>One renamed UNS Area / Line folder: the stable folder
|
/// <summary>One renamed UNS Area / Line folder: the stable folder
|
||||||
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
|
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
|
||||||
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
|
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
|
||||||
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
|
|||||||
t => t.VirtualTagId,
|
t => t.VirtualTagId,
|
||||||
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
|
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
|
||||||
|
|
||||||
|
// Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
|
||||||
|
// as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
|
||||||
|
// changes (none today — a container carries only DisplayName == leaf name, which lives in the
|
||||||
|
// RawPath) fall to Changed, kept for completeness / future fields.
|
||||||
|
var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
|
||||||
|
previous.RawContainers, next.RawContainers,
|
||||||
|
c => c.NodeId,
|
||||||
|
(a, b) => new AddressSpacePlan.RawContainerDelta(a, b));
|
||||||
|
|
||||||
|
// Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
|
||||||
|
// attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
|
||||||
|
// equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
|
||||||
|
var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
|
||||||
|
previous.RawTags, next.RawTags,
|
||||||
|
t => t.NodeId,
|
||||||
|
(a, b) => new AddressSpacePlan.RawTagDelta(a, b));
|
||||||
|
|
||||||
|
// UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
|
||||||
|
// the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
|
||||||
|
// NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
|
||||||
|
// Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
|
||||||
|
var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
|
||||||
|
previous.UnsReferenceVariables, next.UnsReferenceVariables,
|
||||||
|
v => v.UnsTagReferenceId,
|
||||||
|
(a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));
|
||||||
|
|
||||||
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
|
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
|
||||||
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
|
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
|
||||||
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
|
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
|
||||||
@@ -159,6 +238,15 @@ public static class AddressSpacePlanner
|
|||||||
AddedEquipmentVirtualTags = addedVTags,
|
AddedEquipmentVirtualTags = addedVTags,
|
||||||
RemovedEquipmentVirtualTags = removedVTags,
|
RemovedEquipmentVirtualTags = removedVTags,
|
||||||
ChangedEquipmentVirtualTags = changedVTags,
|
ChangedEquipmentVirtualTags = changedVTags,
|
||||||
|
AddedRawContainers = addedRawContainers,
|
||||||
|
RemovedRawContainers = removedRawContainers,
|
||||||
|
ChangedRawContainers = changedRawContainers,
|
||||||
|
AddedRawTags = addedRawTags,
|
||||||
|
RemovedRawTags = removedRawTags,
|
||||||
|
ChangedRawTags = changedRawTags,
|
||||||
|
AddedUnsReferenceVariables = addedUnsRefs,
|
||||||
|
RemovedUnsReferenceVariables = removedUnsRefs,
|
||||||
|
ChangedUnsReferenceVariables = changedUnsRefs,
|
||||||
RenamedFolders = renamedFolders,
|
RenamedFolders = renamedFolders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,48 +21,56 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
|
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveVariableNode(string variableNodeId)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.RemoveVariableNode(variableNodeId);
|
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
|
=> _nodeManager.RemoveVariableNode(variableNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||||
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
|
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||||
|
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||||
|
=> _nodeManager.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct)
|
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var driverHost = _resolveDriverHost();
|
var driverHost = _resolveDriverHost();
|
||||||
if (driverHost is null)
|
if (driverHost is null)
|
||||||
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
||||||
new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false);
|
new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
|
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
|
||||||
return new NodeWriteOutcome(result.Success, result.Reason);
|
return new NodeWriteOutcome(result.Success, result.Reason);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
|
|||||||
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
|
||||||
|
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
|
||||||
|
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
|
||||||
|
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
|
||||||
|
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
|
||||||
|
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
|
||||||
|
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
|
||||||
|
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="root">The artifact root element.</param>
|
||||||
|
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
|
||||||
|
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
|
||||||
|
BuildRawAndUnsSubtrees(JsonElement root)
|
||||||
|
{
|
||||||
|
var rawFolders = new List<RawFolder>();
|
||||||
|
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "RawFolderId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
rawFolders.Add(new RawFolder
|
||||||
|
{
|
||||||
|
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
|
||||||
|
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var driverInstances = new List<DriverInstance>();
|
||||||
|
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "DriverInstanceId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
driverInstances.Add(new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
|
||||||
|
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
|
||||||
|
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var devices = new List<Device>();
|
||||||
|
foreach (var el in EnumerateArray(root, "Devices"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "DeviceId");
|
||||||
|
var di = ReadString(el, "DriverInstanceId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||||
|
devices.Add(new Device
|
||||||
|
{
|
||||||
|
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
|
||||||
|
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagGroups = new List<TagGroup>();
|
||||||
|
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "TagGroupId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
tagGroups.Add(new TagGroup
|
||||||
|
{
|
||||||
|
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
|
||||||
|
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = new List<Tag>();
|
||||||
|
foreach (var el in EnumerateArray(root, "Tags"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "TagId");
|
||||||
|
var deviceId = ReadString(el, "DeviceId");
|
||||||
|
var name = ReadString(el, "Name");
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
|
||||||
|
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
|
||||||
|
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||||
|
? tcEl.GetString() ?? "{}" : "{}";
|
||||||
|
tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
|
||||||
|
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
|
||||||
|
AccessLevel = accessLevel, TagConfig = tagConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var unsTagReferences = new List<UnsTagReference>();
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "UnsTagReferenceId");
|
||||||
|
var equipmentId = ReadString(el, "EquipmentId");
|
||||||
|
var tagId = ReadString(el, "TagId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
|
||||||
|
unsTagReferences.Add(new UnsTagReference
|
||||||
|
{
|
||||||
|
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
|
||||||
|
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var unsAreas = new List<UnsArea>();
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsAreas"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "UnsAreaId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
|
var unsLines = new List<UnsLine>();
|
||||||
|
foreach (var el in EnumerateArray(root, "UnsLines"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "UnsLineId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
|
||||||
|
}
|
||||||
|
|
||||||
|
var equipment = new List<Equipment>();
|
||||||
|
foreach (var el in EnumerateArray(root, "Equipment"))
|
||||||
|
{
|
||||||
|
var id = ReadString(el, "EquipmentId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
|
||||||
|
Name = ReadString(el, "Name") ?? id!,
|
||||||
|
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
|
||||||
|
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
|
||||||
|
MachineCode = ReadString(el, "MachineCode") ?? id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var composition = AddressSpaceComposer.Compose(
|
||||||
|
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: unsTagReferences, tags: tags,
|
||||||
|
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
|
||||||
|
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||||
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
|
|||||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
|
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
|
||||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
|
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
|
||||||
|
|
||||||
|
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
|
||||||
|
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
|
||||||
|
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
|
||||||
|
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
|
||||||
|
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
|
||||||
|
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
|
||||||
|
|
||||||
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
||||||
{
|
{
|
||||||
EquipmentTags = equipmentTags,
|
EquipmentTags = equipmentTags,
|
||||||
EquipmentVirtualTags = equipmentVirtualTags,
|
EquipmentVirtualTags = equipmentVirtualTags,
|
||||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||||
|
RawContainers = rawContainers,
|
||||||
|
RawTags = rawTags,
|
||||||
|
UnsReferenceVariables = unsReferenceVariables,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
|
|||||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||||
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
|
||||||
|
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
|
||||||
|
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
|
||||||
|
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
|
||||||
|
// raw VALUE + UNS reference node is correctly cluster-scoped.
|
||||||
|
RawContainers = full.RawContainers,
|
||||||
|
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||||
|
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||||
public static DiscoveredInjectionPlan Map(
|
public static DiscoveredInjectionPlan Map(
|
||||||
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||||
{
|
{
|
||||||
|
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
||||||
|
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
||||||
|
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
||||||
|
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
||||||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||||||
|
|
||||||
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
||||||
@@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper
|
|||||||
for (var i = 0; i < segs.Count; i++)
|
for (var i = 0; i < segs.Count; i++)
|
||||||
{
|
{
|
||||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||||
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
var nodeId = Combine(rootNodeId, folderPath);
|
||||||
if (folders.ContainsKey(nodeId)) continue;
|
if (folders.ContainsKey(nodeId)) continue;
|
||||||
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
|
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
||||||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var varFolderPath = string.Join('/', segs);
|
var varFolderPath = string.Join('/', segs);
|
||||||
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
|
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||||
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
|
? Combine(rootNodeId, n.BrowseName)
|
||||||
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
|
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||||
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
|
// A folder-less variable parents directly at the root device node.
|
||||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||||
? equipmentId
|
? rootNodeId
|
||||||
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
|
: Combine(rootNodeId, varFolderPath);
|
||||||
variables.Add(new DiscoveredVariable(
|
variables.Add(new DiscoveredVariable(
|
||||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||||
routing[n.FullReference] = varNodeId;
|
routing[n.FullReference] = varNodeId;
|
||||||
|
|||||||
@@ -111,34 +111,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
// spawn so a restart's respawn never collides with the still-terminating old child.
|
// spawn so a restart's respawn never collides with the still-terminating old child.
|
||||||
private long _childSpawnGeneration;
|
private long _childSpawnGeneration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
|
||||||
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment
|
/// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
|
||||||
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
/// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
|
||||||
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and
|
/// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
|
||||||
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands
|
private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
|
||||||
/// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back
|
|
||||||
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
|
|
||||||
/// rebuild dedups by NodeId.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
|
||||||
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/>
|
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
|
||||||
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an
|
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
|
||||||
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning
|
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
|
||||||
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver
|
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
|
||||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
|
||||||
/// map fans out 1:N because one ref can back several variables).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
|
||||||
new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
/// <summary>
|
||||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
|
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
|
||||||
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
|
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
|
||||||
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
|
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
|
||||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
/// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||||
|
/// composition's <c>RawTags</c> ∪ <c>UnsReferenceVariables</c>, and resolved by
|
||||||
|
/// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
|
||||||
|
/// owning driver child as a write of its wire-ref RawPath.
|
||||||
|
/// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=<RawPath></c> and a UNS
|
||||||
|
/// <c>s=<Area/Line/Equip/Eff></c> can collide as bare strings (folder/driver/device names and
|
||||||
|
/// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route
|
||||||
|
/// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full
|
||||||
|
/// ns-qualified id PLUS the realm it resolved (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
|
||||||
|
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
|
||||||
|
/// namespace disambiguation the ns-qualified id carries.</para>
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
|
||||||
|
|
||||||
|
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
|
||||||
|
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
|
||||||
|
/// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
|
||||||
|
/// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
|
||||||
|
/// value-subscription set.</summary>
|
||||||
|
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
|
||||||
@@ -159,7 +171,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
/// <c>TagConfig.alarm.historizeToAveva</c>; it is threaded onto the transition so the
|
/// <c>TagConfig.alarm.historizeToAveva</c>; it is threaded onto the transition so the
|
||||||
/// HistorianAdapterActor's <c>is not false</c> gate suppresses the durable AVEVA row only on an
|
/// HistorianAdapterActor's <c>is not false</c> gate suppresses the durable AVEVA row only on an
|
||||||
/// explicit false (mirroring the scripted-alarm opt-out).</summary>
|
/// explicit false (mirroring the scripted-alarm opt-out).</summary>
|
||||||
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva)> _alarmMetaByNodeId =
|
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths)> _alarmMetaByNodeId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
|
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
|
||||||
@@ -232,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
|
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
|
||||||
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
|
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
|
||||||
public sealed record RouteNodeWrite(string NodeId, object? Value);
|
public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
|
||||||
|
|
||||||
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
|
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
|
||||||
/// (or a gate/lookup failure that never reached the driver).</summary>
|
/// (or a gate/lookup failure that never reached the driver).</summary>
|
||||||
@@ -620,13 +632,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
// equipment variables (identical machines sharing a register), hence the fan-out.
|
// equipment variables (identical machines sharing a register), hence the fan-out.
|
||||||
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
|
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
|
||||||
{
|
{
|
||||||
foreach (var nodeId in nodeIds)
|
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
|
||||||
|
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
|
||||||
|
foreach (var n in nodeIds)
|
||||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
|
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
|
||||||
nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
|
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped",
|
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
|
||||||
_localNode, msg.DriverInstanceId, msg.FullReference);
|
_localNode, msg.DriverInstanceId, msg.FullReference);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
|
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
|
||||||
{
|
{
|
||||||
|
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
|
||||||
|
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
|
||||||
|
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
|
||||||
|
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
|
||||||
|
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
|
||||||
|
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
|
||||||
|
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
|
||||||
|
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
|
||||||
|
// is a separate follow-up.
|
||||||
|
_log.Debug(
|
||||||
|
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
|
||||||
|
_localNode, msg.DriverInstanceId);
|
||||||
|
return;
|
||||||
|
|
||||||
|
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
|
||||||
if (_lastComposition is null)
|
if (_lastComposition is null)
|
||||||
{
|
{
|
||||||
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
|
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
|
||||||
@@ -717,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
||||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||||
|
#pragma warning restore CS0162
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -918,9 +948,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
var key = (driverId, driverRef);
|
var key = (driverId, driverRef);
|
||||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||||
set.Add(nodeId);
|
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
|
||||||
_driverRefByNodeId[nodeId] = key;
|
// through the Raw realm.
|
||||||
|
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
|
||||||
|
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
|
||||||
}
|
}
|
||||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
|
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
|
||||||
equipmentId, plan.Folders, plan.Variables));
|
equipmentId, plan.Folders, plan.Variables));
|
||||||
@@ -1005,17 +1037,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var nodeId in nodeIds)
|
foreach (var n in nodeIds)
|
||||||
{
|
{
|
||||||
|
var nodeId = n.NodeId;
|
||||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
|
||||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||||
nodeId, snapshot, msg.Args.SourceTimestampUtc));
|
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||||
|
|
||||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||||
if (!serviceAlertsAsPrimary) continue;
|
if (!serviceAlertsAsPrimary) continue;
|
||||||
|
|
||||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null);
|
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||||
|
ReferencingEquipmentPaths: (IReadOnlyList<string>)Array.Empty<string>());
|
||||||
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
|
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
|
||||||
AlarmId: nodeId,
|
AlarmId: nodeId,
|
||||||
EquipmentPath: meta.EquipmentId,
|
EquipmentPath: meta.EquipmentId,
|
||||||
@@ -1035,7 +1069,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
||||||
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
||||||
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
||||||
HistorizeToAveva: meta.HistorizeToAveva)));
|
HistorizeToAveva: meta.HistorizeToAveva,
|
||||||
|
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
|
||||||
|
// renders them as the equipment list (empty for a raw condition no equipment references yet).
|
||||||
|
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,9 +1124,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target))
|
// v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string
|
||||||
|
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>") PLUS the realm it resolved from the namespace index.
|
||||||
|
// The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look
|
||||||
|
// up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a
|
||||||
|
// bare string, so a write always routes to its OWN driver ref (never a colliding sibling's).
|
||||||
|
var bareNodeId = BareNodeId(msg.NodeId);
|
||||||
|
if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target))
|
||||||
{
|
{
|
||||||
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}"));
|
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,6 +1158,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
.PipeTo(replyTo);
|
.PipeTo(replyTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalise an OPC UA NodeId string to its BARE <c>s=</c> identifier. The node manager's inbound-write
|
||||||
|
/// hook passes the full ns-qualified form (<c>NodeId.ToString()</c> == <c>"ns=<N>;s=<id>"</c>);
|
||||||
|
/// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes
|
||||||
|
/// <c>"ns=N;s="</c> for a string identifier in a non-zero namespace, so the first <c>";s="</c> is the
|
||||||
|
/// delimiter (an id may itself contain <c>";s="</c> later — <c>IndexOf</c> takes the FIRST, which is the
|
||||||
|
/// namespace delimiter). A bare id (no prefix) passes through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nodeId">The (possibly ns-qualified) NodeId string.</param>
|
||||||
|
/// <returns>The bare <c>s=</c> identifier.</returns>
|
||||||
|
internal static string BareNodeId(string nodeId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(nodeId)) return nodeId;
|
||||||
|
var i = nodeId.IndexOf(";s=", StringComparison.Ordinal);
|
||||||
|
if (i >= 0) return nodeId[(i + 3)..];
|
||||||
|
if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..];
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
|
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
|
||||||
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
|
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
|
||||||
@@ -1456,89 +1518,89 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they
|
// v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
|
||||||
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native
|
// per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
|
||||||
// alarm event stream, routed by ForwardNativeAlarm).
|
// raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
|
||||||
var refsByDriver = composition.EquipmentTags
|
// excluded from the value set.
|
||||||
|
var refsByDriver = composition.RawTags
|
||||||
.Where(t => t.Alarm is null)
|
.Where(t => t.Alarm is null)
|
||||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.ToArray(),
|
.ToArray(),
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's
|
// Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
|
||||||
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one
|
// An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
|
||||||
// alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the
|
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
|
||||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by
|
// in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||||
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
var alarmRefsByDriver = composition.RawTags
|
||||||
var alarmRefsByDriver = composition.EquipmentTags
|
|
||||||
.Where(t => t.Alarm is not null)
|
.Where(t => t.Alarm is not null)
|
||||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.ToArray(),
|
.ToArray(),
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors
|
// Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
|
||||||
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to
|
// every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
|
||||||
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux
|
var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
|
||||||
// can land driver values on the right node. Clear-and-repopulate every apply so renames
|
foreach (var v in composition.UnsReferenceVariables)
|
||||||
// (Name/FolderPath/EquipmentId changes) and removals are reflected.
|
{
|
||||||
|
if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list))
|
||||||
|
unsRefsByRawPath[v.BackingRawPath] = list = new List<UnsReferenceVariable>();
|
||||||
|
list.Add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the driver live-value + write routing maps from the RawTags ∪ UnsReferenceVariables pass.
|
||||||
|
// Clear-and-repopulate every apply so renames/removals/re-points are reflected.
|
||||||
_nodeIdByDriverRef.Clear();
|
_nodeIdByDriverRef.Clear();
|
||||||
// Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an
|
|
||||||
// operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref
|
|
||||||
// FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are
|
|
||||||
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
|
|
||||||
// attribute), so last-writer-wins on the rare duplicate is harmless.
|
|
||||||
_driverRefByNodeId.Clear();
|
_driverRefByNodeId.Clear();
|
||||||
// Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
|
|
||||||
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
|
|
||||||
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
|
|
||||||
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across
|
|
||||||
// redeploys (renames/removals/address-space rebuilds).
|
|
||||||
_alarmNodeIdByDriverRef.Clear();
|
_alarmNodeIdByDriverRef.Clear();
|
||||||
// Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId,
|
|
||||||
// FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver
|
|
||||||
// acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME
|
|
||||||
// alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to
|
|
||||||
// exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the
|
|
||||||
// rare duplicate is harmless.
|
|
||||||
_driverRefByAlarmNodeId.Clear();
|
_driverRefByAlarmNodeId.Clear();
|
||||||
// Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in
|
|
||||||
// the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it.
|
|
||||||
_alarmMetaByNodeId.Clear();
|
_alarmMetaByNodeId.Clear();
|
||||||
_nativeAlarmProjector.Clear();
|
_nativeAlarmProjector.Clear();
|
||||||
foreach (var t in composition.EquipmentTags)
|
foreach (var t in composition.RawTags)
|
||||||
{
|
{
|
||||||
var key = (t.DriverInstanceId, t.FullName);
|
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
|
||||||
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
|
|
||||||
if (t.Alarm is not null)
|
if (t.Alarm is not null)
|
||||||
{
|
{
|
||||||
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and
|
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
|
||||||
// keep them OUT of the value maps + value-subscription set (so they don't get both a value
|
// equipment notifier NodeIds; WP3 wires the single raw condition.
|
||||||
// variable AND a condition).
|
|
||||||
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
|
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
|
||||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
|
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
|
||||||
aset.Add(nodeId);
|
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||||
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition
|
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
|
||||||
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA
|
_driverRefByAlarmNodeId[t.NodeId] = key;
|
||||||
// acknowledge of this condition reaches the right driver child.
|
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
|
||||||
_driverRefByAlarmNodeId[nodeId] = key;
|
// equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
|
||||||
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
|
// Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
|
||||||
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
|
// the /alerts row lists which equipment reference this raw condition.
|
||||||
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
|
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
|
||||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm)
|
||||||
|
// against the SAME driver ref — the single value source fans to all of them.
|
||||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||||
set.Add(nodeId);
|
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||||
_driverRefByNodeId[nodeId] = key;
|
_driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key;
|
||||||
|
|
||||||
|
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
|
||||||
|
{
|
||||||
|
foreach (var v in refs)
|
||||||
|
{
|
||||||
|
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
|
||||||
|
// A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never
|
||||||
|
// collide with a raw NodeId that shares its bare string.
|
||||||
|
_driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
|
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
|
||||||
|
|||||||
@@ -39,16 +39,25 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
|
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
|
||||||
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
|
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
|
||||||
|
|
||||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
|
||||||
|
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
|
||||||
|
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
|
||||||
|
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
|
||||||
|
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
|
||||||
|
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
|
||||||
|
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||||
|
|
||||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||||
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
|
||||||
/// <c>Core.ScriptedAlarms</c>.</summary>
|
/// <c>Core.ScriptedAlarms</c>.</summary>
|
||||||
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param>
|
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
|
||||||
|
/// v3 native raw conditions).</param>
|
||||||
/// <param name="State">The full condition state to project onto the node.</param>
|
/// <param name="State">The full condition state to project onto the node.</param>
|
||||||
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
||||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
|
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
||||||
|
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||||
|
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||||
@@ -263,7 +272,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
|
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||||
Interlocked.Increment(ref _writes);
|
Interlocked.Increment(ref _writes);
|
||||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||||
}
|
}
|
||||||
@@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
|
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||||
Interlocked.Increment(ref _writes);
|
Interlocked.Increment(ref _writes);
|
||||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||||
}
|
}
|
||||||
@@ -340,6 +349,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
||||||
var failedNodes = outcome.FailedNodes;
|
var failedNodes = outcome.FailedNodes;
|
||||||
failedNodes += _applier.MaterialiseHierarchy(composition);
|
failedNodes += _applier.MaterialiseHierarchy(composition);
|
||||||
|
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
|
||||||
|
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
|
||||||
|
// edge finds its raw target. The driver binds live values to these raw NodeIds.
|
||||||
|
failedNodes += _applier.MaterialiseRawSubtree(composition);
|
||||||
|
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
|
||||||
|
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
|
||||||
|
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
|
||||||
|
failedNodes += _applier.MaterialiseUnsReferences(composition);
|
||||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||||
|
|||||||
@@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
|||||||
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
|
||||||
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
|
||||||
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
|
||||||
|
// Scripted alarms are per-equipment condition nodes in the UNS realm.
|
||||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||||
AlarmNodeId: e.AlarmId,
|
AlarmNodeId: e.AlarmId,
|
||||||
State: ToSnapshot(e),
|
State: ToSnapshot(e),
|
||||||
TimestampUtc: e.TimestampUtc));
|
TimestampUtc: e.TimestampUtc,
|
||||||
|
Realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
// Publish the transition to the cluster `alerts` topic — the single historization + live
|
||||||
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
|
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
|
||||||
|
|||||||
@@ -31,12 +31,24 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
|
|
||||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||||
|
|
||||||
|
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||||
|
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||||
|
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, but an
|
||||||
|
/// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the
|
||||||
|
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
|
||||||
|
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
|
||||||
|
|
||||||
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
||||||
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
|
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
|
||||||
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
|
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
|
||||||
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
||||||
/// every existing construction site source-compatible.</summary>
|
/// every existing construction site source-compatible.</summary>
|
||||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);
|
/// <param name="IsReassert"><c>true</c> when this result is a deploy-time re-assertion of the last
|
||||||
|
/// value/quality (see <see cref="ReassertValue"/>) rather than a fresh evaluation. The host still
|
||||||
|
/// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a
|
||||||
|
/// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to
|
||||||
|
/// <c>false</c> so every existing construction site stays source-compatible.</param>
|
||||||
|
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false);
|
||||||
|
|
||||||
private readonly string _virtualTagId;
|
private readonly string _virtualTagId;
|
||||||
private readonly string _scriptId;
|
private readonly string _scriptId;
|
||||||
@@ -105,6 +117,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
_mux = mux;
|
_mux = mux;
|
||||||
|
|
||||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||||
|
Receive<ReassertValue>(_ => OnReassertValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -122,6 +135,32 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a
|
||||||
|
/// deploy that re-materialised our published UNS node to <c>BadWaitingForInitialData</c> recovers even when
|
||||||
|
/// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the
|
||||||
|
/// child has emitted at least once — the first dependency arrival publishes the initial value normally.
|
||||||
|
/// <para>
|
||||||
|
/// The result is flagged <see cref="EvaluationResult.IsReassert"/> so the host publishes it (repairing
|
||||||
|
/// the reset node) but does NOT re-record it to the historian — it is a stale value stamped with a
|
||||||
|
/// fresh deploy-time timestamp, not a real evaluation sample (M1).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ordering (why the re-publish lands on the freshly-materialised node, not the pre-reset one): the
|
||||||
|
/// reset and this re-publish converge on the SAME single-threaded <c>OpcUaPublishActor</c>. On a deploy
|
||||||
|
/// <c>DriverHostActor</c> enqueues <c>RebuildAddressSpace</c> (the reset) to that actor FIRST, then sends
|
||||||
|
/// <c>ApplyVirtualTags</c> to the VirtualTag HOST; the host tells this child <c>ReassertValue</c>, and
|
||||||
|
/// only then does this method Tell the host an <c>EvaluationResult</c> which the host bridges onward to
|
||||||
|
/// the publish actor. That multi-hop chain guarantees the re-publish arrives AFTER <c>RebuildAddressSpace</c>
|
||||||
|
/// in the publish actor's FIFO mailbox, so the materialise runs before the write.
|
||||||
|
/// </para></summary>
|
||||||
|
private void OnReassertValue()
|
||||||
|
{
|
||||||
|
if (!_hasLastValue && !_lastPublishedBad) return;
|
||||||
|
var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
|
||||||
|
Context.Parent.Tell(new EvaluationResult(
|
||||||
|
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true));
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||||
{
|
{
|
||||||
_dependencies[msg.TagId] = msg.Value;
|
_dependencies[msg.TagId] = msg.Value;
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
|||||||
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
|
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
|
||||||
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
|
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
|
||||||
/// materialises against — so the value always lands on a NodeId that exists.
|
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
|
||||||
|
/// always lands on a NodeId that exists.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VirtualTagHostActor : ReceiveActor
|
public sealed class VirtualTagHostActor : ReceiveActor
|
||||||
@@ -149,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
// recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
|
// recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
|
||||||
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
|
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
|
||||||
// subscriptions and last-value dedup state.
|
// subscriptions and last-value dedup state.
|
||||||
|
// Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
|
||||||
|
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
|
||||||
|
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var p in msg.Plans)
|
foreach (var p in msg.Plans)
|
||||||
{
|
{
|
||||||
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
||||||
@@ -165,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
mux: _mux));
|
mux: _mux));
|
||||||
Context.Watch(child);
|
Context.Watch(child);
|
||||||
_children[p.VirtualTagId] = child;
|
_children[p.VirtualTagId] = child;
|
||||||
|
newlySpawned.Add(p.VirtualTagId);
|
||||||
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
|
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +181,24 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
_planByVtag[p.VirtualTagId] = p;
|
_planByVtag[p.VirtualTagId] = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a
|
||||||
|
// deploy, and on a deploy DriverHostActor enqueues RebuildAddressSpace — which re-materialises each
|
||||||
|
// VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded OpcUaPublishActor FIRST,
|
||||||
|
// then sends ApplyVirtualTags to THIS host. A surviving child keeps its value-dedup state, so an
|
||||||
|
// unchanged recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with
|
||||||
|
// a static dependency). Telling each survivor to ReassertValue makes it re-publish its last state; that
|
||||||
|
// re-publish reaches the publish actor via a multi-hop chain (host → child ReassertValue → child →
|
||||||
|
// parent EvaluationResult → OnResult → publish actor), so its AttributeValueUpdate lands AFTER
|
||||||
|
// RebuildAddressSpace in the publish actor's FIFO mailbox — the materialise runs before the write, and
|
||||||
|
// the node recovers. (The ordering does NOT come from ApplyVirtualTags itself reaching the publish
|
||||||
|
// actor — it doesn't; it goes to this host.) Fresh children are skipped (nothing to re-assert; their
|
||||||
|
// first dependency arrival publishes normally).
|
||||||
|
foreach (var (vtagId, child) in _children)
|
||||||
|
{
|
||||||
|
if (newlySpawned.Contains(vtagId)) continue;
|
||||||
|
child.Tell(VirtualTagActor.ReassertValue.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
|
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
|
||||||
desired.Count, _children.Count);
|
desired.Count, _children.Count);
|
||||||
}
|
}
|
||||||
@@ -192,15 +215,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
|
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
|
||||||
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
|
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
|
||||||
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
|
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
|
||||||
|
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
|
||||||
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||||
nodeId, result.Value, result.Quality, result.TimestampUtc));
|
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
|
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
|
||||||
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
|
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
|
||||||
// to. For a computed value source == server, so both timestamps are the evaluation time. Bad
|
// node; recording it would append an artificial historian sample that never corresponded to a real
|
||||||
// results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot
|
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
|
||||||
// status — so the historian trend can distinguish a degraded sample from a Good one.
|
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
|
||||||
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
// SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
|
||||||
|
// timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
|
||||||
|
// dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
|
||||||
|
// sample from a Good one.
|
||||||
|
if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||||
{
|
{
|
||||||
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
|
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
|
||||||
_history.Record(nodeId, new DataValueSnapshot(
|
_history.Record(nodeId, new DataValueSnapshot(
|
||||||
@@ -223,9 +251,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared
|
/// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
|
||||||
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also
|
/// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
|
||||||
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
|
/// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
|
||||||
|
/// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
|
||||||
|
/// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
|
||||||
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
|
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
|
||||||
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name);
|
string.IsNullOrWhiteSpace(p.FolderPath)
|
||||||
|
? V3NodeIds.Uns(p.EquipmentId, p.Name)
|
||||||
|
: V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
{
|
{
|
||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
// Must not throw.
|
// Must not throw.
|
||||||
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
{
|
{
|
||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||||
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
|
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- after SetSink — operations are forwarded ----------
|
// ---------- after SetSink — operations are forwarded ----------
|
||||||
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.SetSink(inner);
|
sink.SetSink(inner);
|
||||||
|
|
||||||
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
inner.WriteValueCalled.ShouldBeTrue();
|
inner.WriteValueCalled.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
inner.RebuildCalled.ShouldBeTrue();
|
inner.RebuildCalled.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void After_SetSink_WireAlarmNotifiers_is_forwarded_with_all_args()
|
||||||
|
{
|
||||||
|
// v3 Batch 4 WP4: without this forward the native-alarm multi-notifier fan-out ships INERT on every
|
||||||
|
// driver-role host (actors inject THIS wrapper, not the inner sink) — the F10b / PR#423 trap class.
|
||||||
|
var inner = new SpySink();
|
||||||
|
var sink = new DeferredAddressSpaceSink();
|
||||||
|
sink.SetSink(inner);
|
||||||
|
|
||||||
|
sink.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw,
|
||||||
|
new[] { "EQ-1", "EQ-2" }, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
inner.WireAlarmNotifiersArgs.ShouldNotBeNull();
|
||||||
|
inner.WireAlarmNotifiersArgs!.Value.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
|
||||||
|
inner.WireAlarmNotifiersArgs.Value.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
inner.WireAlarmNotifiersArgs.Value.Folders.ShouldBe(new[] { "EQ-1", "EQ-2" });
|
||||||
|
inner.WireAlarmNotifiersArgs.Value.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- ISurgicalAddressSpaceSink forwarding ----------
|
// ---------- ISurgicalAddressSpaceSink forwarding ----------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -73,7 +92,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
sink.SetSink(new SpySink());
|
sink.SetSink(new SpySink());
|
||||||
|
|
||||||
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||||
dataType: "Int32", isArray: false, arrayLength: null).ShouldBeFalse();
|
dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -84,7 +103,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
sink.SetSink(surgical);
|
sink.SetSink(surgical);
|
||||||
|
|
||||||
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||||
dataType: "Float", isArray: false, arrayLength: null);
|
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
result.ShouldBeTrue();
|
result.ShouldBeTrue();
|
||||||
surgical.UpdateCalled.ShouldBeTrue();
|
surgical.UpdateCalled.ShouldBeTrue();
|
||||||
@@ -97,7 +116,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.SetSink(new SpySink());
|
sink.SetSink(new SpySink());
|
||||||
|
|
||||||
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse();
|
sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,7 +127,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.SetSink(surgical);
|
sink.SetSink(surgical);
|
||||||
|
|
||||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South");
|
var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
result.ShouldBeTrue();
|
result.ShouldBeTrue();
|
||||||
surgical.FolderRenameCalled.ShouldBeTrue();
|
surgical.FolderRenameCalled.ShouldBeTrue();
|
||||||
@@ -122,9 +141,9 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.SetSink(new SpySink());
|
sink.SetSink(new SpySink());
|
||||||
|
|
||||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -134,9 +153,9 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.SetSink(surgical);
|
sink.SetSink(surgical);
|
||||||
|
|
||||||
sink.RemoveVariableNode("v-1").ShouldBeTrue();
|
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue();
|
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
surgical.RemoveVariableCalled.ShouldBeTrue();
|
surgical.RemoveVariableCalled.ShouldBeTrue();
|
||||||
surgical.RemoveAlarmCalled.ShouldBeTrue();
|
surgical.RemoveAlarmCalled.ShouldBeTrue();
|
||||||
@@ -147,9 +166,9 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
public void Before_SetSink_remove_members_return_false()
|
public void Before_SetSink_remove_members_return_false()
|
||||||
{
|
{
|
||||||
var sink = new DeferredAddressSpaceSink();
|
var sink = new DeferredAddressSpaceSink();
|
||||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- SetSink(null) reverts to null sink ----------
|
// ---------- SetSink(null) reverts to null sink ----------
|
||||||
@@ -163,7 +182,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
sink.SetSink(null); // revert
|
sink.SetSink(null); // revert
|
||||||
|
|
||||||
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
|
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
|
||||||
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
|
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -174,7 +193,7 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
sink.SetSink(inner);
|
sink.SetSink(inner);
|
||||||
sink.SetSink(null); // revert
|
sink.SetSink(null); // revert
|
||||||
|
|
||||||
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
|
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
|
||||||
}
|
}
|
||||||
@@ -185,16 +204,20 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
{
|
{
|
||||||
public bool WriteValueCalled { get; private set; }
|
public bool WriteValueCalled { get; private set; }
|
||||||
public bool RebuildCalled { get; private set; }
|
public bool RebuildCalled { get; private set; }
|
||||||
|
public (string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> Folders, AddressSpaceRealm FolderRealm)? WireAlarmNotifiersArgs { get; private set; }
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> WriteValueCalled = true;
|
=> WriteValueCalled = true;
|
||||||
|
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
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, 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) { }
|
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, 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) { }
|
||||||
public void RebuildAddressSpace() => RebuildCalled = true;
|
public void RebuildAddressSpace() => RebuildCalled = true;
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
|
=> WireAlarmNotifiersArgs = (alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||||
@@ -202,21 +225,23 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
public bool UpdateCalled { get; private set; }
|
public bool UpdateCalled { get; private set; }
|
||||||
public bool FolderRenameCalled { get; private set; }
|
public bool FolderRenameCalled { get; private set; }
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
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, 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) { }
|
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, 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) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
|
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
UpdateCalled = true;
|
UpdateCalled = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
FolderRenameCalled = true;
|
FolderRenameCalled = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -226,8 +251,8 @@ public class DeferredAddressSpaceSinkTests
|
|||||||
public bool RemoveAlarmCalled { get; private set; }
|
public bool RemoveAlarmCalled { get; private set; }
|
||||||
public bool RemoveSubtreeCalled { get; private set; }
|
public bool RemoveSubtreeCalled { get; private set; }
|
||||||
|
|
||||||
public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; }
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; }
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; }
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; }
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; }
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveSubtreeCalled = true; return true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -74,6 +74,24 @@ public class DeferredSinkForwardingReflectionTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_node_naming_sink_method_carries_a_realm_discriminator()
|
||||||
|
{
|
||||||
|
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
|
||||||
|
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
|
||||||
|
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
|
||||||
|
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
|
||||||
|
// the realm surface so a future method can't be added that names a node without a realm.
|
||||||
|
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
||||||
|
{
|
||||||
|
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
|
||||||
|
|
||||||
|
method.GetParameters().Any(p => p.ParameterType == typeof(AddressSpaceRealm)).ShouldBeTrue(
|
||||||
|
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
|
||||||
|
"a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher()
|
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
|
|
||||||
|
|
||||||
public class EquipmentNodeIdsTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Variable_with_no_folder_is_equipment_slash_name()
|
|
||||||
=> EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Variable_with_null_folder_is_equipment_slash_name()
|
|
||||||
=> EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Variable_with_folder_is_equipment_slash_folder_slash_name()
|
|
||||||
=> EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Variable_with_whitespace_folder_is_equipment_slash_name()
|
|
||||||
=> EquipmentNodeIds.Variable("eq-1", " ", "speed").ShouldBe("eq-1/speed");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SubFolder_is_equipment_slash_folder()
|
|
||||||
=> EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers");
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ public class NullOpcUaNodeWriteGatewayTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NullGateway_returns_writes_unavailable()
|
public async Task NullGateway_returns_writes_unavailable()
|
||||||
{
|
{
|
||||||
var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, TestContext.Current.CancellationToken);
|
var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, AddressSpaceRealm.Uns, TestContext.Current.CancellationToken);
|
||||||
outcome.Success.ShouldBeFalse();
|
outcome.Success.ShouldBeFalse();
|
||||||
outcome.Reason.ShouldBe("writes unavailable");
|
outcome.Reason.ShouldBe("writes unavailable");
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -17,8 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|||||||
public sealed class DriverProbeRegistrationTests
|
public sealed class DriverProbeRegistrationTests
|
||||||
{
|
{
|
||||||
// The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
|
// The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
|
||||||
// Keep in sync with the DriverTypeKey constants in
|
// Keep in sync with the IDriverProbe registrations in
|
||||||
// src/Server/.../Components/Pages/Clusters/Drivers/*DriverPage.razor.
|
// src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed
|
||||||
|
// *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw
|
||||||
|
// modals, but the probe set is the authority for what the AdminUI can Test-connect).
|
||||||
private static readonly string[] AdminUiDriverTypeKeys =
|
private static readonly string[] AdminUiDriverTypeKeys =
|
||||||
[
|
[
|
||||||
"Modbus",
|
"Modbus",
|
||||||
@@ -29,6 +31,7 @@ public sealed class DriverProbeRegistrationTests
|
|||||||
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
|
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
|
||||||
"OpcUaClient",
|
"OpcUaClient",
|
||||||
"GalaxyMxGateway",
|
"GalaxyMxGateway",
|
||||||
|
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+109
-28
@@ -4,10 +4,12 @@ using Shouldly;
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
@@ -15,15 +17,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// End-to-end deploy of a UNS folder hierarchy through the <b>real</b> <c>ConfigComposer</c>: seed a
|
/// End-to-end deploy of a UNS folder hierarchy through the <b>real</b> <c>ConfigComposer</c>: seed a
|
||||||
/// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device →
|
/// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device →
|
||||||
/// Tag), <c>StartDeployment</c>, then assert the deployment's persisted artifact decodes (via
|
/// Tag) + a <see cref="UnsTagReference"/> projecting the raw tag into the equipment, <c>StartDeployment</c>,
|
||||||
|
/// then assert the deployment's persisted artifact decodes (via
|
||||||
/// <see cref="DeploymentArtifact.ParseComposition"/>).
|
/// <see cref="DeploymentArtifact.ParseComposition"/>).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>v3 DARK address space (Batch 1):</b> the composer + artifact deliberately emit an EMPTY
|
/// <b>v3 dual namespace (Batch 4):</b> the <c>AddressSpaceComposer</c> un-darkens BOTH subtrees. The
|
||||||
/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw
|
/// <see cref="Deploying_a_uns_hierarchy_carries_folder_nodes_and_leaves_equipment_tags_dark"/> case pins the
|
||||||
/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes
|
/// artifact-decode invariant that the retired equipment-namespace <c>EquipmentTags</c> set stays EMPTY
|
||||||
/// decode with their friendly UNS names, and <c>EquipmentTags</c> is empty EVEN THOUGH a raw tag was
|
/// (values now flow through the raw + UNS variable nodes). The
|
||||||
/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is
|
/// <see cref="Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables"/> case drives the
|
||||||
/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete).
|
/// real <c>AddressSpaceComposer</c> over the SAME seeded config (loaded back from the deploy DB) and pins
|
||||||
|
/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the
|
||||||
|
/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out).
|
||||||
|
/// <para><i>Note:</i> the composer is what WP1 lights up. WP3 migrated the artifact-decode seam
|
||||||
|
/// (<c>DeploymentArtifact.ParseComposition</c>) to carry both realms too, so
|
||||||
|
/// <see cref="Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster"/> (added in WP5) pins
|
||||||
|
/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and
|
||||||
|
/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster.</para>
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
|
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
|
||||||
@@ -33,12 +43,6 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EquipmentNamespaceMaterializationTests
|
public sealed class EquipmentNamespaceMaterializationTests
|
||||||
{
|
{
|
||||||
private const string Batch4Pending =
|
|
||||||
"v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " +
|
|
||||||
"not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " +
|
|
||||||
"empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " +
|
|
||||||
"against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes.";
|
|
||||||
|
|
||||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
// Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the
|
// Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the
|
||||||
@@ -95,12 +99,62 @@ public sealed class EquipmentNamespaceMaterializationTests
|
|||||||
composition.EquipmentTags.ShouldBeEmpty();
|
composition.EquipmentTags.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes
|
/// <summary>BATCH-4 dual namespace: the real <see cref="AddressSpaceComposer"/> over the seeded config
|
||||||
/// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark
|
/// (loaded back from the deploy DB) emits BOTH subtrees — the seeded raw tag as a Raw-realm Variable
|
||||||
/// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once
|
/// keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
|
||||||
/// the equipment-tag plan set is lit up.</summary>
|
/// Organizes UNS→Raw target + fan-out source) and inheriting the raw tag's DataType. Drives the composer
|
||||||
[Fact(Skip = Batch4Pending)]
|
/// directly (not the artifact-decode round-trip, which WP3 migrates).</summary>
|
||||||
public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact()
|
[Fact]
|
||||||
|
public async Task Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables()
|
||||||
|
{
|
||||||
|
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||||
|
await harness.SeedDefaultClusterAsync("c1");
|
||||||
|
await SeedUnsHierarchyAsync(harness);
|
||||||
|
|
||||||
|
await using var db = await CreateDbAsync(harness);
|
||||||
|
var composition = AddressSpaceComposer.Compose(
|
||||||
|
await db.UnsAreas.AsNoTracking().ToListAsync(Ct),
|
||||||
|
await db.UnsLines.AsNoTracking().ToListAsync(Ct),
|
||||||
|
await db.Equipment.AsNoTracking().ToListAsync(Ct),
|
||||||
|
await db.DriverInstances.AsNoTracking().ToListAsync(Ct),
|
||||||
|
await db.ScriptedAlarms.AsNoTracking().ToListAsync(Ct),
|
||||||
|
unsTagReferences: await db.UnsTagReferences.AsNoTracking().ToListAsync(Ct),
|
||||||
|
tags: await db.Tags.AsNoTracking().ToListAsync(Ct),
|
||||||
|
rawFolders: await db.RawFolders.AsNoTracking().ToListAsync(Ct),
|
||||||
|
devices: await db.Devices.AsNoTracking().ToListAsync(Ct),
|
||||||
|
tagGroups: await db.TagGroups.AsNoTracking().ToListAsync(Ct));
|
||||||
|
|
||||||
|
// Raw subtree: the seeded raw tag is a Raw-realm Variable keyed by its RawPath (Plant/Modbus/dev-1/Speed).
|
||||||
|
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
||||||
|
rawTag.TagId.ShouldBe("tag-speed");
|
||||||
|
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||||
|
rawTag.DataType.ShouldBe("Float");
|
||||||
|
|
||||||
|
// UNS subtree: the UnsTagReference is a UNS-realm Variable carrying the backing RawPath + inherited type.
|
||||||
|
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||||
|
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
unsVar.EquipmentId.ShouldBe(EquipmentId);
|
||||||
|
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||||
|
unsVar.EffectiveName.ShouldBe("Speed");
|
||||||
|
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); // Organizes UNS→Raw + fan-out
|
||||||
|
unsVar.DataType.ShouldBe("Float");
|
||||||
|
|
||||||
|
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
|
||||||
|
composition.EquipmentTags.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the
|
||||||
|
/// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the
|
||||||
|
/// 2-node cluster, AND the persisted artifact round-trips BOTH realms via
|
||||||
|
/// <see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/> — the raw tag as a Raw-realm
|
||||||
|
/// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
|
||||||
|
/// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the
|
||||||
|
/// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated
|
||||||
|
/// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises
|
||||||
|
/// the full deploy → persisted-artifact → decode path a driver node actually applies.)</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster()
|
||||||
{
|
{
|
||||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||||
await harness.SeedDefaultClusterAsync("c1");
|
await harness.SeedDefaultClusterAsync("c1");
|
||||||
@@ -113,29 +167,51 @@ public sealed class EquipmentNamespaceMaterializationTests
|
|||||||
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
|
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
|
||||||
var deploymentId = result.DeploymentId!.Value.Value;
|
var deploymentId = result.DeploymentId!.Value.Value;
|
||||||
|
|
||||||
|
// Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the
|
||||||
|
// second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery
|
||||||
|
// is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover).
|
||||||
var artifact = Array.Empty<byte>();
|
var artifact = Array.Empty<byte>();
|
||||||
await WaitForAsync(async () =>
|
await WaitForAsync(async () =>
|
||||||
{
|
{
|
||||||
await using var db = await CreateDbAsync(harness);
|
await using var db = await CreateDbAsync(harness);
|
||||||
var d = await db.Deployments.AsNoTracking()
|
var d = await db.Deployments.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
|
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
|
||||||
if (d is { ArtifactBlob.Length: > 0 })
|
if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 })
|
||||||
{
|
{
|
||||||
artifact = d.ArtifactBlob;
|
artifact = d.ArtifactBlob;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, TimeSpan.FromSeconds(15));
|
}, TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
await using (var db = await CreateDbAsync(harness))
|
||||||
|
{
|
||||||
|
var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
|
||||||
|
.Where(s => s.DeploymentId == deploymentId)
|
||||||
|
.ToListAsync(Ct);
|
||||||
|
nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state");
|
||||||
|
nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree.
|
||||||
var composition = DeploymentArtifact.ParseComposition(artifact);
|
var composition = DeploymentArtifact.ParseComposition(artifact);
|
||||||
|
|
||||||
// Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig.
|
// Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath.
|
||||||
var tag = composition.EquipmentTags.ShouldHaveSingleItem();
|
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
||||||
tag.TagId.ShouldBe("tag-speed");
|
rawTag.TagId.ShouldBe("tag-speed");
|
||||||
tag.EquipmentId.ShouldBe(EquipmentId);
|
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
tag.Name.ShouldBe("Speed");
|
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||||
tag.DataType.ShouldBe("Float");
|
|
||||||
tag.FullName.ShouldBe("40001");
|
// UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the
|
||||||
|
// Organizes UNS→Raw target + the fan-out source).
|
||||||
|
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||||
|
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
unsVar.EquipmentId.ShouldBe(EquipmentId);
|
||||||
|
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||||
|
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||||
|
|
||||||
|
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
|
||||||
|
composition.EquipmentTags.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
|
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
|
||||||
@@ -170,6 +246,11 @@ public sealed class EquipmentNamespaceMaterializationTests
|
|||||||
Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read,
|
Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"40001\"}",
|
TagConfig = "{\"FullName\":\"40001\"}",
|
||||||
});
|
});
|
||||||
|
// v3 Batch 4: the equipment projects the raw tag by reference (reference-only UNS).
|
||||||
|
db.UnsTagReferences.Add(new UnsTagReference
|
||||||
|
{
|
||||||
|
UnsTagReferenceId = "ref-speed", EquipmentId = EquipmentId, TagId = "tag-speed",
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync(Ct);
|
await db.SaveChangesAsync(Ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
|||||||
using Akka.Hosting;
|
using Akka.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -68,7 +69,7 @@ public sealed class PrimaryGateFailoverTests
|
|||||||
|
|
||||||
private static async Task<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost)
|
private static async Task<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost)
|
||||||
=> await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
=> await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
||||||
new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0), TimeSpan.FromSeconds(10));
|
new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0, AddressSpaceRealm.Uns), TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
private static bool IsGateReject(DriverHostActor.NodeWriteResult r)
|
private static bool IsGateReject(DriverHostActor.NodeWriteResult r)
|
||||||
=> !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal);
|
=> !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal);
|
||||||
|
|||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real
|
||||||
|
/// <see cref="OtOpcUaSdkServer"/> (exactly as <c>SubscriptionSurvivalTests</c> / the multi-notifier
|
||||||
|
/// alarm test do), materialises a raw device folder + a raw tag Variable (<c>ns=Raw, s=<RawPath></c>)
|
||||||
|
/// and an equipment folder + a UNS reference Variable (<c>ns=UNS, s=<Area/Line/Equip/Eff></c>) through
|
||||||
|
/// the PRODUCTION <see cref="SdkAddressSpaceSink"/>, wires the <c>Organizes</c> UNS→Raw edge, then drives a
|
||||||
|
/// real OPC UA client to assert:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>BOTH namespace URIs (<see cref="V3NodeIds.RawNamespaceUri"/> +
|
||||||
|
/// <see cref="V3NodeIds.UnsNamespaceUri"/>) are registered + distinct (they replaced the single
|
||||||
|
/// <c>.../ns</c>).</item>
|
||||||
|
/// <item>Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its
|
||||||
|
/// equipment-path NodeId.</item>
|
||||||
|
/// <item>The UNS Variable <c>Organizes</c>-references its backing raw node.</item>
|
||||||
|
/// <item>Single-source fan-out parity: one driver publish (modelled as a
|
||||||
|
/// <see cref="SdkAddressSpaceSink.WriteValue"/> to each NodeId with identical value/quality/timestamp)
|
||||||
|
/// is read back IDENTICALLY on both NodeIds.</item>
|
||||||
|
/// <item>HistoryRead via EITHER NodeId returns <c>GoodNoData</c> under the shared historian tagname (the
|
||||||
|
/// <c>NullHistorianDataSource</c> default — the offline parity path).</item>
|
||||||
|
/// <item>The <c>WriteOperate</c> gate is symmetric across both NodeIds: an anonymous client write is
|
||||||
|
/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE
|
||||||
|
/// realm-qualified routing path is unit-covered in <c>DriverHostActorWriteRoutingTests</c> /
|
||||||
|
/// <c>NodeManagerWriteRevertTests</c>, which drive a role-carrying identity the node manager
|
||||||
|
/// directly).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully
|
||||||
|
/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DualNamespaceAddressSpaceTests
|
||||||
|
{
|
||||||
|
private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace";
|
||||||
|
|
||||||
|
// Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath.
|
||||||
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||||
|
private const string RawTagPath = "Plant/Modbus/dev1/Speed";
|
||||||
|
// UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path.
|
||||||
|
private const string EquipFolder = "filling/line1/station1";
|
||||||
|
private const string UnsVarPath = "filling/line1/station1/Speed";
|
||||||
|
// Both NodeIds register the SAME historian tagname (the raw tag's RawPath).
|
||||||
|
private const string HistorianTagname = RawTagPath;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
MaterialiseDualTree(sink);
|
||||||
|
// One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload.
|
||||||
|
var ts = DateTime.UtcNow;
|
||||||
|
sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
||||||
|
sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
|
||||||
|
// (1) both namespaces registered + distinct.
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||||
|
rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered");
|
||||||
|
unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered");
|
||||||
|
rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices");
|
||||||
|
|
||||||
|
var rawNodeId = new NodeId(RawTagPath, rawNs);
|
||||||
|
var unsNodeId = new NodeId(UnsVarPath, unsNs);
|
||||||
|
|
||||||
|
// (2) both subtrees browse + read.
|
||||||
|
var rawValue = await session.ReadValueAsync(rawNodeId, ct);
|
||||||
|
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
|
||||||
|
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
|
||||||
|
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
|
||||||
|
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
|
||||||
|
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
|
||||||
|
|
||||||
|
// (3) the UNS Variable Organizes-references its backing raw node.
|
||||||
|
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
|
||||||
|
var organizesRaw = refs.Any(r =>
|
||||||
|
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
||||||
|
r.NodeId.NamespaceIndex == rawNs &&
|
||||||
|
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
|
||||||
|
organizesRaw.ShouldBeTrue(
|
||||||
|
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
|
||||||
|
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
MaterialiseDualTree(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||||
|
|
||||||
|
// The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with
|
||||||
|
// identical value/quality/timestamp — the fan-out drift invariant.
|
||||||
|
var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc);
|
||||||
|
sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
||||||
|
sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
|
||||||
|
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
|
||||||
|
|
||||||
|
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
|
||||||
|
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
|
||||||
|
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
|
||||||
|
unsValue.SourceTimestamp.ShouldBe(ts);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname)
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||||
|
|
||||||
|
var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct);
|
||||||
|
var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct);
|
||||||
|
|
||||||
|
// NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an
|
||||||
|
// error) — identical for the raw and the UNS NodeId under the one registered historian tagname.
|
||||||
|
rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead");
|
||||||
|
unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteOperate_gate_is_symmetric_across_both_nodeids()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
MaterialiseDualTree(sink); // both variables writable
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||||
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||||
|
|
||||||
|
var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct);
|
||||||
|
var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct);
|
||||||
|
|
||||||
|
// The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate
|
||||||
|
// rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a
|
||||||
|
// UNS write is neither more nor less privileged than the raw write it fans from).
|
||||||
|
rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)");
|
||||||
|
unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference
|
||||||
|
/// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge.</summary>
|
||||||
|
private static void MaterialiseDualTree(SdkAddressSpaceSink sink)
|
||||||
|
{
|
||||||
|
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true,
|
||||||
|
realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname);
|
||||||
|
|
||||||
|
sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true,
|
||||||
|
realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname);
|
||||||
|
|
||||||
|
// Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source).
|
||||||
|
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
|
||||||
|
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
|
||||||
|
{
|
||||||
|
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
|
||||||
|
var (_, cp, refs) = await session.BrowseAsync(
|
||||||
|
null, null, node, 0u, BrowseDirection.Forward, referenceType,
|
||||||
|
includeSubtypes: true, nodeClassMask: 0u);
|
||||||
|
return (cp, refs ?? new ReferenceDescriptionCollection());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var details = new ReadRawModifiedDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
IsReadModified = false,
|
||||||
|
ReturnBounds = false,
|
||||||
|
};
|
||||||
|
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
|
||||||
|
var response = await session.HistoryReadAsync(
|
||||||
|
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
|
||||||
|
response.Results.ShouldNotBeNull();
|
||||||
|
response.Results.Count.ShouldBe(1);
|
||||||
|
return response.Results[0].StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var writeCollection = new WriteValueCollection
|
||||||
|
{
|
||||||
|
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
|
||||||
|
};
|
||||||
|
var response = await session.WriteAsync(null, writeCollection, ct);
|
||||||
|
response.Results.ShouldNotBeNull();
|
||||||
|
response.Results.Count.ShouldBe(1);
|
||||||
|
return response.Results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
||||||
|
int port, string pkiRoot, string appUri, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var options = new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = appUri,
|
||||||
|
ApplicationUri = appUri,
|
||||||
|
OpcUaPort = port,
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
PkiStoreRoot = pkiRoot,
|
||||||
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
};
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await host.StartAsync(server, ct);
|
||||||
|
return (server, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.DualNamespaceClient",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||||
|
};
|
||||||
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||||
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||||
|
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||||
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||||
|
|
||||||
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||||
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||||
|
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
|
||||||
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SafeDelete(string dir)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP4) — the HEADLINE over-the-wire proof for the multi-notifier native alarm (Wave C
|
||||||
|
/// review M1): a SINGLE native transition on a condition wired to MULTIPLE notifier roots (its raw
|
||||||
|
/// device-folder parent + N referencing equipment folders) delivers <b>exactly one</b> event copy to a
|
||||||
|
/// Server-object event subscriber — the Part 9 shared-<c>InstanceStateSnapshot</c> dedup
|
||||||
|
/// (<c>MonitoredItem.QueueEvent</c> → <c>IsEventContainedInQueue</c>), NOT one copy per root. This is the
|
||||||
|
/// regression guard the design's "duplicate-report fallback rejected" decision rests on, and it is exactly
|
||||||
|
/// the multi-root topology (2 equipment folders → 2 notifier paths up to Server) where a per-root
|
||||||
|
/// re-report would surface as double delivery.
|
||||||
|
///
|
||||||
|
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> exactly as <c>SubscriptionSurvivalTests</c> does,
|
||||||
|
/// materialises the raw condition + two referencing equipment folders + the notifier wiring through the
|
||||||
|
/// production <see cref="SdkAddressSpaceSink"/>, opens a real client subscription with event monitored
|
||||||
|
/// items (captured via the subscription <see cref="Subscription.FastEventCallback"/>, keyed by
|
||||||
|
/// <c>ClientHandle</c>), fires ONE transition via <see cref="SdkAddressSpaceSink.WriteAlarmCondition"/>,
|
||||||
|
/// and counts the delivered events. Heavy in-process server+client integration — runs in the serial
|
||||||
|
/// integration pass, not the lightweight unit sweep.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NativeAlarmMultiNotifierEventDeliveryTests
|
||||||
|
{
|
||||||
|
private const string ServerUri = "urn:OtOpcUa.MultiNotifierEventDelivery";
|
||||||
|
|
||||||
|
// The raw device-oriented topology (ns=Raw) + two referencing equipment folders (ns=UNS).
|
||||||
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||||
|
private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
|
||||||
|
private const string Equip1 = "EQ-filling-line1-station1";
|
||||||
|
private const string Equip2 = "EQ-filling-line2-station2";
|
||||||
|
private const string AlarmMessage = "over-temperature (multi-notifier test)";
|
||||||
|
|
||||||
|
// Index of the Message select clause in the event filter (see BuildEventFilter): [0]=EventId, [1]=Message.
|
||||||
|
private const int MessageFieldIndex = 1;
|
||||||
|
|
||||||
|
/// <summary>One native transition on a condition wired to two equipment folders delivers EXACTLY ONE event
|
||||||
|
/// to a Server-object subscriber (not one per notifier root) — the shared-snapshot queue dedup.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Single_transition_delivers_exactly_one_event_at_the_Server_object()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-evt-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireSingleConditionToTwoEquipment(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
var collector = new EventCollector();
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
var serverItem = AddEventItem(subscription, ObjectIds.Server);
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
// Fire ONE genuine transition (inactive+acked → active+unacked). No ConditionRefresh is issued, so
|
||||||
|
// the ONLY event is this transition — a per-root re-report would show up as a second copy.
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5));
|
||||||
|
// Settle past the publishing interval so any (erroneous) duplicate would have arrived.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||||
|
|
||||||
|
collector.CountFor(serverItem.ClientHandle).ShouldBe(1,
|
||||||
|
"the single condition must deliver exactly ONE event to the Server object despite two notifier " +
|
||||||
|
"roots (2 equipment folders) — a per-root re-report would deliver 2.");
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Overlapping subscriptions: event monitored items at the Server object AND an equipment folder
|
||||||
|
/// each receive EXACTLY ONE copy of the one transition — the multi-root topology does not multiply delivery
|
||||||
|
/// to any single subscriber, and the equipment-folder subscriber (ns=UNS) sees the native alarm without
|
||||||
|
/// touching ns=Raw.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Overlapping_folder_and_server_subscriptions_each_receive_exactly_one_copy()
|
||||||
|
{
|
||||||
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-ovl-{Guid.NewGuid():N}");
|
||||||
|
var port = AllocateFreePort();
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Overlap", ct);
|
||||||
|
await using var _ = host;
|
||||||
|
|
||||||
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
WireSingleConditionToTwoEquipment(sink);
|
||||||
|
|
||||||
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
|
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||||
|
unsNs.ShouldBeGreaterThan((ushort)0);
|
||||||
|
|
||||||
|
var collector = new EventCollector();
|
||||||
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||||
|
subscription.FastEventCallback = collector.OnEvents;
|
||||||
|
session.AddSubscription(subscription);
|
||||||
|
await subscription.CreateAsync(ct);
|
||||||
|
|
||||||
|
var serverItem = AddEventItem(subscription, ObjectIds.Server);
|
||||||
|
var equipItem = AddEventItem(subscription, new NodeId(Equip1, unsNs));
|
||||||
|
await subscription.ApplyChangesAsync(ct);
|
||||||
|
|
||||||
|
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() =>
|
||||||
|
collector.CountFor(serverItem.ClientHandle) >= 1 &&
|
||||||
|
collector.CountFor(equipItem.ClientHandle) >= 1,
|
||||||
|
TimeSpan.FromSeconds(5));
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||||
|
|
||||||
|
// Each subscriber gets exactly one copy — the shared snapshot is deduped per monitored item; the
|
||||||
|
// multi-root topology never multiplies delivery.
|
||||||
|
collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "Server-object subscriber");
|
||||||
|
collector.CountFor(equipItem.ClientHandle).ShouldBe(1, "equipment-folder subscriber (ns=UNS) sees the native alarm");
|
||||||
|
|
||||||
|
await subscription.DeleteAsync(true, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SafeDelete(pkiRoot + "-srv");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Materialise the raw device folder + the single native condition at its RawPath + two referencing
|
||||||
|
/// equipment folders (UNS), then wire the condition as an event notifier of both equipment folders.</summary>
|
||||||
|
private static void WireSingleConditionToTwoEquipment(SdkAddressSpaceSink sink)
|
||||||
|
{
|
||||||
|
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
|
||||||
|
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
sink.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
|
||||||
|
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmConditionSnapshot ActiveSnapshot() =>
|
||||||
|
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||||
|
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
|
||||||
|
|
||||||
|
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
|
||||||
|
{
|
||||||
|
var item = new MonitoredItem(subscription.DefaultItem)
|
||||||
|
{
|
||||||
|
StartNodeId = source,
|
||||||
|
AttributeId = Attributes.EventNotifier,
|
||||||
|
Filter = BuildEventFilter(),
|
||||||
|
QueueSize = 100,
|
||||||
|
SamplingInterval = 0,
|
||||||
|
};
|
||||||
|
subscription.AddItem(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An EventFilter selecting [0]=EventId (dedup identity) and [1]=Message (our discriminator).</summary>
|
||||||
|
private static EventFilter BuildEventFilter()
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
|
||||||
|
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collects events delivered to a subscription's <see cref="Subscription.FastEventCallback"/>,
|
||||||
|
/// tallying per monitored-item ClientHandle and filtering to our unique alarm Message (so unrelated server
|
||||||
|
/// events / refresh brackets never count).</summary>
|
||||||
|
private sealed class EventCollector
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<uint, int> _byHandle = new();
|
||||||
|
|
||||||
|
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var e in notification.Events)
|
||||||
|
{
|
||||||
|
if (e.EventFields.Count > MessageFieldIndex &&
|
||||||
|
e.EventFields[MessageFieldIndex].Value is LocalizedText lt &&
|
||||||
|
lt.Text == AlarmMessage)
|
||||||
|
{
|
||||||
|
_byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountFor(uint clientHandle)
|
||||||
|
{
|
||||||
|
lock (_gate) return _byHandle.GetValueOrDefault(clientHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
||||||
|
int port, string pkiRoot, string appUri, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var options = new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = appUri,
|
||||||
|
ApplicationUri = appUri,
|
||||||
|
OpcUaPort = port,
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
PkiStoreRoot = pkiRoot,
|
||||||
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
};
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await host.StartAsync(server, ct);
|
||||||
|
return (server, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var appConfig = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||||
|
};
|
||||||
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||||
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||||
|
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||||
|
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||||
|
|
||||||
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||||
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||||
|
sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000,
|
||||||
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition()) return;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
condition().ShouldBeTrue("condition not met within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SafeDelete(string dir)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-16
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Client;
|
using Opc.Ua.Client;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||||
@@ -65,8 +66,8 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
||||||
applier.MaterialiseHierarchy(withA);
|
applier.MaterialiseHierarchy(withA);
|
||||||
applier.MaterialiseEquipmentTags(withA);
|
applier.MaterialiseEquipmentTags(withA);
|
||||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
// --- Open a client session + subscription + monitored item on A. ---
|
// --- Open a client session + subscription + monitored item on A. ---
|
||||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
@@ -115,7 +116,7 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
|
|
||||||
// --- A's monitored item must still be alive: a new write to A is delivered. ---
|
// --- A's monitored item must still be alive: a new write to A is delivered. ---
|
||||||
lock (gate) received.Clear();
|
lock (gate) received.Clear();
|
||||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
|
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
|
||||||
lock (gate)
|
lock (gate)
|
||||||
@@ -125,8 +126,8 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- B is browsable + readable (the add landed). ---
|
// --- B is browsable + readable (the add landed). ---
|
||||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
var bValue = await session.ReadValueAsync(nodeIdB, ct);
|
var bValue = await session.ReadValueAsync(nodeIdB, ct);
|
||||||
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
|
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
|
||||||
bValue.Value.ShouldBe(7);
|
bValue.Value.ShouldBe(7);
|
||||||
@@ -173,10 +174,10 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||||
applier.MaterialiseHierarchy(withAB);
|
applier.MaterialiseHierarchy(withAB);
|
||||||
applier.MaterialiseEquipmentTags(withAB);
|
applier.MaterialiseEquipmentTags(withAB);
|
||||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||||
var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B");
|
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
|
||||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||||
@@ -212,7 +213,7 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
|
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// A stays alive: a fresh write to A is delivered.
|
// A stays alive: a fresh write to A is delivered.
|
||||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
|
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
|
||||||
@@ -261,8 +262,8 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||||
applier.MaterialiseHierarchy(withAB);
|
applier.MaterialiseHierarchy(withAB);
|
||||||
applier.MaterialiseEquipmentTags(withAB);
|
applier.MaterialiseEquipmentTags(withAB);
|
||||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||||
@@ -295,18 +296,18 @@ public sealed class SubscriptionSurvivalTests
|
|||||||
applier.AnnounceAddedNodes(plan);
|
applier.AnnounceAddedNodes(plan);
|
||||||
|
|
||||||
// A stays alive.
|
// A stays alive.
|
||||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
|
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
|
||||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||||
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
|
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
|
||||||
async () => await session.ReadValueAsync(nodeIdB, ct));
|
async () => await session.ReadValueAsync(nodeIdB, ct));
|
||||||
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||||
|
|
||||||
// C is browsable + readable.
|
// C is browsable + readable.
|
||||||
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns);
|
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
|
||||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
var cRead = await session.ReadValueAsync(nodeIdC, ct);
|
var cRead = await session.ReadValueAsync(nodeIdC, ct);
|
||||||
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||||
cRead.Value.ShouldBe(9);
|
cRead.Value.ShouldBe(9);
|
||||||
|
|||||||
+8
-6
@@ -161,24 +161,24 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
|||||||
public bool ThrowOnAlarmWrite { get; init; }
|
public bool ThrowOnAlarmWrite { get; init; }
|
||||||
public bool ThrowOnMaterialiseAlarm { get; init; }
|
public bool ThrowOnMaterialiseAlarm { get; init; }
|
||||||
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
{
|
{
|
||||||
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
|
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
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, 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)
|
||||||
{
|
{
|
||||||
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
|
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
|
||||||
}
|
}
|
||||||
@@ -188,6 +188,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
|||||||
if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault");
|
if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -307,24 +307,24 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The OPC UA quality value.</param>
|
/// <param name="quality">The OPC UA quality value.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
|
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
|
||||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
/// <summary>Records a folder creation request.</summary>
|
/// <summary>Records a folder creation request.</summary>
|
||||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
|
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
|
||||||
/// <param name="variableNodeId">The node ID of the variable.</param>
|
/// <param name="variableNodeId">The node ID of the variable.</param>
|
||||||
@@ -333,10 +333,12 @@ 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, 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) { }
|
||||||
/// <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>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-10
@@ -292,8 +292,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
|||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||||
{
|
{
|
||||||
RemovedEquipmentTags = new[] { removedTag },
|
RemovedRawTags = new[] { removedTag },
|
||||||
ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) },
|
ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) },
|
||||||
};
|
};
|
||||||
|
|
||||||
applier.Apply(plan);
|
applier.Apply(plan);
|
||||||
@@ -324,15 +324,20 @@ public sealed class AddressSpaceApplierProvisioningTests
|
|||||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
|
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
// v3 Batch 4: historized value tags are RAW tags — the mux ref + historian default are the RawPath
|
||||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
|
// (== NodeId). The `fullName` param plays the role of the RawPath here (kept as a param name so the
|
||||||
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
|
// existing assertions — HistorizedTagRef("ref"/"40001", …) — read unchanged).
|
||||||
|
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||||
|
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||||
|
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||||
|
IsHistorized: true, HistorianTagname: historianName);
|
||||||
|
|
||||||
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
|
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
|
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||||
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
|
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||||
|
IsHistorized: false, HistorianTagname: null);
|
||||||
|
|
||||||
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
|
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
|
||||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||||
@@ -343,6 +348,6 @@ public sealed class AddressSpaceApplierProvisioningTests
|
|||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||||
{
|
{
|
||||||
AddedEquipmentTags = tags,
|
AddedRawTags = tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
|
||||||
|
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
|
||||||
|
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
|
||||||
|
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
|
||||||
|
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
|
||||||
|
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
|
||||||
|
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
|
||||||
|
/// its realm EXPLICITLY.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddressSpaceApplierRawUnsTests
|
||||||
|
{
|
||||||
|
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
|
||||||
|
new(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawContainers = new[]
|
||||||
|
{
|
||||||
|
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
|
||||||
|
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
|
||||||
|
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
|
||||||
|
},
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
|
||||||
|
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
// Containers are folders in the Raw realm, parents-before-children.
|
||||||
|
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
|
||||||
|
{
|
||||||
|
("Plant", AddressSpaceRealm.Raw),
|
||||||
|
("Plant/Modbus", AddressSpaceRealm.Raw),
|
||||||
|
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
|
||||||
|
});
|
||||||
|
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
|
||||||
|
var v = sink.Variables.ShouldHaveSingleItem();
|
||||||
|
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||||
|
v.Parent.ShouldBe("Plant/Modbus/dev1");
|
||||||
|
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
v.Writable.ShouldBeTrue();
|
||||||
|
v.HistorianTagname.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
// Historized, no override → historian tagname defaults to the RawPath.
|
||||||
|
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
|
||||||
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
|
||||||
|
// Historized with override → the override wins.
|
||||||
|
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
|
||||||
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
var byId = sink.Variables.ToDictionary(v => v.NodeId);
|
||||||
|
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
|
||||||
|
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||||
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||||
|
ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
|
||||||
|
var c = sink.Conditions.ShouldHaveSingleItem();
|
||||||
|
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
|
||||||
|
c.Parent.ShouldBe("Plant/A/dev");
|
||||||
|
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
c.IsNative.ShouldBeTrue();
|
||||||
|
// No referencing equipment ⇒ no multi-notifier wiring.
|
||||||
|
sink.NotifierWirings.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
|
||||||
|
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
new[] { new UnsAreaProjection("a1", "filling") },
|
||||||
|
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new EquipmentNode("EQ-1", "station1", "l1"),
|
||||||
|
new EquipmentNode("EQ-2", "station2", "l2"),
|
||||||
|
},
|
||||||
|
Array.Empty<DriverInstancePlan>(),
|
||||||
|
Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||||
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||||
|
// The composer emits Area/Line/Equipment NAME paths here.
|
||||||
|
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
// The single condition is materialised ONCE at the RawPath (Raw realm)...
|
||||||
|
var c = sink.Conditions.ShouldHaveSingleItem();
|
||||||
|
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||||
|
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
|
||||||
|
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
|
||||||
|
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
||||||
|
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||||
|
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name
|
||||||
|
/// coupling). The composer builds a native alarm's <c>ReferencingEquipmentPaths</c> from the
|
||||||
|
/// Area/Line/Equipment <c>Name</c>s; the applier's <c>BuildEquipmentIdByFolderPath</c> inverts those
|
||||||
|
/// paths back to the <c>EquipmentId</c> via the projected DisplayName. Feed a REAL composer output
|
||||||
|
/// (not a hand-built plan) through <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> and assert
|
||||||
|
/// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree
|
||||||
|
/// end-to-end. If a future editable Area/Line display-name feature made <c>DisplayName != Name</c>, this
|
||||||
|
/// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier()
|
||||||
|
{
|
||||||
|
// Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" /
|
||||||
|
// Line "line-1" / Equipment "station-1").
|
||||||
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
||||||
|
var driver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||||
|
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
||||||
|
var speed = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
||||||
|
};
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||||
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||||
|
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
||||||
|
|
||||||
|
var composition = AddressSpaceComposer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip },
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: new[] { refSpeed },
|
||||||
|
tags: new[] { speed },
|
||||||
|
rawFolders: new[] { folder },
|
||||||
|
devices: new[] { device },
|
||||||
|
tagGroups: Array.Empty<TagGroup>());
|
||||||
|
|
||||||
|
// The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names.
|
||||||
|
var alarmTag = composition.RawTags.Single(t => t.Alarm is not null);
|
||||||
|
alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") });
|
||||||
|
|
||||||
|
// The applier inverts that path back to the EquipmentId folder and wires the notifier there.
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
||||||
|
w.AlarmNodeId.ShouldBe(alarmTag.NodeId);
|
||||||
|
w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an
|
||||||
|
/// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed
|
||||||
|
/// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the
|
||||||
|
/// condition itself is still materialised.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
// No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder.
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||||
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||||
|
ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var failed = NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||||
|
|
||||||
|
failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip)
|
||||||
|
sink.NotifierWirings.ShouldBeEmpty(); // nothing wired
|
||||||
|
sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
|
||||||
|
{
|
||||||
|
var sink = new RealmRecordingSink();
|
||||||
|
var composition = new AddressSpaceComposition(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
RawTags = new[]
|
||||||
|
{
|
||||||
|
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
|
||||||
|
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
||||||
|
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
||||||
|
IsHistorized: true, HistorianTagname: "WW.Speed"),
|
||||||
|
},
|
||||||
|
UnsReferenceVariables = new[]
|
||||||
|
{
|
||||||
|
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||||
|
"Plant/A/dev/speed", "Float", Writable: true),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
NewApplier(sink).MaterialiseUnsReferences(composition);
|
||||||
|
|
||||||
|
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
|
||||||
|
var v = sink.Variables.ShouldHaveSingleItem();
|
||||||
|
v.NodeId.ShouldBe("filling/line1/station1/speed");
|
||||||
|
v.Parent.ShouldBe("EQ-1");
|
||||||
|
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
v.Writable.ShouldBeTrue();
|
||||||
|
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
|
||||||
|
|
||||||
|
// An Organizes reference UNS→Raw links the two.
|
||||||
|
var r = sink.References.ShouldHaveSingleItem();
|
||||||
|
r.Source.ShouldBe("filling/line1/station1/speed");
|
||||||
|
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
r.Target.ShouldBe("Plant/A/dev/speed");
|
||||||
|
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
r.ReferenceType.ShouldBe("Organizes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
|
||||||
|
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
|
||||||
|
{
|
||||||
|
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
|
||||||
|
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
|
||||||
|
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
||||||
|
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
||||||
|
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
|
||||||
|
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||||
|
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||||
|
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||||
|
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
|
||||||
|
|
||||||
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
|
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
||||||
|
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||||
|
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
|
||||||
|
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||||
|
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||||
|
|
||||||
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||||
|
public void RebuildAddressSpace() { }
|
||||||
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,7 +148,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
||||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
||||||
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
// A Read plan threads Writable: false (the node stays CurrentRead).
|
// A Read plan threads Writable: false (the node stays CurrentRead).
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
||||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
||||||
@@ -228,13 +228,13 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
|
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
|
||||||
var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
|
||||||
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
|
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
|
||||||
|
|
||||||
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
|
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
|
||||||
// matching display/type/severity) and did NOT drive EnsureVariable.
|
// matching display/type/severity) and did NOT drive EnsureVariable.
|
||||||
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
|
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||||
// A native equipment-tag alarm: the call-site threads isNative: true.
|
// A native equipment-tag alarm: the call-site threads isNative: true.
|
||||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||||
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
|
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
|
||||||
@@ -264,7 +264,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
// The sub-folder is still created for an alarm tag with a FolderPath.
|
// The sub-folder is still created for an alarm tag with a FolderPath.
|
||||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||||
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
|
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
|
||||||
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp");
|
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
|
||||||
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
|
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
|
||||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||||
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
|
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
|
||||||
@@ -300,9 +300,9 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
|
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
|
||||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
||||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
||||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
|
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
|
||||||
@@ -326,7 +326,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
var call = sink.HistorianCalls.ShouldHaveSingleItem();
|
var call = sink.HistorianCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
call.HistorianTagname.ShouldBe("40001");
|
call.HistorianTagname.ShouldBe("40001");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
|
||||||
call.IsArray.ShouldBeTrue();
|
call.IsArray.ShouldBeTrue();
|
||||||
call.ArrayLength.ShouldBe(16u);
|
call.ArrayLength.ShouldBe(16u);
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
call.IsArray.ShouldBeFalse();
|
call.IsArray.ShouldBeFalse();
|
||||||
call.ArrayLength.ShouldBeNull();
|
call.ArrayLength.ShouldBeNull();
|
||||||
}
|
}
|
||||||
@@ -471,11 +471,11 @@ public sealed class AddressSpaceApplierTests
|
|||||||
// VirtualTags are computed outputs — always read-only (Writable: false).
|
// VirtualTags are computed outputs — always read-only (Writable: false).
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||||
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
||||||
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — the
|
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
|
||||||
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
|
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
|
||||||
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
||||||
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
||||||
@@ -507,10 +507,10 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||||
|
|
||||||
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
||||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency"));
|
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
|
||||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg"));
|
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||||
@@ -760,7 +760,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
sink.RebuildCalls.ShouldBe(0);
|
sink.RebuildCalls.ShouldBe(0);
|
||||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
call.Writable.ShouldBeTrue();
|
call.Writable.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,7 +815,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
|
|
||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
sink.RebuildCalls.ShouldBe(0);
|
sink.RebuildCalls.ShouldBe(0);
|
||||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||||
// Terminal Bad written to the removed variable BEFORE the removal.
|
// Terminal Bad written to the removed variable BEFORE the removal.
|
||||||
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
|
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
|
||||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
|
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
|
||||||
@@ -842,7 +842,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
var outcome = applier.Apply(plan);
|
var outcome = applier.Apply(plan);
|
||||||
|
|
||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
|
var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||||
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
|
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
|
||||||
var write = sink.AlarmWrites.ShouldHaveSingleItem();
|
var write = sink.AlarmWrites.ShouldHaveSingleItem();
|
||||||
write.NodeId.ShouldBe(nodeId);
|
write.NodeId.ShouldBe(nodeId);
|
||||||
@@ -953,7 +953,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
sink.RebuildCalls.ShouldBe(0);
|
sink.RebuildCalls.ShouldBe(0);
|
||||||
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
|
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
|
||||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old")));
|
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old")));
|
||||||
outcome.AddedNodes.ShouldBe(1);
|
outcome.AddedNodes.ShouldBe(1);
|
||||||
outcome.RemovedNodes.ShouldBe(1);
|
outcome.RemovedNodes.ShouldBe(1);
|
||||||
}
|
}
|
||||||
@@ -967,7 +967,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
var sink = new RecordingSink();
|
var sink = new RecordingSink();
|
||||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||||
|
|
||||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot");
|
var nodeId = V3NodeIds.Uns("eq-1", "Slot");
|
||||||
var plan = EmptyPlan with
|
var plan = EmptyPlan with
|
||||||
{
|
{
|
||||||
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
|
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
|
||||||
@@ -1457,8 +1457,8 @@ public sealed class AddressSpaceApplierTests
|
|||||||
sink.RebuildCalls.ShouldBe(0);
|
sink.RebuildCalls.ShouldBe(0);
|
||||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted
|
outcome.RemovedNodes.ShouldBe(2); // both removals counted
|
||||||
sink.RemoveCalls.Count.ShouldBe(2);
|
sink.RemoveCalls.Count.ShouldBe(2);
|
||||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
|
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed")));
|
||||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
|
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
||||||
@@ -1490,7 +1490,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
||||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
call.Writable.ShouldBeTrue(); // the NEW Writable value
|
call.Writable.ShouldBeTrue(); // the NEW Writable value
|
||||||
call.Historian.ShouldBeNull(); // not historized
|
call.Historian.ShouldBeNull(); // not historized
|
||||||
outcome.ChangedNodes.ShouldBe(1);
|
outcome.ChangedNodes.ShouldBe(1);
|
||||||
@@ -1584,7 +1584,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
outcome.RebuildCalled.ShouldBeFalse();
|
outcome.RebuildCalled.ShouldBeFalse();
|
||||||
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
|
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
|
||||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||||
call.DataType.ShouldBe("Int32"); // the NEW DataType
|
call.DataType.ShouldBe("Int32"); // the NEW DataType
|
||||||
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
|
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
|
||||||
call.IsArray.ShouldBeFalse();
|
call.IsArray.ShouldBeFalse();
|
||||||
@@ -1781,8 +1781,8 @@ public sealed class AddressSpaceApplierTests
|
|||||||
sink.SurgicalCalls.Count.ShouldBe(2);
|
sink.SurgicalCalls.Count.ShouldBe(2);
|
||||||
|
|
||||||
// Both expected node ids must appear (order is not guaranteed).
|
// Both expected node ids must appear (order is not guaranteed).
|
||||||
var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
|
||||||
var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure");
|
var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
|
||||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
|
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
|
||||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
|
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
|
||||||
|
|
||||||
@@ -2040,7 +2040,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
|
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
|
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
|
||||||
@@ -2101,7 +2101,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
applier.AnnounceAddedNodes(plan);
|
applier.AnnounceAddedNodes(plan);
|
||||||
|
|
||||||
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
|
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
|
||||||
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
|
new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
|
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
|
||||||
@@ -2170,7 +2170,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="dataType">The new OPC UA data type name to apply in place.</param>
|
/// <param name="dataType">The new OPC UA data type name to apply in place.</param>
|
||||||
/// <param name="isArray">The new array-ness of the node.</param>
|
/// <param name="isArray">The new array-ness of the node.</param>
|
||||||
/// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
|
/// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
|
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
|
||||||
return SurgicalReturns;
|
return SurgicalReturns;
|
||||||
@@ -2187,7 +2187,7 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
|
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
|
||||||
/// <param name="folderNodeId">The folder node ID to update in place.</param>
|
/// <param name="folderNodeId">The folder node ID to update in place.</param>
|
||||||
/// <param name="displayName">The new display name to apply.</param>
|
/// <param name="displayName">The new display name to apply.</param>
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
||||||
return FolderRenameReturns;
|
return FolderRenameReturns;
|
||||||
@@ -2202,21 +2202,21 @@ public sealed class AddressSpaceApplierTests
|
|||||||
public bool RemoveReturns { get; init; } = true;
|
public bool RemoveReturns { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||||
public bool RemoveVariableNode(string variableNodeId)
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
RemoveQueue.Enqueue(("var", variableNodeId));
|
RemoveQueue.Enqueue(("var", variableNodeId));
|
||||||
return RemoveReturns;
|
return RemoveReturns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
RemoveQueue.Enqueue(("alarm", alarmNodeId));
|
RemoveQueue.Enqueue(("alarm", alarmNodeId));
|
||||||
return RemoveReturns;
|
return RemoveReturns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
|
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
RemoveQueue.Enqueue(("equipment", equipmentNodeId));
|
RemoveQueue.Enqueue(("equipment", equipmentNodeId));
|
||||||
return RemoveReturns;
|
return RemoveReturns;
|
||||||
@@ -2262,13 +2262,13 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The OPC UA quality.</param>
|
/// <param name="quality">The OPC UA quality.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> ValueWriteQueue.Enqueue((nodeId, quality));
|
=> ValueWriteQueue.Enqueue((nodeId, quality));
|
||||||
/// <summary>Records an alarm condition write call.</summary>
|
/// <summary>Records an alarm condition write call.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||||
@@ -2276,13 +2276,13 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
|
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
|
||||||
/// <summary>Records a folder creation call.</summary>
|
/// <summary>Records a folder creation call.</summary>
|
||||||
/// <param name="folderNodeId">The folder node ID.</param>
|
/// <param name="folderNodeId">The folder node ID.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
/// <summary>Records a variable creation call.</summary>
|
/// <summary>Records a variable creation call.</summary>
|
||||||
/// <param name="variableNodeId">The variable node ID.</param>
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
@@ -2291,7 +2291,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, 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)
|
||||||
{
|
{
|
||||||
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||||
@@ -2306,7 +2306,9 @@ public sealed class AddressSpaceApplierTests
|
|||||||
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
|
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
|
||||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
|
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
|
||||||
@@ -2318,19 +2320,21 @@ public sealed class AddressSpaceApplierTests
|
|||||||
public int RebuildCalls;
|
public int RebuildCalls;
|
||||||
|
|
||||||
/// <summary>Records a value write (no-op in this sink).</summary>
|
/// <summary>Records a value write (no-op in this sink).</summary>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>No-op alarm condition write call.</summary>
|
/// <summary>No-op alarm condition write call.</summary>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
/// <summary>No-op folder creation call.</summary>
|
/// <summary>No-op folder creation call.</summary>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
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, 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) { }
|
||||||
/// <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>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
||||||
@@ -2345,13 +2349,13 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The OPC UA quality.</param>
|
/// <param name="quality">The OPC UA quality.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>Throws an exception if configured to do so.</summary>
|
/// <summary>Throws an exception if configured to do so.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||||
}
|
}
|
||||||
@@ -2361,12 +2365,12 @@ public sealed class AddressSpaceApplierTests
|
|||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
/// <summary>No-op folder creation call.</summary>
|
/// <summary>No-op folder creation call.</summary>
|
||||||
/// <param name="folderNodeId">The folder node ID.</param>
|
/// <param name="folderNodeId">The folder node ID.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
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>
|
||||||
/// <param name="variableNodeId">The variable node ID.</param>
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
||||||
@@ -2374,10 +2378,12 @@ 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, 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) { }
|
||||||
/// <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>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy:
|
||||||
|
/// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove), and any Changed raw/uns delta
|
||||||
|
/// routes to Rebuild (the safe default — WP3's applier owns surgical refinement).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||||
|
{
|
||||||
|
private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder);
|
||||||
|
|
||||||
|
private static RawTagPlan RawTag(string nodeId) =>
|
||||||
|
new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float",
|
||||||
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
|
||||||
|
|
||||||
|
private static UnsReferenceVariable UnsRef(string nodeId) =>
|
||||||
|
new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false);
|
||||||
|
|
||||||
|
private static AddressSpacePlan Empty() => new(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||||
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||||
|
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Added_raw_tag_only_is_PureAdd()
|
||||||
|
{
|
||||||
|
var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Added_raw_container_only_is_PureAdd()
|
||||||
|
{
|
||||||
|
var plan = Empty() with { AddedRawContainers = new[] { Folder } };
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Added_uns_reference_only_is_PureAdd()
|
||||||
|
{
|
||||||
|
var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Removed_raw_tag_only_is_PureRemove()
|
||||||
|
{
|
||||||
|
var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Removed_uns_reference_only_is_PureRemove()
|
||||||
|
{
|
||||||
|
var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Raw_rename_add_and_remove_is_AddRemoveMix()
|
||||||
|
{
|
||||||
|
var plan = Empty() with
|
||||||
|
{
|
||||||
|
RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") },
|
||||||
|
AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") },
|
||||||
|
};
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Changed_raw_tag_is_Rebuild()
|
||||||
|
{
|
||||||
|
var plan = Empty() with
|
||||||
|
{
|
||||||
|
ChangedRawTags = new[]
|
||||||
|
{
|
||||||
|
new AddressSpacePlan.RawTagDelta(
|
||||||
|
RawTag("Plant/Modbus1/PLC-A/Speed"),
|
||||||
|
RawTag("Plant/Modbus1/PLC-A/Speed") with { IsHistorized = true }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Uns_reference_repoint_is_Rebuild()
|
||||||
|
{
|
||||||
|
var plan = Empty() with
|
||||||
|
{
|
||||||
|
ChangedUnsReferenceVariables = new[]
|
||||||
|
{
|
||||||
|
new AddressSpacePlan.UnsReferenceDelta(
|
||||||
|
UnsRef("filling/line-1/station-1/Speed"),
|
||||||
|
UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||||
|
}
|
||||||
|
}
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree
|
||||||
|
/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed <c>s=<RawPath></c>,
|
||||||
|
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
|
||||||
|
/// <see cref="UnsTagReference"/> keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>,
|
||||||
|
/// realm <see cref="AddressSpaceRealm.Uns"/>, carrying its backing RawPath for the Organizes ref +
|
||||||
|
/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddressSpaceComposerDualNamespaceTests
|
||||||
|
{
|
||||||
|
// A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags.
|
||||||
|
// Speed sits under the group; Status directly under the device; Levels is an array tag.
|
||||||
|
private static AddressSpaceComposition ComposeFullFixture()
|
||||||
|
{
|
||||||
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
||||||
|
var driver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||||
|
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
||||||
|
var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" };
|
||||||
|
|
||||||
|
var speed = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed",
|
||||||
|
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite,
|
||||||
|
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," +
|
||||||
|
"\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}",
|
||||||
|
};
|
||||||
|
var status = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-status", DeviceId = "dev-1", Name = "Status",
|
||||||
|
DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
|
};
|
||||||
|
var levels = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels",
|
||||||
|
DataType = "Int32", AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"isArray\":true,\"arrayLength\":8}",
|
||||||
|
};
|
||||||
|
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||||
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||||
|
|
||||||
|
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
||||||
|
var refStatus = new UnsTagReference
|
||||||
|
{
|
||||||
|
UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status",
|
||||||
|
DisplayNameOverride = "MachineStatus",
|
||||||
|
};
|
||||||
|
|
||||||
|
return AddressSpaceComposer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip },
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: new[] { refSpeed, refStatus },
|
||||||
|
tags: new[] { speed, status, levels },
|
||||||
|
rawFolders: new[] { folder },
|
||||||
|
devices: new[] { device },
|
||||||
|
tagGroups: new[] { group });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each
|
||||||
|
/// realm=Raw, with parents-before-children ordering + correct parent NodeIds.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents()
|
||||||
|
{
|
||||||
|
var c = ComposeFullFixture();
|
||||||
|
|
||||||
|
c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder);
|
||||||
|
folder.NodeId.ShouldBe("Plant");
|
||||||
|
folder.ParentNodeId.ShouldBeNull(); // cluster root
|
||||||
|
folder.DisplayName.ShouldBe("Plant");
|
||||||
|
|
||||||
|
var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver);
|
||||||
|
driver.NodeId.ShouldBe("Plant/Modbus1");
|
||||||
|
driver.ParentNodeId.ShouldBe("Plant");
|
||||||
|
|
||||||
|
var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device);
|
||||||
|
device.NodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||||
|
device.ParentNodeId.ShouldBe("Plant/Modbus1");
|
||||||
|
|
||||||
|
var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup);
|
||||||
|
group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
||||||
|
group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||||
|
|
||||||
|
// Sorted by NodeId ordinal ⇒ a parent always precedes each of its children.
|
||||||
|
c.RawContainers.Select(n => n.NodeId)
|
||||||
|
.ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raw tags are Variables keyed <c>s=<RawPath></c> (realm=Raw), carrying DataType /
|
||||||
|
/// writable / historize + historian tagname / array shape, and hang under their group (or device).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes()
|
||||||
|
{
|
||||||
|
var c = ComposeFullFixture();
|
||||||
|
|
||||||
|
c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
||||||
|
speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed");
|
||||||
|
speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
||||||
|
speed.DriverInstanceId.ShouldBe("drv-modbus");
|
||||||
|
speed.DataType.ShouldBe("Float");
|
||||||
|
speed.Writable.ShouldBeTrue();
|
||||||
|
speed.IsHistorized.ShouldBeTrue();
|
||||||
|
speed.HistorianTagname.ShouldBe("speed_hist");
|
||||||
|
|
||||||
|
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
||||||
|
status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device
|
||||||
|
status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||||
|
status.Writable.ShouldBeFalse();
|
||||||
|
status.IsHistorized.ShouldBeFalse();
|
||||||
|
|
||||||
|
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
||||||
|
levels.IsArray.ShouldBeTrue();
|
||||||
|
levels.ArrayLength.ShouldBe(8u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list
|
||||||
|
/// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths()
|
||||||
|
{
|
||||||
|
var c = ComposeFullFixture();
|
||||||
|
|
||||||
|
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
||||||
|
speed.Alarm.ShouldNotBeNull();
|
||||||
|
speed.Alarm!.AlarmType.ShouldBe("LimitAlarm");
|
||||||
|
speed.Alarm.Severity.ShouldBe(700);
|
||||||
|
speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
||||||
|
|
||||||
|
// A non-alarm tag carries no condition + its own referencing-equipment list.
|
||||||
|
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
||||||
|
status.Alarm.ShouldBeNull();
|
||||||
|
status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
||||||
|
|
||||||
|
// An unreferenced tag has an empty referencing-equipment set.
|
||||||
|
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
||||||
|
levels.ReferencingEquipmentPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Each UnsTagReference emits a UNS Variable keyed
|
||||||
|
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c> (realm=Uns), carrying its backing
|
||||||
|
/// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective
|
||||||
|
/// name = DisplayNameOverride else the backing raw tag's Name.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access()
|
||||||
|
{
|
||||||
|
var c = ComposeFullFixture();
|
||||||
|
|
||||||
|
c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
|
||||||
|
speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name
|
||||||
|
speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||||
|
speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out
|
||||||
|
speedRef.DataType.ShouldBe("Float");
|
||||||
|
speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag
|
||||||
|
|
||||||
|
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
|
||||||
|
statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins
|
||||||
|
statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus");
|
||||||
|
statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status");
|
||||||
|
statusRef.DataType.ShouldBe("Boolean");
|
||||||
|
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
|
||||||
|
/// convenience overloads + earlier callers keep working).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void No_raw_or_uns_inputs_leaves_both_subtrees_empty()
|
||||||
|
{
|
||||||
|
var c = AddressSpaceComposer.Compose(
|
||||||
|
equipment: Array.Empty<Equipment>(),
|
||||||
|
driverInstances: Array.Empty<DriverInstance>(),
|
||||||
|
scriptedAlarms: Array.Empty<ScriptedAlarm>());
|
||||||
|
|
||||||
|
c.RawContainers.ShouldBeEmpty();
|
||||||
|
c.RawTags.ShouldBeEmpty();
|
||||||
|
c.UnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Raw + UNS emit is deterministic — repeated calls produce element-identical output
|
||||||
|
/// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dual_subtree_emit_is_deterministic()
|
||||||
|
{
|
||||||
|
var a = ComposeFullFixture();
|
||||||
|
var b = ComposeFullFixture();
|
||||||
|
|
||||||
|
a.RawContainers.ShouldBe(b.RawContainers);
|
||||||
|
a.RawTags.ShouldBe(b.RawTags);
|
||||||
|
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP1) — the planner diffs per realm. Raw containers + raw tags diff by RawPath
|
||||||
|
/// (NodeId), so a rename is remove(old)+add(new); UNS reference variables diff by the stable
|
||||||
|
/// <c>UnsTagReferenceId</c>, so a backing-tag rename is a re-point (BackingRawPath moves, NodeId stable)
|
||||||
|
/// and a display-name-override edit is a UNS-only change with no raw delta.
|
||||||
|
/// <para><b>The pinned invariant:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddressSpacePlannerDualNamespaceTests
|
||||||
|
{
|
||||||
|
private const string RawFolderName = "Plant";
|
||||||
|
private const string DriverName = "Modbus1";
|
||||||
|
private const string DeviceName = "PLC-A";
|
||||||
|
|
||||||
|
// Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it
|
||||||
|
// (with an optional display-name override so the effective name can be held stable across a raw rename).
|
||||||
|
private static AddressSpaceComposition Compose(string tagName, string? displayOverride)
|
||||||
|
{
|
||||||
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
|
||||||
|
var driver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||||
|
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
|
||||||
|
var tag = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-speed", DeviceId = "dev-1", Name = tagName,
|
||||||
|
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
|
||||||
|
};
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||||
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||||
|
var reference = new UnsTagReference
|
||||||
|
{
|
||||||
|
UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed",
|
||||||
|
DisplayNameOverride = displayOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
return AddressSpaceComposer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip },
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: new[] { reference },
|
||||||
|
tags: new[] { tag },
|
||||||
|
rawFolders: new[] { folder },
|
||||||
|
devices: new[] { device });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A newly-added raw tag surfaces in AddedRawTags (keyed by RawPath); the plan is non-empty.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Added_raw_tag_goes_to_AddedRawTags()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: null);
|
||||||
|
var next = Compose("Speed", displayOverride: null);
|
||||||
|
// prev without the tag: strip RawTags/UNS by composing an empty raw side.
|
||||||
|
var empty = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(empty, next);
|
||||||
|
|
||||||
|
plan.IsEmpty.ShouldBeFalse();
|
||||||
|
plan.AddedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
|
||||||
|
plan.RemovedRawTags.ShouldBeEmpty();
|
||||||
|
_ = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A disappeared raw tag surfaces in RemovedRawTags.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Removed_raw_tag_goes_to_RemovedRawTags()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: null);
|
||||||
|
var empty = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prev, empty);
|
||||||
|
|
||||||
|
plan.RemovedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
|
||||||
|
plan.AddedRawTags.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A newly-added UNS reference surfaces in AddedUnsReferenceVariables.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Added_uns_reference_goes_to_AddedUnsReferenceVariables()
|
||||||
|
{
|
||||||
|
var empty = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||||
|
var next = Compose("Speed", displayOverride: null);
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(empty, next);
|
||||||
|
|
||||||
|
plan.AddedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
|
||||||
|
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A disappeared UNS reference surfaces in RemovedUnsReferenceVariables.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Removed_uns_reference_goes_to_RemovedUnsReferenceVariables()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: null);
|
||||||
|
var empty = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prev, empty);
|
||||||
|
|
||||||
|
plan.RemovedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
|
||||||
|
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><b>THE PIN:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS. With a display-name
|
||||||
|
/// override holding the effective name stable, the reference row is unchanged (same UnsTagReferenceId,
|
||||||
|
/// same UNS NodeId) but its backing NodeId moved ⇒ the UNS variable's Organizes target (BackingRawPath)
|
||||||
|
/// re-points via a ChangedUnsReferenceVariables delta.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Raw_rename_is_remove_add_in_raw_and_repoint_in_uns()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: "Spd");
|
||||||
|
var next = Compose("Velocity", displayOverride: "Spd"); // raw tag renamed; effective name held stable
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||||
|
|
||||||
|
// Raw realm: remove the old RawPath, add the new one (immutable NodeId ⇒ not a Changed delta).
|
||||||
|
plan.RemovedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Speed" });
|
||||||
|
plan.AddedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Velocity" });
|
||||||
|
plan.ChangedRawTags.ShouldBeEmpty();
|
||||||
|
|
||||||
|
// UNS realm: the reference row is unchanged in identity + NodeId, but re-points to the new RawPath.
|
||||||
|
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
var repoint = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
|
||||||
|
repoint.Previous.UnsTagReferenceId.ShouldBe("ref-1");
|
||||||
|
repoint.Current.UnsTagReferenceId.ShouldBe("ref-1");
|
||||||
|
repoint.Previous.NodeId.ShouldBe(repoint.Current.NodeId); // effective name stable ⇒ NodeId stable
|
||||||
|
repoint.Previous.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Speed");
|
||||||
|
repoint.Current.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Velocity");
|
||||||
|
|
||||||
|
// The raw container topology (Folder/Driver/Device) is untouched by a tag rename.
|
||||||
|
plan.AddedRawContainers.ShouldBeEmpty();
|
||||||
|
plan.RemovedRawContainers.ShouldBeEmpty();
|
||||||
|
plan.ChangedRawContainers.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A display-name-override change is a UNS-only change (no raw delta): same reference row id,
|
||||||
|
/// the effective name + UNS NodeId move, the backing RawPath is unchanged.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Display_name_override_change_is_uns_only_no_raw_change()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: "Spd");
|
||||||
|
var next = Compose("Speed", displayOverride: "Speedy"); // only the override changed
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||||
|
|
||||||
|
// No raw-side change whatsoever.
|
||||||
|
plan.AddedRawTags.ShouldBeEmpty();
|
||||||
|
plan.RemovedRawTags.ShouldBeEmpty();
|
||||||
|
plan.ChangedRawTags.ShouldBeEmpty();
|
||||||
|
plan.AddedRawContainers.ShouldBeEmpty();
|
||||||
|
plan.RemovedRawContainers.ShouldBeEmpty();
|
||||||
|
plan.ChangedRawContainers.ShouldBeEmpty();
|
||||||
|
|
||||||
|
// UNS: same reference row id, effective name + NodeId changed, backing RawPath unchanged.
|
||||||
|
var delta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
|
||||||
|
delta.Previous.UnsTagReferenceId.ShouldBe("ref-1");
|
||||||
|
delta.Previous.EffectiveName.ShouldBe("Spd");
|
||||||
|
delta.Current.EffectiveName.ShouldBe("Speedy");
|
||||||
|
delta.Previous.NodeId.ShouldNotBe(delta.Current.NodeId);
|
||||||
|
delta.Previous.BackingRawPath.ShouldBe(delta.Current.BackingRawPath);
|
||||||
|
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An attribute-only raw-tag edit (historize toggle) keeps the same RawPath ⇒ a
|
||||||
|
/// ChangedRawTags delta (not remove+add).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Raw_tag_attribute_edit_keeps_nodeid_and_routes_to_ChangedRawTags()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: null);
|
||||||
|
// next: same identity + name, but toggle historize on the raw tag.
|
||||||
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
|
||||||
|
var driver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||||
|
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
|
||||||
|
var tag = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed",
|
||||||
|
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true}",
|
||||||
|
};
|
||||||
|
var next = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { device });
|
||||||
|
|
||||||
|
var prevRawOnly = AddressSpaceComposer.Compose(
|
||||||
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
tags: new[] { new Tag { TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" } },
|
||||||
|
rawFolders: new[] { folder }, devices: new[] { device });
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prevRawOnly, next);
|
||||||
|
|
||||||
|
plan.AddedRawTags.ShouldBeEmpty();
|
||||||
|
plan.RemovedRawTags.ShouldBeEmpty();
|
||||||
|
var changed = plan.ChangedRawTags.ShouldHaveSingleItem();
|
||||||
|
changed.Previous.NodeId.ShouldBe(changed.Current.NodeId);
|
||||||
|
changed.Previous.IsHistorized.ShouldBeFalse();
|
||||||
|
changed.Current.IsHistorized.ShouldBeTrue();
|
||||||
|
_ = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Identical compositions diff to an empty plan across both realms (fresh list instances must
|
||||||
|
/// not spuriously flag RawTags/UnsReferenceVariables as changed).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Identical_dual_namespace_compositions_diff_to_empty()
|
||||||
|
{
|
||||||
|
var prev = Compose("Speed", displayOverride: "Spd");
|
||||||
|
var next = Compose("Speed", displayOverride: "Spd");
|
||||||
|
|
||||||
|
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||||
|
|
||||||
|
plan.IsEmpty.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnAcknowledge.ShouldNotBeNull();
|
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||||
@@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-2");
|
var condition = nm.TryGetAlarmCondition("alm-2");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-3");
|
var condition = nm.TryGetAlarmCondition("alm-3");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
|
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-c");
|
var condition = nm.TryGetAlarmCondition("alm-c");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnConfirm.ShouldNotBeNull();
|
condition!.OnConfirm.ShouldNotBeNull();
|
||||||
@@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
|
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ac");
|
var condition = nm.TryGetAlarmCondition("alm-ac");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnAddComment.ShouldNotBeNull();
|
condition!.OnAddComment.ShouldNotBeNull();
|
||||||
@@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
|
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-s1");
|
var condition = nm.TryGetAlarmCondition("alm-s1");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnShelve.ShouldNotBeNull();
|
condition!.OnShelve.ShouldNotBeNull();
|
||||||
@@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
|
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-s2");
|
var condition = nm.TryGetAlarmCondition("alm-s2");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
|
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-s3");
|
var condition = nm.TryGetAlarmCondition("alm-s3");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
|
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-s4");
|
var condition = nm.TryGetAlarmCondition("alm-s4");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
|
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-tu");
|
var condition = nm.TryGetAlarmCondition("alm-tu");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnTimedUnshelve.ShouldNotBeNull();
|
condition!.OnTimedUnshelve.ShouldNotBeNull();
|
||||||
@@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1");
|
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ed1");
|
var condition = nm.TryGetAlarmCondition("alm-ed1");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnEnableDisable.ShouldNotBeNull();
|
condition!.OnEnableDisable.ShouldNotBeNull();
|
||||||
@@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2");
|
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ed2");
|
var condition = nm.TryGetAlarmCondition("alm-ed2");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnEnableDisable.ShouldNotBeNull();
|
condition!.OnEnableDisable.ShouldNotBeNull();
|
||||||
@@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3");
|
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ed3");
|
var condition = nm.TryGetAlarmCondition("alm-ed3");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var captured = new List<AlarmCommand>();
|
var captured = new List<AlarmCommand>();
|
||||||
nm.AlarmCommandRouter = captured.Add;
|
nm.AlarmCommandRouter = captured.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4");
|
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ed4");
|
var condition = nm.TryGetAlarmCondition("alm-ed4");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnEnableDisable.ShouldNotBeNull();
|
condition!.OnEnableDisable.ShouldNotBeNull();
|
||||||
@@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
nm.AlarmCommandRouter = scripted.Add;
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
nm.NativeAlarmAckRouter = native.Add;
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1");
|
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-nak1");
|
var condition = nm.TryGetAlarmCondition("alm-nak1");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnAcknowledge.ShouldNotBeNull();
|
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||||
@@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
nm.AlarmCommandRouter = scripted.Add;
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
nm.NativeAlarmAckRouter = native.Add;
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2");
|
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-nak2");
|
var condition = nm.TryGetAlarmCondition("alm-nak2");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
condition!.OnAcknowledge.ShouldNotBeNull();
|
condition!.OnAcknowledge.ShouldNotBeNull();
|
||||||
@@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
nm.AlarmCommandRouter = scripted.Add;
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
nm.NativeAlarmAckRouter = native.Add;
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4");
|
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-nak4");
|
var condition = nm.TryGetAlarmCondition("alm-nak4");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
nm.AlarmCommandRouter = scripted.Add;
|
nm.AlarmCommandRouter = scripted.Add;
|
||||||
nm.NativeAlarmAckRouter = native.Add;
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3");
|
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
|
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-nak3");
|
var condition = nm.TryGetAlarmCondition("alm-nak3");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
// No router set (default null).
|
// No router set (default null).
|
||||||
|
|
||||||
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
|
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var condition = nm.TryGetAlarmCondition("alm-nr");
|
var condition = nm.TryGetAlarmCondition("alm-nr");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
||||||
|
|
||||||
@@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.IsNativeAlarmNode("a2").ShouldBeFalse();
|
nm.IsNativeAlarmNode("a2").ShouldBeFalse();
|
||||||
|
|
||||||
@@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
||||||
|
|
||||||
// RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
|
// RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
|
||||||
// before the same id can be re-materialised (ResolveParentFolder needs the parent back).
|
// before the same id can be re-materialised (ResolveParentFolder needs the parent back).
|
||||||
nm.RebuildAddressSpace();
|
nm.RebuildAddressSpace();
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
|
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
|
||||||
|
|
||||||
@@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
|
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
|
||||||
|
|
||||||
nm.RebuildAddressSpace();
|
nm.RebuildAddressSpace();
|
||||||
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var deferred = new DeferredAddressSpaceSink();
|
var deferred = new DeferredAddressSpaceSink();
|
||||||
|
|
||||||
// No throw, no observable side effect.
|
// No throw, no observable side effect.
|
||||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
deferred.RebuildAddressSpace();
|
deferred.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var inner = new RecordingSink();
|
var inner = new RecordingSink();
|
||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
|
|
||||||
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
|
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow);
|
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
deferred.RebuildAddressSpace();
|
deferred.RebuildAddressSpace();
|
||||||
deferred.RaiseNodesAddedModelChange("eq-1");
|
deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" });
|
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" });
|
||||||
}
|
}
|
||||||
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var deferred = new DeferredAddressSpaceSink();
|
var deferred = new DeferredAddressSpaceSink();
|
||||||
var inner = new RecordingSink();
|
var inner = new RecordingSink();
|
||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
inner.Calls.Count.ShouldBe(1);
|
inner.Calls.Count.ShouldBe(1);
|
||||||
|
|
||||||
deferred.SetSink(null);
|
deferred.SetSink(null);
|
||||||
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped
|
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
|
||||||
inner.Calls.Count.ShouldBe(1);
|
inner.Calls.Count.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var second = new RecordingSink();
|
var second = new RecordingSink();
|
||||||
|
|
||||||
deferred.SetSink(first);
|
deferred.SetSink(first);
|
||||||
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
deferred.SetSink(second);
|
deferred.SetSink(second);
|
||||||
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
first.Calls.Single().ShouldBe("WV:a");
|
first.Calls.Single().ShouldBe("WV:a");
|
||||||
second.Calls.Single().ShouldBe("WV:b");
|
second.Calls.Single().ShouldBe("WV:b");
|
||||||
@@ -77,7 +77,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var inner = new RecordingSink();
|
var inner = new RecordingSink();
|
||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
|
|
||||||
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV");
|
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var call = inner.HistorianCalls.ShouldHaveSingleItem();
|
var call = inner.HistorianCalls.ShouldHaveSingleItem();
|
||||||
call.NodeId.ShouldBe("v-1");
|
call.NodeId.ShouldBe("v-1");
|
||||||
@@ -96,7 +96,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
|
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
|
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
|
||||||
dataType: "Int32", isArray: true, arrayLength: 8u)
|
dataType: "Int32", isArray: true, arrayLength: 8u, AddressSpaceRealm.Uns)
|
||||||
.ShouldBeTrue();
|
.ShouldBeTrue();
|
||||||
|
|
||||||
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
|
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
|
||||||
@@ -119,7 +119,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||||
|
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
|
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
|
||||||
dataType: "Float", isArray: false, arrayLength: null)
|
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||||
.ShouldBeFalse();
|
.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,12 +131,12 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
{
|
{
|
||||||
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
|
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||||
dataType: "Float", isArray: false, arrayLength: null)
|
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||||
.ShouldBeFalse();
|
.ShouldBeFalse();
|
||||||
|
|
||||||
deferred.SetSink(new RecordingSink()); // a non-surgical inner
|
deferred.SetSink(new RecordingSink()); // a non-surgical inner
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||||
dataType: "Float", isArray: false, arrayLength: null)
|
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||||
.ShouldBeFalse();
|
.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var inner = new SurgicalRecordingSink { Result = true };
|
var inner = new SurgicalRecordingSink { Result = true };
|
||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
|
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South")
|
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns)
|
||||||
.ShouldBeTrue();
|
.ShouldBeTrue();
|
||||||
|
|
||||||
var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
|
var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
|
||||||
@@ -167,11 +167,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var deferred = new DeferredAddressSpaceSink();
|
var deferred = new DeferredAddressSpaceSink();
|
||||||
// Inner reports the folder missing ⇒ forward returns false.
|
// Inner reports the folder missing ⇒ forward returns false.
|
||||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
|
|
||||||
// Non-surgical inner (the null sink before swap-in) ⇒ false.
|
// Non-surgical inner (the null sink before swap-in) ⇒ false.
|
||||||
deferred.SetSink(null);
|
deferred.SetSink(null);
|
||||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
|
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
|
||||||
@@ -184,9 +184,9 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
var inner = new SurgicalRecordingSink { Result = true };
|
var inner = new SurgicalRecordingSink { Result = true };
|
||||||
deferred.SetSink(inner);
|
deferred.SetSink(inner);
|
||||||
|
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue();
|
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
|
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
|
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
|
||||||
}
|
}
|
||||||
@@ -198,14 +198,14 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
{
|
{
|
||||||
var deferred = new DeferredAddressSpaceSink();
|
var deferred = new DeferredAddressSpaceSink();
|
||||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
|
|
||||||
deferred.SetSink(null);
|
deferred.SetSink(null);
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
|
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
|
||||||
@@ -228,19 +228,19 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
|
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
|
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
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, 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)
|
||||||
{
|
{
|
||||||
CallQueue.Enqueue($"EV:{variableNodeId}");
|
CallQueue.Enqueue($"EV:{variableNodeId}");
|
||||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||||
@@ -248,7 +248,9 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => CallQueue.Enqueue($"NA:{affectedNodeId}");
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}");
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||||
@@ -261,14 +263,14 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new();
|
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
|
SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
|
||||||
return Result;
|
return Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
FolderRenameCalls.Add((folderNodeId, displayName));
|
FolderRenameCalls.Add((folderNodeId, displayName));
|
||||||
return Result;
|
return Result;
|
||||||
@@ -278,25 +280,27 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public List<(string Kind, string NodeId)> RemoveCalls { get; } = new();
|
public List<(string Kind, string NodeId)> RemoveCalls { get; } = new();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveVariableNode(string variableNodeId) { RemoveCalls.Add(("var", variableNodeId)); return Result; }
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("var", variableNodeId)); return Result; }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; }
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
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, 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) { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -1,4 +1,5 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
@@ -27,14 +28,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var first = nm.TryGetAlarmCondition("alm-1");
|
var first = nm.TryGetAlarmCondition("alm-1");
|
||||||
first.ShouldNotBeNull();
|
first.ShouldNotBeNull();
|
||||||
|
|
||||||
// Same id + same kind ⇒ skip-if-present: the existing instance is kept.
|
// Same id + same kind ⇒ skip-if-present: the existing instance is kept.
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var second = nm.TryGetAlarmCondition("alm-1");
|
var second = nm.TryGetAlarmCondition("alm-1");
|
||||||
|
|
||||||
second.ShouldBeSameAs(first);
|
second.ShouldBeSameAs(first);
|
||||||
@@ -50,14 +51,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var scripted = nm.TryGetAlarmCondition("alm-1");
|
var scripted = nm.TryGetAlarmCondition("alm-1");
|
||||||
nm.IsNativeAlarmNode("alm-1").ShouldBeFalse();
|
nm.IsNativeAlarmNode("alm-1").ShouldBeFalse();
|
||||||
|
|
||||||
// Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set.
|
// Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set.
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
var native = nm.TryGetAlarmCondition("alm-1");
|
var native = nm.TryGetAlarmCondition("alm-1");
|
||||||
|
|
||||||
native.ShouldNotBeSameAs(scripted);
|
native.ShouldNotBeSameAs(scripted);
|
||||||
@@ -75,16 +76,16 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var before = nm.TryGetAlarmCondition("alm-1");
|
var before = nm.TryGetAlarmCondition("alm-1");
|
||||||
|
|
||||||
nm.RebuildAddressSpace();
|
nm.RebuildAddressSpace();
|
||||||
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
||||||
|
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
var after = nm.TryGetAlarmCondition("alm-1");
|
var after = nm.TryGetAlarmCondition("alm-1");
|
||||||
|
|
||||||
after.ShouldNotBeNull();
|
after.ShouldNotBeNull();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32",
|
nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32",
|
||||||
writable: false, historianTagname: null, isArray: true, arrayLength: 8);
|
writable: false, historianTagname: null, isArray: true, arrayLength: 8, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/arr");
|
var variable = nm.TryGetVariable("eq-1/arr");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
@@ -52,7 +52,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32",
|
nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32",
|
||||||
writable: false, historianTagname: null, isArray: true, arrayLength: null);
|
writable: false, historianTagname: null, isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/arr-unfixed");
|
var variable = nm.TryGetVariable("eq-1/arr-unfixed");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
@@ -72,7 +72,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32",
|
nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32",
|
||||||
writable: false);
|
writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/scalar");
|
var variable = nm.TryGetVariable("eq-1/scalar");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
@@ -91,10 +91,10 @@ public sealed class NodeManagerArrayTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32",
|
nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32",
|
||||||
writable: false, historianTagname: null, isArray: true, arrayLength: 3);
|
writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var payload = new[] { 1, 2, 3 };
|
var payload = new[] { 1, 2, 3 };
|
||||||
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/arrwrite");
|
var variable = nm.TryGetVariable("eq-1/arrwrite");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -29,7 +30,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Tag");
|
writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/temp");
|
var variable = nm.TryGetVariable("eq-1/temp");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
@@ -56,9 +57,9 @@ public sealed class NodeManagerHistorizeTests : IDisposable
|
|||||||
|
|
||||||
// Explicit null and the defaulted-param form both mean "not historized".
|
// Explicit null and the defaulted-param form both mean "not historized".
|
||||||
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
|
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
|
||||||
writable: false, historianTagname: null);
|
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32",
|
nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32",
|
||||||
writable: false);
|
writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" })
|
foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" })
|
||||||
{
|
{
|
||||||
@@ -83,7 +84,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float",
|
nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float",
|
||||||
writable: true, historianTagname: "WW.Setpoint");
|
writable: true, historianTagname: "WW.Setpoint", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var variable = nm.TryGetVariable("eq-1/setpoint");
|
var variable = nm.TryGetVariable("eq-1/setpoint");
|
||||||
variable.ShouldNotBeNull();
|
variable.ShouldNotBeNull();
|
||||||
@@ -109,7 +110,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Tag");
|
writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
|
||||||
nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue();
|
nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue();
|
||||||
|
|
||||||
nm.RebuildAddressSpace();
|
nm.RebuildAddressSpace();
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
@@ -228,7 +229,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
|||||||
{
|
{
|
||||||
var key = $"eq/tag{i}";
|
var key = $"eq/tag{i}";
|
||||||
nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float",
|
nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float",
|
||||||
writable: false, historianTagname: $"WW.Tag{i}");
|
writable: false, historianTagname: $"WW.Tag{i}", realm: AddressSpaceRealm.Uns);
|
||||||
ids[i] = nm.TryGetVariable(key)!.NodeId;
|
ids[i] = nm.TryGetVariable(key)!.NodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -42,8 +43,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
const string equipmentId = "eq-evt";
|
const string equipmentId = "eq-evt";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700);
|
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||||
@@ -107,8 +108,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
const string equipmentId = "eq-unbounded";
|
const string equipmentId = "eq-unbounded";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600);
|
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
fake.EventsResult = new HistoricalEventsResult(
|
fake.EventsResult = new HistoricalEventsResult(
|
||||||
@@ -143,8 +144,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
const string equipmentId = "eq-unsupported";
|
const string equipmentId = "eq-unsupported";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500);
|
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
fake.EventsResult = new HistoricalEventsResult(
|
fake.EventsResult = new HistoricalEventsResult(
|
||||||
@@ -187,8 +188,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
const string equipmentId = "eq-empty";
|
const string equipmentId = "eq-empty";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300);
|
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
var details = new ReadEventDetails
|
var details = new ReadEventDetails
|
||||||
@@ -221,8 +222,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
// Materialise the alarm while the source is still the Null default — the folder is promoted to
|
// Materialise the alarm while the source is still the Null default — the folder is promoted to
|
||||||
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
|
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
|
||||||
const string equipmentId = "eq-nosrc";
|
const string equipmentId = "eq-nosrc";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200);
|
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
|
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
|
||||||
@@ -271,7 +272,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
// A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
|
// A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
|
||||||
// EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
|
// EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
|
||||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Temp");
|
writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadEventDetails
|
var details = new ReadEventDetails
|
||||||
@@ -303,8 +304,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
const string equipmentId = "eq-boom";
|
const string equipmentId = "eq-boom";
|
||||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900);
|
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns);
|
||||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
var details = new ReadEventDetails
|
var details = new ReadEventDetails
|
||||||
|
|||||||
+9
-8
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -42,7 +43,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double",
|
nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.PV");
|
writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId;
|
||||||
|
|
||||||
var collected = new List<double>();
|
var collected = new List<double>();
|
||||||
@@ -84,7 +85,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1));
|
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1));
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double",
|
nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.Exact");
|
writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId;
|
||||||
|
|
||||||
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
||||||
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
|
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.Tie");
|
writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId;
|
||||||
|
|
||||||
var collected = new List<double>();
|
var collected = new List<double>();
|
||||||
@@ -167,7 +168,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double",
|
nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.Burst");
|
writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId;
|
||||||
|
|
||||||
var collected = new List<double>();
|
var collected = new List<double>();
|
||||||
@@ -211,7 +212,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double",
|
nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.Absurd");
|
writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId;
|
||||||
|
|
||||||
// Page 1: a full page of the first 2 ties, with a continuation point.
|
// Page 1: a full page of the first 2 ties, with a continuation point.
|
||||||
@@ -241,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double",
|
nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.All");
|
writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId;
|
||||||
|
|
||||||
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
|
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
|
||||||
@@ -265,7 +266,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double",
|
nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.BadCp");
|
writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId;
|
||||||
|
|
||||||
fake.ResetReadCount();
|
fake.ResetReadCount();
|
||||||
@@ -291,7 +292,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double",
|
nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double",
|
||||||
writable: false, historianTagname: "WW.Rel");
|
writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId;
|
||||||
|
|
||||||
// Page 1 — get a CP.
|
// Page 1 — get a CP.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -39,7 +40,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Temp");
|
writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
||||||
|
|
||||||
var src = DateTime.UtcNow.AddSeconds(-5);
|
var src = DateTime.UtcNow.AddSeconds(-5);
|
||||||
@@ -91,7 +92,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
|
|
||||||
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
|
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
|
||||||
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
|
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
|
||||||
writable: false, historianTagname: "Plant.Flow.PV");
|
writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
|
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Empty");
|
writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
@@ -156,7 +157,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
|
|
||||||
// Plain (non-historized) variable — no HistoryRead access bit.
|
// Plain (non-historized) variable — no HistoryRead access bit.
|
||||||
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
|
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
|
||||||
writable: false, historianTagname: null);
|
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
@@ -185,7 +186,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
|
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Mod");
|
writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
@@ -215,7 +216,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
|
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Avg");
|
writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadProcessedDetails
|
var details = new ReadProcessedDetails
|
||||||
@@ -247,7 +248,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
|
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Sd");
|
writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadProcessedDetails
|
var details = new ReadProcessedDetails
|
||||||
@@ -277,7 +278,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
|
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.At");
|
writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
|
||||||
|
|
||||||
var t1 = DateTime.UtcNow.AddMinutes(-2);
|
var t1 = DateTime.UtcNow.AddMinutes(-2);
|
||||||
@@ -309,9 +310,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
|
|
||||||
// Materialise two distinct historized variables under separate equipment folders.
|
// Materialise two distinct historized variables under separate equipment folders.
|
||||||
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
|
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
|
||||||
writable: false, historianTagname: "A.PV");
|
writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
|
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
|
||||||
writable: false, historianTagname: "B.PV");
|
writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
|
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
|
||||||
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
|
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
|
||||||
@@ -368,7 +369,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
nm.HistorianDataSource = fake;
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
|
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
|
||||||
writable: false, historianTagname: "WW.Boom");
|
writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns);
|
||||||
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
|
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
|
||||||
|
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
|
|||||||
+6
-6
@@ -43,8 +43,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7");
|
nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false);
|
nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
var parent = nm.TryGetFolder("eq-7")!;
|
var parent = nm.TryGetFolder("eq-7")!;
|
||||||
|
|
||||||
var e = nm.BuildNodesAddedModelChange("eq-7");
|
var e = nm.BuildNodesAddedModelChange("eq-7");
|
||||||
@@ -95,13 +95,13 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
// Before any nodes exist under the parent — must not throw.
|
// Before any nodes exist under the parent — must not throw.
|
||||||
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9"));
|
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false);
|
nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
// After the nodes are materialised — still must not throw.
|
// After the nodes are materialised — still must not throw.
|
||||||
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9"));
|
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (B4-WP4) — the native-alarm <b>multi-notifier</b> wiring +
|
||||||
|
/// <b>teardown symmetry</b> on <see cref="OtOpcUaNodeManager"/>. A native alarm is a SINGLE Part 9
|
||||||
|
/// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
|
||||||
|
/// <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> wires that one condition as an event notifier of
|
||||||
|
/// EACH referencing equipment's UNS folder so one <c>ReportEvent</c> fans one event to every referencing
|
||||||
|
/// equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent,
|
||||||
|
/// a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal
|
||||||
|
/// / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks
|
||||||
|
/// across redeploys (exactly N notifiers after a re-trip, not 2N).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private const string RawAlarm = "Plant/Modbus/dev1/temp_hi";
|
||||||
|
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||||
|
private const string Equip1 = "EQ-filling-line1-station1";
|
||||||
|
private const string Equip2 = "EQ-filling-line2-station2";
|
||||||
|
|
||||||
|
private readonly string _pkiRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"otopcua-multi-notifier-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
/// <summary>Materialise the raw device folder + the single native condition + two equipment folders, then
|
||||||
|
/// wire the condition as a notifier of both. Returns the node manager.</summary>
|
||||||
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
// Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath.
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
|
||||||
|
// Two referencing equipment folders (UNS realm).
|
||||||
|
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
|
||||||
|
return (host, nm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The single condition is wired as a notifier of EACH referencing equipment folder: the condition
|
||||||
|
/// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and
|
||||||
|
/// each folder becomes a root (Server-object) event notifier.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
// One inverse-notifier entry per equipment folder on the single condition.
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
// Each equipment folder holds the inverse entry back to the condition + is a root notifier.
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the
|
||||||
|
/// notifier entries — the SDK dedups by node reference and the tracking dedups its list.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present
|
||||||
|
/// folders are still wired. A missing condition is likewise a no-op.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
|
||||||
|
// "EQ-missing" was never materialised — it is skipped; Equip1 is still wired.
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns);
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
|
||||||
|
// An unmaterialised condition id is a no-op (never throws).
|
||||||
|
Should.NotThrow(() =>
|
||||||
|
nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down
|
||||||
|
/// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed
|
||||||
|
/// condition (no dangling reference).</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
|
||||||
|
nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown).
|
||||||
|
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0);
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw
|
||||||
|
/// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops
|
||||||
|
/// exactly one notifier and keeps the other.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
|
||||||
|
nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
|
// The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder.
|
||||||
|
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise +
|
||||||
|
/// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the
|
||||||
|
/// prior notifier pairs bidirectionally before dropping the nodes.</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
|
||||||
|
// Redeploy (the full-rebuild path).
|
||||||
|
nm.RebuildAddressSpace();
|
||||||
|
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||||
|
|
||||||
|
// Re-materialise the whole thing + re-wire (what the applier does every apply).
|
||||||
|
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
// Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild.
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wave C review M3 — <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> is a RECONCILE: re-wiring
|
||||||
|
/// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced
|
||||||
|
/// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink
|
||||||
|
/// arrives as a full rebuild.)</summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
[Fact]
|
||||||
|
public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set()
|
||||||
|
{
|
||||||
|
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||||
|
|
||||||
|
// Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally.
|
||||||
|
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||||
|
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||||
|
{
|
||||||
|
var host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.MultiNotifierTest",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = AllocateFreePort(),
|
||||||
|
PublicHostname = "localhost",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
},
|
||||||
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
await host.StartAsync(server, Ct);
|
||||||
|
return (host, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cleans up the PKI root directory.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment"));
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns));
|
||||||
ex.Message.ShouldContain("address space has not been created");
|
ex.Message.ShouldContain("address space has not been created");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -55,7 +55,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
|||||||
{
|
{
|
||||||
Should.Throw<InvalidOperationException>(() =>
|
Should.Throw<InvalidOperationException>(() =>
|
||||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp",
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp",
|
||||||
dataType: "Float", writable: false));
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Should.Throw<InvalidOperationException>(() =>
|
Should.Throw<InvalidOperationException>(() =>
|
||||||
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow));
|
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -85,7 +85,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Should.Throw<InvalidOperationException>(() =>
|
Should.Throw<InvalidOperationException>(() =>
|
||||||
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500));
|
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
+25
-24
@@ -1,4 +1,5 @@
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -31,13 +32,13 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
|
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false);
|
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
var countBefore = nm.VariableCount;
|
var countBefore = nm.VariableCount;
|
||||||
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue();
|
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue();
|
||||||
|
|
||||||
nm.RemoveVariableNode("eq-1/A").ShouldBeTrue();
|
nm.RemoveVariableNode("eq-1/A", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
nm.TryGetVariable("eq-1/A").ShouldBeNull();
|
nm.TryGetVariable("eq-1/A").ShouldBeNull();
|
||||||
nm.VariableCount.ShouldBe(countBefore - 1);
|
nm.VariableCount.ShouldBe(countBefore - 1);
|
||||||
@@ -56,7 +57,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse();
|
nm.RemoveVariableNode("eq-1/nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -68,8 +69,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false);
|
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var e = nm.BuildNodesRemovedModelChange("eq-1/A");
|
var e = nm.BuildNodesRemovedModelChange("eq-1/A");
|
||||||
|
|
||||||
@@ -92,17 +93,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull();
|
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull();
|
||||||
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue();
|
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue();
|
||||||
|
|
||||||
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue();
|
nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
|
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
|
||||||
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
|
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
|
||||||
|
|
||||||
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false
|
nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); // already gone ⇒ false
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -114,10 +115,10 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false);
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
|
nm.RemoveAlarmConditionNode("alm-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
@@ -136,17 +137,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
// Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition.
|
// Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition.
|
||||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag");
|
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
|
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false);
|
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
|
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
// Sibling equipment eq-2 that must survive untouched.
|
// Sibling equipment eq-2 that must survive untouched.
|
||||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
|
||||||
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false);
|
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
nm.RemoveEquipmentSubtree("eq-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||||
|
|
||||||
// Every eq-1 descendant is gone from every map.
|
// Every eq-1 descendant is gone from every map.
|
||||||
nm.TryGetFolder("eq-1").ShouldBeNull();
|
nm.TryGetFolder("eq-1").ShouldBeNull();
|
||||||
@@ -163,7 +164,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
|
|
||||||
// Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the
|
// Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the
|
||||||
// eq-1 demotion) — proves no orphaned root-notifier ref broke the event path.
|
// eq-1 demotion) — proves no orphaned root-notifier ref broke the event path.
|
||||||
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false));
|
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false, realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -176,7 +177,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse();
|
nm.RemoveEquipmentSubtree("eq-nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-19
@@ -44,13 +44,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
|
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
|
||||||
|
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null,
|
||||||
dataType: "Int32", isArray: false, arrayLength: null);
|
dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place
|
node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place
|
||||||
@@ -69,13 +69,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false);
|
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/buf")!;
|
var node = nm.TryGetVariable("eq-1/buf")!;
|
||||||
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
|
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
|
||||||
|
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
||||||
dataType: "Int16", isArray: true, arrayLength: 8u);
|
dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||||
@@ -96,13 +96,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
|
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
|
||||||
writable: false, historianTagname: null, isArray: true, arrayLength: 4u);
|
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/buf")!;
|
var node = nm.TryGetVariable("eq-1/buf")!;
|
||||||
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
|
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
|
||||||
|
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
||||||
dataType: "Int16", isArray: true, arrayLength: 8u);
|
dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.ArrayDimensions[0].ShouldBe(8u);
|
node.ArrayDimensions[0].ShouldBe(8u);
|
||||||
@@ -121,13 +121,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
|
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
|
||||||
writable: false, historianTagname: null, isArray: true, arrayLength: 4u);
|
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/buf")!;
|
var node = nm.TryGetVariable("eq-1/buf")!;
|
||||||
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
|
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
|
||||||
|
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
||||||
dataType: "Int16", isArray: false, arrayLength: null);
|
dataType: "Int16", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.ValueRank.ShouldBe(ValueRanks.Scalar);
|
node.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||||
@@ -146,11 +146,11 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false);
|
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/buf")!;
|
var node = nm.TryGetVariable("eq-1/buf")!;
|
||||||
|
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
|
||||||
dataType: "Int16", isArray: true, arrayLength: null);
|
dataType: "Int16", isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||||
@@ -171,13 +171,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
|
|
||||||
// Same DataType ("Float") + same scalar shape — only Writable flips false → true.
|
// Same DataType ("Float") + same scalar shape — only Writable flips false → true.
|
||||||
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null,
|
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null,
|
||||||
dataType: "Float", isArray: false, arrayLength: null);
|
dataType: "Float", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
applied.ShouldBeTrue();
|
applied.ShouldBeTrue();
|
||||||
node.Value.ShouldBe(7.0f); // value preserved (NOT reset)
|
node.Value.ShouldBe(7.0f); // value preserved (NOT reset)
|
||||||
@@ -199,7 +199,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
|
|
||||||
bool result = true;
|
bool result = true;
|
||||||
Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null,
|
Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null,
|
||||||
dataType: "Int32", isArray: false, arrayLength: null));
|
dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns));
|
||||||
result.ShouldBeFalse();
|
result.ShouldBeFalse();
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
@@ -215,7 +215,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
|
|
||||||
var e = nm.BuildNodeShapeChangedEvent(node);
|
var e = nm.BuildNodeShapeChangedEvent(node);
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
|
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
|
||||||
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
|
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
optimisticValue: 42,
|
optimisticValue: 42,
|
||||||
priorValue: 7,
|
priorValue: 7,
|
||||||
priorStatus: StatusCodes.Good,
|
priorStatus: StatusCodes.Good,
|
||||||
clientUserId: "op");
|
clientUserId: "op", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
node.Value.ShouldBe(7); // settled back to prior value
|
node.Value.ShouldBe(7); // settled back to prior value
|
||||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad)
|
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad)
|
||||||
@@ -78,15 +78,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
node.Value = 42;
|
node.Value = 42;
|
||||||
|
|
||||||
nm.RevertOptimisticWriteIfNeeded(
|
nm.RevertOptimisticWriteIfNeeded(
|
||||||
"eq-1/sp",
|
"eq-1/sp",
|
||||||
outcome: new NodeWriteOutcome(true, null),
|
outcome: new NodeWriteOutcome(true, null),
|
||||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
|
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
node.Value.ShouldBe(42); // success ⇒ optimistic value stands
|
node.Value.ShouldBe(42); // success ⇒ optimistic value stands
|
||||||
|
|
||||||
@@ -101,15 +101,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
|
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
|
||||||
|
|
||||||
nm.RevertOptimisticWriteIfNeeded(
|
nm.RevertOptimisticWriteIfNeeded(
|
||||||
"eq-1/sp",
|
"eq-1/sp",
|
||||||
outcome: new NodeWriteOutcome(false, "device rejected"),
|
outcome: new NodeWriteOutcome(false, "device rejected"),
|
||||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
|
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
node.Value.ShouldBe(99); // poll value preserved — not reverted to 7
|
node.Value.ShouldBe(99); // poll value preserved — not reverted to 7
|
||||||
|
|
||||||
@@ -127,7 +127,71 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded(
|
Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded(
|
||||||
"eq-1/gone",
|
"eq-1/gone",
|
||||||
outcome: new NodeWriteOutcome(false, "device rejected"),
|
outcome: new NodeWriteOutcome(false, "device rejected"),
|
||||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"));
|
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────── v3 Batch 4 (review M2) — realm-qualified dual-node self-correction ───────────────────
|
||||||
|
|
||||||
|
/// <summary>(M2a) A failed write driven through a UNS NodeId reverts ONLY the UNS node back to its prior
|
||||||
|
/// value; the raw node that shares the SAME bare id (but lives in the Raw realm) is NEVER touched and keeps
|
||||||
|
/// its driver value. The revert routes by (realm, bareId), so it can't clobber the sibling node.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
// Same bare id "A/B/C/D" in BOTH realms — the raw device node and the UNS reference node.
|
||||||
|
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value
|
||||||
|
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
|
||||||
|
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
|
||||||
|
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
|
||||||
|
|
||||||
|
uns.Value = 42; // the SDK optimistically applied the client's UNS write
|
||||||
|
|
||||||
|
nm.RevertOptimisticWriteIfNeeded(
|
||||||
|
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
|
||||||
|
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
|
||||||
|
realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
uns.Value.ShouldBe(10); // UNS node reverted to prior
|
||||||
|
raw.Value.ShouldBe(10); // raw node NEVER changed (realm-qualified revert)
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>(M2b) A raw-realm revert reverts the RAW node and leaves the same-bare-id UNS node untouched.
|
||||||
|
/// This locks the realm arg: were it omitted (defaulting to Uns) the revert would target the UNS node — whose
|
||||||
|
/// value is not the optimistic one, so the still-holds-optimistic guard skips it — leaving the raw node stuck
|
||||||
|
/// at the rejected value and FAILING the raw-reverts assertion below.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Raw_realm_revert_reverts_raw_node_only()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
|
||||||
|
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw);
|
||||||
|
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns);
|
||||||
|
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
|
||||||
|
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
|
||||||
|
|
||||||
|
raw.Value = 42; // optimistic write to the RAW node
|
||||||
|
|
||||||
|
nm.RevertOptimisticWriteIfNeeded(
|
||||||
|
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
|
||||||
|
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
|
||||||
|
realm: AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
raw.Value.ShouldBe(10); // raw reverted (would stay 42 if the realm were dropped to Uns)
|
||||||
|
uns.Value.ShouldBe(10); // UNS node untouched
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -143,8 +207,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
|
|
||||||
var audit = nm.BuildWriteFailureAuditEvent(
|
var audit = nm.BuildWriteFailureAuditEvent(
|
||||||
@@ -174,8 +238,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
|
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||||
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
|
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
|
||||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||||
|
|
||||||
var audit = nm.BuildWriteFailureAuditEvent(
|
var audit = nm.BuildWriteFailureAuditEvent(
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
|
||||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
|
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||||
|
|
||||||
@@ -44,14 +44,75 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
|
||||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>B4-WP2 M1: AddReference wires the cross-tree Organizes edge from a UNS reference variable
|
||||||
|
/// (source, realm=Uns) to its backing Raw node (target, realm=Raw), bidirectionally, so browse resolves
|
||||||
|
/// both ways — and is idempotent.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AddReference_wires_Organizes_edge_from_UNS_variable_to_Raw_node()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
|
// Backing Raw node (device tree) and the UNS reference variable (equipment tree).
|
||||||
|
sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
|
||||||
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||||
|
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
||||||
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
||||||
|
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
||||||
|
|
||||||
|
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
||||||
|
var rawVar = nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
||||||
|
unsVar.ShouldNotBeNull();
|
||||||
|
rawVar.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Forward Organizes on the UNS variable → Raw node; inverse (OrganizedBy) on the Raw node.
|
||||||
|
unsVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: false, rawVar.NodeId).ShouldBeTrue();
|
||||||
|
rawVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: true, unsVar.NodeId).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Idempotent: a second identical call does not duplicate the edge. The UNS variable's only
|
||||||
|
// FORWARD Organizes ref is the one we added (its parent link manifests as an inverse ref).
|
||||||
|
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
||||||
|
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
||||||
|
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
|
||||||
|
.Count(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBe(1);
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>B4-WP2 M1: a missing endpoint makes AddReference a safe no-op (never throws, no edge added).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AddReference_with_missing_endpoint_is_a_safe_no_op()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
|
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
||||||
|
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
|
// Target does not exist ⇒ no-op, no throw.
|
||||||
|
Should.NotThrow(() => sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
||||||
|
"MAIN/does/not/exist", AddressSpaceRealm.Raw));
|
||||||
|
|
||||||
|
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
||||||
|
unsVar.ShouldNotBeNull();
|
||||||
|
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
|
||||||
|
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBeFalse();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
|
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RebuildAddressSpace_clears_all_registered_variables()
|
public async Task RebuildAddressSpace_clears_all_registered_variables()
|
||||||
@@ -59,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var (host, server) = await BootAsync();
|
var (host, server) = await BootAsync();
|
||||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||||
|
|
||||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
server.NodeManager!.VariableCount.ShouldBe(3);
|
server.NodeManager!.VariableCount.ShouldBe(3);
|
||||||
|
|
||||||
sink.RebuildAddressSpace();
|
sink.RebuildAddressSpace();
|
||||||
server.NodeManager.VariableCount.ShouldBe(0);
|
server.NodeManager.VariableCount.ShouldBe(0);
|
||||||
|
|
||||||
// After rebuild, subsequent writes start fresh.
|
// After rebuild, subsequent writes start fresh.
|
||||||
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
server.NodeManager.VariableCount.ShouldBe(1);
|
server.NodeManager.VariableCount.ShouldBe(1);
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
@@ -81,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
// Sanity check that the F10 fallback still works — production callers default to
|
// Sanity check that the F10 fallback still works — production callers default to
|
||||||
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
||||||
var sink = NullOpcUaAddressSpaceSink.Instance;
|
var sink = NullOpcUaAddressSpaceSink.Instance;
|
||||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.RebuildAddressSpace();
|
sink.RebuildAddressSpace();
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -100,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
||||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
||||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
nm.AlarmConditionCount.ShouldBe(1);
|
nm.AlarmConditionCount.ShouldBe(1);
|
||||||
|
|
||||||
@@ -111,11 +172,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
||||||
condition.ShouldBeOfType<OffNormalAlarmState>();
|
condition.ShouldBeOfType<OffNormalAlarmState>();
|
||||||
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
|
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
|
||||||
|
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
|
||||||
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs));
|
||||||
|
|
||||||
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
|
||||||
condition.Parent.ShouldNotBeNull();
|
condition.Parent.ShouldNotBeNull();
|
||||||
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex));
|
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs));
|
||||||
|
|
||||||
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
||||||
condition.EnabledState.Id.Value.ShouldBeTrue();
|
condition.EnabledState.Id.Value.ShouldBeTrue();
|
||||||
@@ -137,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
|
|
||||||
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
||||||
// BaseDataVariable is minted for the alarm id.
|
// BaseDataVariable is minted for the alarm id.
|
||||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||||
|
|
||||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||||
@@ -145,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||||
|
|
||||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
nm.AlarmConditionCount.ShouldBe(1);
|
nm.AlarmConditionCount.ShouldBe(1);
|
||||||
|
|
||||||
// RebuildAddressSpace clears the alarm dict too.
|
// RebuildAddressSpace clears the alarm dict too.
|
||||||
@@ -164,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-x");
|
var condition = nm.TryGetAlarmCondition("alm-x");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -186,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
|
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-rich");
|
var condition = nm.TryGetAlarmCondition("alm-rich");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -203,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
Shelving: AlarmShelvingKind.Timed,
|
Shelving: AlarmShelvingKind.Timed,
|
||||||
Severity: 850,
|
Severity: 850,
|
||||||
Message: "Pressure above limit"),
|
Message: "Pressure above limit"),
|
||||||
DateTime.UtcNow);
|
DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||||
@@ -232,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
|
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-base");
|
var condition = nm.TryGetAlarmCondition("alm-base");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -254,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
Shelving: AlarmShelvingKind.OneShot,
|
Shelving: AlarmShelvingKind.OneShot,
|
||||||
Severity: 500,
|
Severity: 500,
|
||||||
Message: "still works"),
|
Message: "still works"),
|
||||||
DateTime.UtcNow));
|
DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
// Mandatory state still projected despite the missing optional child.
|
// Mandatory state still projected despite the missing optional child.
|
||||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||||
@@ -282,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-evt");
|
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -293,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||||
|
|
||||||
// First engine-driven transition → fires an event with a fresh EventId.
|
// First engine-driven transition → fires an event with a fresh EventId.
|
||||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
firstEventId.ShouldNotBeNull();
|
firstEventId.ShouldNotBeNull();
|
||||||
@@ -305,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
secondEventId.ShouldNotBeNull();
|
secondEventId.ShouldNotBeNull();
|
||||||
@@ -331,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -340,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||||
Should.NotThrow(() =>
|
Should.NotThrow(() =>
|
||||||
{
|
{
|
||||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Final projected state is intact after the last firing.
|
// Final projected state is intact after the last firing.
|
||||||
@@ -370,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack");
|
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-ack");
|
var condition = nm.TryGetAlarmCondition("alm-ack");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
||||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
||||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||||
|
|
||||||
@@ -398,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
||||||
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
||||||
// no change ⇒ NO event fires.
|
// no change ⇒ NO event fires.
|
||||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
||||||
@@ -422,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var nm = server.NodeManager!;
|
var nm = server.NodeManager!;
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta");
|
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
|
||||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
|
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||||
|
|
||||||
var condition = nm.TryGetAlarmCondition("alm-delta");
|
var condition = nm.TryGetAlarmCondition("alm-delta");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
@@ -432,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
|
|
||||||
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
||||||
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
||||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
afterFirst.ShouldNotBeNull();
|
afterFirst.ShouldNotBeNull();
|
||||||
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
||||||
|
|
||||||
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
||||||
// events (EventId unchanged from the first fire).
|
// events (EventId unchanged from the first fire).
|
||||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
afterSecond.ShouldBe(afterFirst); // suppressed
|
afterSecond.ShouldBe(afterFirst); // suppressed
|
||||||
|
|
||||||
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
||||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||||
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
afterThird.ShouldNotBe(afterSecond); // fired
|
afterThird.ShouldNotBe(afterSecond); // fired
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
@@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
|
|||||||
var probe = CreateTestProbe();
|
var probe = CreateTestProbe();
|
||||||
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
|
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
|
||||||
|
|
||||||
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
|
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
|
||||||
|
|
||||||
var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
|
var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
|
||||||
routed.NodeId.ShouldBe("eq-1/speed");
|
routed.NodeId.ShouldBe("eq-1/speed");
|
||||||
@@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
|
|||||||
var probe = CreateTestProbe();
|
var probe = CreateTestProbe();
|
||||||
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
|
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
|
||||||
|
|
||||||
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
|
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
|
||||||
|
|
||||||
probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
|
probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
|
||||||
probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary"));
|
probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary"));
|
||||||
@@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
|
|||||||
resolveDriverHost: () => probe.Ref, NullLogger.Instance,
|
resolveDriverHost: () => probe.Ref, NullLogger.Instance,
|
||||||
askTimeout: TimeSpan.FromMilliseconds(200));
|
askTimeout: TimeSpan.FromMilliseconds(200));
|
||||||
|
|
||||||
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
|
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
|
||||||
|
|
||||||
outcome.Success.ShouldBeFalse();
|
outcome.Success.ShouldBeFalse();
|
||||||
outcome.Reason.ShouldNotBeNull();
|
outcome.Reason.ShouldNotBeNull();
|
||||||
@@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance);
|
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance);
|
||||||
|
|
||||||
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
|
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
|
||||||
|
|
||||||
outcome.Success.ShouldBeFalse();
|
outcome.Success.ShouldBeFalse();
|
||||||
outcome.Reason.ShouldBe("writes unavailable");
|
outcome.Reason.ShouldBe("writes unavailable");
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ internal static class DarkAddressSpaceReasons
|
|||||||
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
|
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
|
||||||
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
|
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
|
||||||
|
|
||||||
|
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
|
||||||
|
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
|
||||||
|
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
|
||||||
|
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
|
||||||
|
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
|
||||||
|
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
|
||||||
|
public const string DiscoveryInjectionDormantV3 =
|
||||||
|
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
|
||||||
|
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
|
||||||
|
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
|
||||||
|
"re-migrating injection onto the raw device subtree is a separate follow-up.";
|
||||||
|
|
||||||
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
||||||
public const string EquipmentDeviceBindingRetired =
|
public const string EquipmentDeviceBindingRetired =
|
||||||
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
|
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam
|
||||||
|
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) and the live compose seam
|
||||||
|
/// (<see cref="AddressSpaceComposer.Compose"/>) must produce BYTE-IDENTICAL <b>Raw</b> and <b>UNS</b> node
|
||||||
|
/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently
|
||||||
|
/// diverge the deployed dual-namespace address space from what was authored/validated. The existing
|
||||||
|
/// <c>DeploymentArtifactEquipRefParityTests</c> covers <c>{{equip}}</c> script-path parity; this covers the
|
||||||
|
/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths,
|
||||||
|
/// writable + historian + array shape).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeploymentArtifactRawUnsParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
|
||||||
|
{
|
||||||
|
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
|
||||||
|
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
|
||||||
|
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
|
||||||
|
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
|
||||||
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
|
||||||
|
var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null };
|
||||||
|
var speed = new Tag
|
||||||
|
{
|
||||||
|
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}",
|
||||||
|
};
|
||||||
|
var run = new Tag
|
||||||
|
{
|
||||||
|
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
|
||||||
|
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
|
};
|
||||||
|
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
|
||||||
|
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
|
||||||
|
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" };
|
||||||
|
|
||||||
|
var composed = AddressSpaceComposer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip },
|
||||||
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: new[] { reference }, tags: new[] { speed, run },
|
||||||
|
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
|
||||||
|
|
||||||
|
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } },
|
||||||
|
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } },
|
||||||
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
|
||||||
|
TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } },
|
||||||
|
Tags = new object[]
|
||||||
|
{
|
||||||
|
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
|
||||||
|
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
|
||||||
|
},
|
||||||
|
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||||
|
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
|
||||||
|
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
|
||||||
|
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||||
|
|
||||||
|
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
|
||||||
|
composed.RawTags.Count.ShouldBe(2);
|
||||||
|
composed.UnsReferenceVariables.Count.ShouldBe(1);
|
||||||
|
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
|
||||||
|
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
|
||||||
|
|
||||||
|
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
|
||||||
|
decoded.RawContainers.ShouldBe(composed.RawContainers);
|
||||||
|
decoded.RawTags.ShouldBe(composed.RawTags);
|
||||||
|
decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-8
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
|||||||
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
|
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
|
||||||
/// overwrote the BadWaitingForInitialData seed.
|
/// overwrote the BadWaitingForInitialData seed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
|
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
|||||||
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
|
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
|
||||||
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
|
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_node_and_value_survive_a_redeploy()
|
public void Discovered_node_and_value_survive_a_redeploy()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -324,27 +324,29 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
|||||||
public int RebuildCalls;
|
public int RebuildCalls;
|
||||||
|
|
||||||
/// <summary>Records a live-value write.</summary>
|
/// <summary>Records a live-value write.</summary>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
||||||
|
|
||||||
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
|
||||||
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|
||||||
/// <summary>Records an EnsureFolder call.</summary>
|
/// <summary>Records an EnsureFolder call.</summary>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
|
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
|
||||||
/// <summary>Records an EnsureVariable call.</summary>
|
/// <summary>Records an EnsureVariable call.</summary>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, 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)
|
||||||
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||||
|
|
||||||
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
|
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
|
|
||||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-18
@@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
|
||||||
|
/// A driver reports a FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>, but the host must
|
||||||
|
/// NOT materialise anything (no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> reaches the publish
|
||||||
|
/// side) — discovered raw tags are authored via the <c>/raw</c> browse-commit flow, not injected at runtime.
|
||||||
|
/// The skipped v2 injection scenarios below are the counterpart of this guard.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Discovered_nodes_are_ignored_dormant_in_v3()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
|
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||||
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||||
|
{
|
||||||
|
new DiscoveredNode(
|
||||||
|
FolderPathSegments: new[] { "FOCAS", "Identity" },
|
||||||
|
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
|
||||||
|
Writable: false, IsHistorized: false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise).
|
||||||
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
|
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
|
||||||
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
|
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
|
||||||
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
|
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
|
||||||
/// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
|
/// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
|
||||||
/// live-value routing map was extended).</summary>
|
/// live-value routing map was extended).</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
|
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
|
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
|
||||||
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
|
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
|
||||||
/// "no equipment/authored tags".</summary>
|
/// "no equipment/authored tags".</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
|
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
|
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
|
||||||
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
|
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
|
||||||
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
|
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
|
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -238,10 +265,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
|
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
|
||||||
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
|
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
|
||||||
// (a lowercase-only "h1" check would miss a leaked raw "H1").
|
// (a lowercase-only "h1" check would miss a leaked raw "H1").
|
||||||
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
|
eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
|
||||||
eqANodeId.ShouldNotContain("H1");
|
eqANodeId.ShouldNotContain("H1");
|
||||||
eqANodeId.ShouldNotContain("h1");
|
eqANodeId.ShouldNotContain("h1");
|
||||||
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
|
eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
|
||||||
eqBNodeId.ShouldNotContain("h2");
|
eqBNodeId.ShouldNotContain("h2");
|
||||||
|
|
||||||
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
|
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
|
||||||
@@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
|
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
|
||||||
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
|
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
|
||||||
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
|
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
|
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
|
/// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
|
||||||
/// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
|
/// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
|
||||||
/// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
|
/// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
|
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
|
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
|
||||||
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
|
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
|
||||||
/// are materialised.</summary>
|
/// are materialised.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
|
public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
|
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
|
||||||
/// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
|
/// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
|
||||||
/// FixedTree still converges.</summary>
|
/// FixedTree still converges.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
|
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
|
/// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
|
||||||
/// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
|
/// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
|
||||||
/// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
|
/// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_nodes_survive_a_redeploy_rebuild()
|
public void Discovered_nodes_survive_a_redeploy_rebuild()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
|
/// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
|
||||||
/// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
|
/// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
|
||||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
|
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
|
public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
|
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
|
||||||
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
|
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
|
||||||
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
|
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
|
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
|
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
|
||||||
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
|
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
|
||||||
/// under the new equipment EQ-2.</summary>
|
/// under the new equipment EQ-2.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
|
public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
|
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
|
||||||
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
|
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
|
||||||
/// pass.</summary>
|
/// pass.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void No_drop_redeploy_does_not_re_trigger_discovery()
|
public void No_drop_redeploy_does_not_re_trigger_discovery()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the
|
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the
|
||||||
/// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
|
/// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
|
||||||
/// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
|
/// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
|
public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
|
/// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
|
||||||
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery
|
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery
|
||||||
/// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
|
/// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
|
public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail
|
/// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail
|
||||||
/// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
|
/// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
|
||||||
/// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
|
/// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
|
public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
@@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
|
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
|
||||||
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
|
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
|
||||||
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
|
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||||
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
|
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
|
|||||||
+143
-84
@@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the equipment-tag <b>live-value routing</b> wired into <see cref="DriverHostActor"/>:
|
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
|
||||||
/// a driver publishes a value keyed by its wire-ref <c>FullName</c>, but the equipment variable was
|
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
|
||||||
/// materialised under a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an
|
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
|
||||||
/// apply, the host's <c>_nodeIdByDriverRef</c> map resolves <c>(DriverInstanceId, FullName)</c> to
|
/// <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> — resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
|
||||||
/// that folder-scoped NodeId, so <c>ForwardToMux</c> lands the value on the right node (and still
|
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
|
||||||
/// forwards the raw value to the dependency mux for VirtualTag inputs).
|
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
|
||||||
///
|
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
|
||||||
|
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Drives a real apply through the existing harness: the seeded artifact carries the
|
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
|
||||||
/// <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays that
|
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
|
||||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, and a
|
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
|
||||||
/// <c>DispatchDeployment</c> triggers the <c>ApplyAndAck → PushDesiredSubscriptions</c> pass
|
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
|
||||||
/// that builds the map. The OPC UA sink and the dependency mux are injected as
|
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
|
||||||
/// <see cref="Akka.TestKit.TestProbe"/>s.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||||
@@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
|||||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped
|
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
|
||||||
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary>
|
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact]
|
||||||
public void Driver_value_routes_to_folder_scoped_equipment_NodeId()
|
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed".
|
var deploymentId = SeedV3Deployment(db, RevA,
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||||
|
|
||||||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
// The driver publishes by its wire-ref RawPath.
|
||||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
"drv-1", "40001", 42.0, OpcUaQuality.Good, Ts));
|
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||||
update.NodeId.ShouldBe("eq-1/speed");
|
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||||
update.Value.ShouldBe(42.0);
|
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
|
||||||
update.Quality.ShouldBe(OpcUaQuality.Good);
|
|
||||||
update.TimestampUtc.ShouldBe(Ts);
|
|
||||||
|
|
||||||
// The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName).
|
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
|
||||||
|
// Realm travels with each update so the sink resolves the right namespace.
|
||||||
|
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
|
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||||
|
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
|
||||||
|
foreach (var u in byId.Values)
|
||||||
|
{
|
||||||
|
u.Value.ShouldBe(42.0);
|
||||||
|
u.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
u.TimestampUtc.ShouldBe(Ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
|
||||||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||||||
.FullReference.ShouldBe("40001");
|
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one
|
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
|
||||||
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary>
|
[Fact]
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
public void Unreferenced_raw_tag_fans_to_raw_node_only()
|
||||||
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
|
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
// Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines).
|
var deploymentId = SeedV3Deployment(db, RevA,
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"),
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||||
(Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
|
||||||
|
|
||||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
"drv-1", "40001", 7.0, OpcUaQuality.Good, Ts));
|
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
// One publish → two updates. Assert the SET of NodeIds (order is not contractual).
|
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||||
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||||
var second = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
new[] { first.NodeId, second.NodeId }.ShouldBe(
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true);
|
}
|
||||||
first.Value.ShouldBe(7.0);
|
|
||||||
second.Value.ShouldBe(7.0);
|
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
|
||||||
|
/// identical value) — the 1:N fan-out.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedV3Deployment(db, RevA,
|
||||||
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||||
|
unsRefs: new[]
|
||||||
|
{
|
||||||
|
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
|
||||||
|
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
|
||||||
|
});
|
||||||
|
|
||||||
|
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
|
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
|
var updates = new[]
|
||||||
|
{
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||||
|
};
|
||||||
|
updates.Select(u => u.NodeId).ShouldBe(new[]
|
||||||
|
{
|
||||||
|
"Plant/Modbus/dev1/speed",
|
||||||
|
"filling/line1/station1/speed",
|
||||||
|
"filling/line1/station2/speed",
|
||||||
|
}, ignoreOrder: true);
|
||||||
|
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
|
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
|
||||||
@@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
|||||||
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
|
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
var deploymentId = SeedV3Deployment(db, RevA,
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||||
|
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||||
|
|
||||||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
"drv-1", "59999", 99.0, OpcUaQuality.Good, Ts));
|
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
// No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink.
|
|
||||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
// The raw publish still reaches the dependency mux.
|
|
||||||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||||||
.FullReference.ShouldBe("59999");
|
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
|
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
|
||||||
/// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed
|
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
|
||||||
/// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't
|
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||||
/// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost).</summary>
|
|
||||||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
|
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||||
{
|
{
|
||||||
@@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
|
|
||||||
// ExpectMsg<AttributeValueUpdate> assertions see only value updates.
|
|
||||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
return (actor, publish, mux);
|
return (actor, publish, mux);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||||
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
|
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
|
||||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags:
|
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
|
||||||
/// <c>Namespaces</c> (one Equipment-kind ns), <c>DriverInstances</c> (each driver bound to that
|
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
|
||||||
/// ns), and <c>Tags</c> (each with a non-null EquipmentId + a TagConfig blob carrying FullName).
|
/// numerically (AccessLevel: ReadWrite = 1).</summary>
|
||||||
/// </summary>
|
private static DeploymentId SeedV3Deployment(
|
||||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||||||
|
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
|
||||||
{
|
{
|
||||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
var drivers = rawTags
|
||||||
|
.Select(t => (t.Driver, t.DriverName))
|
||||||
|
.Distinct()
|
||||||
|
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
|
||||||
|
.ToArray();
|
||||||
|
var devices = rawTags
|
||||||
|
.Select(t => (t.Driver, t.Device))
|
||||||
|
.Distinct()
|
||||||
|
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
|
||||||
|
.ToArray();
|
||||||
|
var tags = rawTags.Select(t => new
|
||||||
|
{
|
||||||
|
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
|
||||||
|
DeviceId = $"{t.Driver}:{t.Device}",
|
||||||
|
TagGroupId = (string?)null,
|
||||||
|
Name = t.Tag,
|
||||||
|
DataType = t.DataType,
|
||||||
|
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
||||||
|
TagConfig = "{}",
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
||||||
|
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
|
||||||
|
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
|
||||||
|
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
|
||||||
|
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
|
||||||
|
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
|
||||||
|
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
|
||||||
|
var unsTagReferences = unsRefs.Select((r, i) => new
|
||||||
|
{
|
||||||
|
UnsTagReferenceId = $"ref-{i}",
|
||||||
|
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
|
||||||
|
TagId = r.BackingTag,
|
||||||
|
DisplayNameOverride = r.Effective,
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
Namespaces = new[]
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||||
{
|
DriverInstances = drivers,
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
Devices = devices,
|
||||||
},
|
TagGroups = Array.Empty<object>(),
|
||||||
DriverInstances = driverIds.Select(d => new
|
Tags = tags,
|
||||||
{
|
UnsAreas = areas,
|
||||||
DriverInstanceId = d,
|
UnsLines = lines,
|
||||||
NamespaceId = "ns-eq",
|
Equipment = equipment,
|
||||||
}).ToArray(),
|
UnsTagReferences = unsTagReferences,
|
||||||
Tags = tags.Select((t, i) => new
|
|
||||||
{
|
|
||||||
TagId = $"tag-{i}",
|
|
||||||
EquipmentId = t.Equip,
|
|
||||||
DriverInstanceId = t.Driver,
|
|
||||||
Name = t.Name,
|
|
||||||
FolderPath = t.Folder,
|
|
||||||
DataType = "Double",
|
|
||||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
|
||||||
}).ToArray(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var id = DeploymentId.NewId();
|
var id = DeploymentId.NewId();
|
||||||
|
|||||||
+51
-56
@@ -19,21 +19,22 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the inbound native-condition <b>acknowledge</b> routing wired into
|
/// v3 Batch 4 (B4-WP4) — the inbound native-condition <b>acknowledge</b> routing wired into
|
||||||
/// <see cref="DriverHostActor"/> (H6d): an OPC UA client Acknowledges a NATIVE condition, the
|
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges
|
||||||
/// node manager invokes <c>NativeAlarmAckRouter</c>, and the host (NEXT task) Tells a
|
/// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes
|
||||||
/// <see cref="DriverHostActor.RouteNativeAlarmAck"/> in. The host resolves the condition NodeId →
|
/// <c>NativeAlarmAckRouter</c> and the host receives a <see cref="DriverHostActor.RouteNativeAlarmAck"/>.
|
||||||
/// owning <c>(DriverInstanceId, FullName)</c> via the <c>_driverRefByAlarmNodeId</c> inverse map
|
/// The host resolves the condition NodeId (== the RawPath) → owning <c>(DriverInstanceId, RawPath)</c> via
|
||||||
/// (built alongside the alarm forward map in <c>PushDesiredSubscriptions</c>), applies the SAME
|
/// the <c>_driverRefByAlarmNodeId</c> inverse map (built alongside the alarm forward map in
|
||||||
|
/// <c>PushDesiredSubscriptions</c> from the alarm-bearing <c>composition.RawTags</c>), applies the SAME
|
||||||
/// primary gate the inbound write path uses, and routes to the owning driver child's
|
/// primary gate the inbound write path uses, and routes to the owning driver child's
|
||||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Mirrors <c>DriverHostActorWriteRoutingTests</c>: a real apply through the existing harness
|
/// Mirrors <c>DriverHostActorLiveValueTests</c>: a real apply through the harness spawns a real
|
||||||
/// spawns a real (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording
|
/// (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording <see cref="IAlarmSource"/>
|
||||||
/// <see cref="IAlarmSource"/> driver, so the inverse map is populated authentically and the
|
/// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the
|
||||||
/// forwarded acknowledge request can be observed. The seeded tag carries an <c>alarm</c> object so
|
/// forwarded acknowledge request can be observed. The seeded raw tag carries an <c>alarm</c> object so it
|
||||||
/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
|
/// materialises as a Part 9 condition at its RawPath, not a value variable.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
|
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
|
||||||
@@ -42,31 +43,31 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
|
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag.
|
||||||
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
private const string AlarmRawPath = "Plant/gw/dev1/temp_hi";
|
||||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
|
|
||||||
/// principal, and the comment.</summary>
|
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
/// (== the RawPath) forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
||||||
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, correlated on the RawPath, with the operator principal + the
|
||||||
|
/// comment.</summary>
|
||||||
|
[Fact]
|
||||||
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
|
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
// → condition NodeId "eq-1/temp_hi".
|
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
||||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||||
|
|
||||||
// The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
|
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
recorder.Acks.Count.ShouldBe(1);
|
recorder.Acks.Count.ShouldBe(1);
|
||||||
recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
|
recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath);
|
||||||
recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
|
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
|
||||||
recorder.Acks[0].Comment.ShouldBe("cmt");
|
recorder.Acks[0].Comment.ShouldBe("cmt");
|
||||||
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
||||||
}, duration: Timeout);
|
}, duration: Timeout);
|
||||||
@@ -79,27 +80,24 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
|
||||||
|
|
||||||
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
||||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
||||||
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called — a secondary keeps its address
|
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called.</summary>
|
||||||
/// space warm but must not push commands to the shared upstream alarm system.</summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
|
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
@@ -112,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
},
|
},
|
||||||
CorrelationId.NewId()));
|
CorrelationId.NewId()));
|
||||||
|
|
||||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||||
|
|
||||||
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
||||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||||
@@ -137,51 +135,48 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||||
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object so
|
/// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag)
|
||||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null <c>EquipmentTagAlarmInfo</c> —
|
/// with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||||
/// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
|
/// <c>EquipmentTagAlarmInfo</c> — making the raw tag a Part 9 condition at its RawPath. The non-Windows
|
||||||
/// <c>DriverInstances</c> row carries a non-Windows-only <c>DriverType</c> ("GalaxyMxGateway") + an
|
/// <c>DriverType</c> + Enabled flag spawn a REAL <see cref="DriverInstanceActor"/> child.
|
||||||
/// Enabled flag so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
private static DeploymentId SeedV3AlarmDeployment(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string Driver, string Tag)
|
||||||
string Equip, string Driver, string FullName, string? Folder, string Name)
|
|
||||||
{
|
{
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
Namespaces = new[]
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||||
{
|
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
|
||||||
},
|
|
||||||
DriverInstances = new[]
|
DriverInstances = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
DriverInstanceRowId = Guid.NewGuid(),
|
DriverInstanceRowId = Guid.NewGuid(),
|
||||||
DriverInstanceId = Driver,
|
DriverInstanceId = Driver,
|
||||||
Name = Driver,
|
RawFolderId = "rf-plant",
|
||||||
|
Name = "gw",
|
||||||
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
DriverConfig = "{}",
|
DriverConfig = "{}",
|
||||||
NamespaceId = "ns-eq",
|
ClusterId = "c1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Devices = new[]
|
||||||
|
{
|
||||||
|
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
|
||||||
|
},
|
||||||
|
TagGroups = Array.Empty<object>(),
|
||||||
Tags = new[]
|
Tags = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
TagId = "tag-0",
|
TagId = "tag-0",
|
||||||
EquipmentId = Equip,
|
DeviceId = $"{Driver}:dev1",
|
||||||
DriverInstanceId = Driver,
|
TagGroupId = (string?)null,
|
||||||
Name,
|
Name = Tag,
|
||||||
FolderPath = Folder,
|
|
||||||
DataType = "Boolean",
|
DataType = "Boolean",
|
||||||
TagConfig = JsonSerializer.Serialize(new
|
AccessLevel = 0, // TagAccessLevel.Read
|
||||||
{
|
TagConfig = JsonSerializer.Serialize(new { alarm = new { alarmType = "OffNormalAlarm", severity = 700 } }),
|
||||||
FullName,
|
|
||||||
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+95
-122
@@ -24,22 +24,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
|
/// v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
|
||||||
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
|
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
|
||||||
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
|
/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
|
||||||
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
|
/// equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
|
||||||
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
|
/// alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
|
||||||
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
|
/// condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
|
||||||
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
|
/// <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
|
||||||
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
|
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
|
||||||
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
|
/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
|
||||||
/// alarms use.
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
/// Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||||
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
|
/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
|
||||||
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
|
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
|
|
||||||
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
|
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -49,25 +47,26 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
|
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
|
||||||
/// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
|
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
|
||||||
|
|
||||||
|
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
|
||||||
|
/// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
|
||||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
|
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
|
||||||
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
|
/// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
|
||||||
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
|
/// <c>SourceNodeId</c>.</summary>
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
[Fact]
|
||||||
public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
|
public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi".
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "temperature high",
|
Message: "temperature high",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -75,7 +74,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
Kind: AlarmTransitionKind.Raise)));
|
Kind: AlarmTransitionKind.Raise)));
|
||||||
|
|
||||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
|
update.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||||
update.State.Active.ShouldBeTrue();
|
update.State.Active.ShouldBeTrue();
|
||||||
update.State.Acknowledged.ShouldBeFalse();
|
update.State.Acknowledged.ShouldBeFalse();
|
||||||
update.TimestampUtc.ShouldBe(Ts);
|
update.TimestampUtc.ShouldBe(Ts);
|
||||||
@@ -87,35 +87,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
|
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop
|
ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "nope",
|
Message: "nope",
|
||||||
Severity: AlarmSeverity.Low,
|
Severity: AlarmSeverity.Low,
|
||||||
SourceTimestampUtc: Ts,
|
SourceTimestampUtc: Ts,
|
||||||
Kind: AlarmTransitionKind.Raise)));
|
Kind: AlarmTransitionKind.Raise)));
|
||||||
|
|
||||||
// No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink.
|
|
||||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref
|
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||||
/// publishes exactly one <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
|
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||||
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
|
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||||
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
|
[Fact]
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
|
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var alerts = CreateTestProbe();
|
var alerts = CreateTestProbe();
|
||||||
SubscribeToAlerts(alerts);
|
SubscribeToAlerts(alerts);
|
||||||
@@ -124,8 +120,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
ConditionId: AlarmRawPath,
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "temperature high",
|
Message: "temperature high",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -134,38 +130,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
// The OPC UA condition update is UNGATED — it must arrive.
|
// The OPC UA condition update is UNGATED — it must arrive.
|
||||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
|
|
||||||
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
|
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
|
||||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId
|
evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath)
|
||||||
evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId
|
evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath
|
||||||
evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name
|
evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name
|
||||||
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
|
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
|
||||||
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
|
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
|
||||||
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
|
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
|
||||||
evt.Message.ShouldBe("temperature high");
|
evt.Message.ShouldBe("temperature high");
|
||||||
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
|
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
|
||||||
// This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
|
evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on
|
||||||
// gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
|
|
||||||
// suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
|
|
||||||
evt.HistorizeToAveva.ShouldBeNull();
|
|
||||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
|
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose <c>TagConfig.alarm</c> carries
|
/// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
|
||||||
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
|
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
|
||||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate
|
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
|
||||||
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
|
/// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
|
||||||
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
|
[Fact]
|
||||||
/// publishes; only the durable row is gated downstream).</summary>
|
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Native_alarm_historizeToAveva_false_threads_through()
|
public void Native_alarm_historizeToAveva_false_threads_through()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
|
||||||
historizeToAveva: false);
|
|
||||||
|
|
||||||
var alerts = CreateTestProbe();
|
var alerts = CreateTestProbe();
|
||||||
SubscribeToAlerts(alerts);
|
SubscribeToAlerts(alerts);
|
||||||
@@ -175,7 +164,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp",
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "Temp.HiHi",
|
ConditionId: AlarmRawPath,
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "temperature high",
|
Message: "temperature high",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -183,21 +172,17 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
Kind: AlarmTransitionKind.Raise)));
|
Kind: AlarmTransitionKind.Raise)));
|
||||||
|
|
||||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
evt.AlarmId.ShouldBe("eq-1/temp_hi");
|
evt.AlarmId.ShouldBe(AlarmRawPath);
|
||||||
// The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
|
|
||||||
evt.HistorizeToAveva.ShouldBe(false);
|
evt.HistorizeToAveva.ShouldBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
|
/// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
|
||||||
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
|
/// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
|
||||||
/// deliberately opts in is recorded as such on the transition.</summary>
|
[Fact]
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Native_alarm_historizeToAveva_true_threads_through()
|
public void Native_alarm_historizeToAveva_true_threads_through()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
|
||||||
historizeToAveva: true);
|
|
||||||
|
|
||||||
var alerts = CreateTestProbe();
|
var alerts = CreateTestProbe();
|
||||||
SubscribeToAlerts(alerts);
|
SubscribeToAlerts(alerts);
|
||||||
@@ -207,7 +192,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp",
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "Temp.HiHi",
|
ConditionId: AlarmRawPath,
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "temperature high",
|
Message: "temperature high",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -218,16 +203,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
evt.HistorizeToAveva.ShouldBe(true);
|
evt.HistorizeToAveva.ShouldBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host
|
/// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
|
||||||
/// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
|
/// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
|
||||||
/// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
|
/// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
|
||||||
/// the single fleet-wide copy).</summary>
|
[Fact]
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
|
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var alerts = CreateTestProbe();
|
var alerts = CreateTestProbe();
|
||||||
SubscribeToAlerts(alerts);
|
SubscribeToAlerts(alerts);
|
||||||
@@ -239,8 +222,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
ConditionId: AlarmRawPath,
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "temperature high",
|
Message: "temperature high",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -249,7 +232,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
|
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
|
||||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||||
update.State.Active.ShouldBeTrue();
|
update.State.Active.ShouldBeTrue();
|
||||||
|
|
||||||
// The cluster-wide alerts publish is gated off on the secondary.
|
// The cluster-wide alerts publish is gated off on the secondary.
|
||||||
@@ -257,27 +240,23 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
|
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
|
||||||
/// <c>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
|
/// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
|
||||||
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
|
/// the operator string and <c>User</c> is <c>"device"</c>.</summary>
|
||||||
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
|
[Fact]
|
||||||
/// </summary>
|
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Native_alarm_operator_comment_flows_to_transition_event()
|
public void Native_alarm_operator_comment_flows_to_transition_event()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
||||||
|
|
||||||
var alerts = CreateTestProbe();
|
var alerts = CreateTestProbe();
|
||||||
SubscribeToAlerts(alerts);
|
SubscribeToAlerts(alerts);
|
||||||
|
|
||||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||||
|
|
||||||
// Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
|
|
||||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||||
new StubAlarmHandle(),
|
new StubAlarmHandle(),
|
||||||
SourceNodeId: "Temp",
|
SourceNodeId: "Temp",
|
||||||
ConditionId: "Temp.HiHi",
|
ConditionId: AlarmRawPath,
|
||||||
AlarmType: "OffNormalAlarm",
|
AlarmType: "OffNormalAlarm",
|
||||||
Message: "investigating",
|
Message: "investigating",
|
||||||
Severity: AlarmSeverity.High,
|
Severity: AlarmSeverity.High,
|
||||||
@@ -285,18 +264,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
Kind: AlarmTransitionKind.Acknowledge,
|
Kind: AlarmTransitionKind.Acknowledge,
|
||||||
OperatorComment: "investigating")));
|
OperatorComment: "investigating")));
|
||||||
|
|
||||||
// OPC UA condition update is ungated — drain it.
|
|
||||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// The published AlarmTransitionEvent must carry the comment + the "device" user marker.
|
|
||||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||||
evt.Comment.ShouldBe("investigating");
|
evt.Comment.ShouldBe("investigating");
|
||||||
evt.User.ShouldBe("device");
|
evt.User.ShouldBe("device");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
|
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||||
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the
|
|
||||||
/// ScriptedAlarmHostActor test harness.</summary>
|
|
||||||
private void SubscribeToAlerts(TestProbe probe)
|
private void SubscribeToAlerts(TestProbe probe)
|
||||||
{
|
{
|
||||||
DistributedPubSub.Get(Sys).Mediator.Tell(
|
DistributedPubSub.Get(Sys).Mediator.Tell(
|
||||||
@@ -304,9 +279,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own
|
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
|
||||||
/// node (<see cref="TestNode"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
|
/// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
|
||||||
/// so the alerts-publish gate observes the local role.</summary>
|
|
||||||
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
|
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
|
||||||
{
|
{
|
||||||
host.Tell(new RedundancyStateChanged(
|
host.Tell(new RedundancyStateChanged(
|
||||||
@@ -322,10 +296,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
CorrelationId.NewId()));
|
CorrelationId.NewId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied
|
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
|
||||||
/// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the
|
/// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
|
||||||
/// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
|
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||||
{
|
{
|
||||||
var coordinator = CreateTestProbe();
|
var coordinator = CreateTestProbe();
|
||||||
@@ -352,47 +326,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||||
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
|
/// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's <c>TagConfig</c>
|
||||||
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
|
/// carrying an <c>alarm</c> object so the composer projects a non-null <c>EquipmentTagAlarmInfo</c>
|
||||||
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
|
/// (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
|
||||||
/// condition rather than a value variable.
|
/// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
|
||||||
|
/// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
private static DeploymentId SeedV3AlarmDeployment(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||||
string Equip, string Driver, string FullName, string? Folder, string Name,
|
string Driver, string Tag, bool? historizeToAveva = null)
|
||||||
bool? historizeToAveva = null)
|
|
||||||
{
|
{
|
||||||
// historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
|
// historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
|
||||||
// exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
|
// a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||||
object alarm = historizeToAveva is { } h
|
object alarm = historizeToAveva is { } h
|
||||||
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
|
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
|
||||||
: new { alarmType = "OffNormalAlarm", severity = 700 };
|
: new { alarmType = "OffNormalAlarm", severity = 700 };
|
||||||
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
Namespaces = new[]
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||||
{
|
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
|
||||||
},
|
|
||||||
DriverInstances = new[]
|
DriverInstances = new[]
|
||||||
{
|
{
|
||||||
new { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
|
new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||||
},
|
},
|
||||||
|
Devices = new[]
|
||||||
|
{
|
||||||
|
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
|
||||||
|
},
|
||||||
|
TagGroups = Array.Empty<object>(),
|
||||||
Tags = new[]
|
Tags = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
TagId = "tag-0",
|
TagId = "tag-0",
|
||||||
EquipmentId = Equip,
|
DeviceId = $"{Driver}:dev1",
|
||||||
DriverInstanceId = Driver,
|
TagGroupId = (string?)null,
|
||||||
Name,
|
Name = Tag,
|
||||||
FolderPath = Folder,
|
|
||||||
DataType = "Boolean",
|
DataType = "Boolean",
|
||||||
TagConfig = JsonSerializer.Serialize(new
|
AccessLevel = 0, // TagAccessLevel.Read
|
||||||
{
|
TagConfig = JsonSerializer.Serialize(new { alarm }),
|
||||||
FullName,
|
|
||||||
alarm,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
|||||||
using Akka.TestKit;
|
using Akka.TestKit;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||||
@@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
|||||||
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
|
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
|
||||||
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
@@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
|||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
res.Reason.ShouldNotBe("not primary");
|
res.Reason.ShouldNotBe("not primary");
|
||||||
res.Reason.ShouldNotBe("not primary (role unknown)");
|
res.Reason.ShouldNotBe("not primary (role unknown)");
|
||||||
@@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
|||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
var asker1 = CreateTestProbe();
|
var asker1 = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
|
||||||
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
res.Reason.ShouldNotBe("not primary");
|
res.Reason.ShouldNotBe("not primary");
|
||||||
res.Success.ShouldBeTrue(res.Reason);
|
res.Success.ShouldBeTrue(res.Reason);
|
||||||
@@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
|||||||
// Secondary snapshot ⇒ denied with the steady-state reason.
|
// Secondary snapshot ⇒ denied with the steady-state reason.
|
||||||
TellRole(actor, RedundancyRole.Secondary);
|
TellRole(actor, RedundancyRole.Secondary);
|
||||||
var asker2 = CreateTestProbe();
|
var asker2 = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
|
||||||
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
denied.Success.ShouldBeFalse();
|
denied.Success.ShouldBeFalse();
|
||||||
denied.Reason.ShouldBe("not primary");
|
denied.Reason.ShouldBe("not primary");
|
||||||
@@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
|
|||||||
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
|
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
|
||||||
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
|
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
|
||||||
|
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
|||||||
using Akka.Event;
|
using Akka.Event;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase
|
|||||||
// drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled
|
// drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled
|
||||||
// OpcUaProbeResult would already be on the EventStream before we assert.
|
// OpcUaProbeResult would already be on the EventStream before we assert.
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
|
|
||||||
// Assert no dead-letter carrying an OpcUaProbeResult was published.
|
// Assert no dead-letter carrying an OpcUaProbeResult was published.
|
||||||
|
|||||||
+182
-174
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
@@ -20,22 +21,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
|
/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
|
||||||
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
|
/// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
|
||||||
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
|
/// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
|
||||||
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
|
/// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
|
||||||
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
|
/// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
|
||||||
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
|
/// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
|
||||||
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
|
/// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
|
||||||
///
|
/// both reach the SAME driver point (they share the driver ref).
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Drives a real apply through the existing harness (same artifact shape as
|
/// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
|
||||||
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
|
/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
|
||||||
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
|
|
||||||
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
|
|
||||||
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
|
|
||||||
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
|
|
||||||
/// harness allows.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||||
@@ -44,48 +40,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
|
// The single seeded raw tag + its UNS reference.
|
||||||
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary>
|
private const string RawPath = "Plant/Modbus/dev1/speed";
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
private const string UnsNodeId = "filling/line1/station1/speed";
|
||||||
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
|
|
||||||
|
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
|
||||||
|
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingDriverFactory("Modbus");
|
var recorder = new RecordingDriverFactory("Modbus");
|
||||||
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
|
var deploymentId = SeedV3Deployment(db, RevA);
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
|
||||||
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||||
result.Success.ShouldBeTrue();
|
|
||||||
|
|
||||||
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
|
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
recorder.Writes.Count.ShouldBe(1);
|
recorder.Writes.Count.ShouldBe(1);
|
||||||
recorder.Writes[0].FullReference.ShouldBe("40001");
|
recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
|
||||||
recorder.Writes[0].Value.ShouldBe(123.0);
|
recorder.Writes[0].Value.ShouldBe(123.0);
|
||||||
}, duration: Timeout);
|
}, duration: Timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
|
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
|
||||||
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
/// ref and forwards the write keyed by the RawPath.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var recorder = new RecordingDriverFactory("Modbus");
|
||||||
|
var deploymentId = SeedV3Deployment(db, RevA);
|
||||||
|
|
||||||
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
|
var asker = CreateTestProbe();
|
||||||
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
|
||||||
|
|
||||||
|
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
recorder.Writes.Count.ShouldBe(1);
|
||||||
|
recorder.Writes[0].FullReference.ShouldBe(RawPath);
|
||||||
|
recorder.Writes[0].Value.ShouldBe(456.0);
|
||||||
|
}, duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
|
||||||
|
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Secondary_rejects_write_and_does_not_forward_to_driver()
|
public void Secondary_rejects_write_and_does_not_forward_to_driver()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingDriverFactory("Modbus");
|
var recorder = new RecordingDriverFactory("Modbus");
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
var deploymentId = SeedV3Deployment(db, RevA);
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
// Force this node Secondary so the primary gate rejects.
|
|
||||||
actor.Tell(new RedundancyStateChanged(
|
actor.Tell(new RedundancyStateChanged(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
@@ -95,29 +111,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
CorrelationId.NewId()));
|
CorrelationId.NewId()));
|
||||||
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
result.Reason.ShouldBe("not primary");
|
result.Reason.ShouldBe("not primary");
|
||||||
|
|
||||||
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
|
|
||||||
recorder.Writes.ShouldBeEmpty();
|
recorder.Writes.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
|
/// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Unknown_node_id_replies_failure()
|
public void Unknown_node_id_replies_failure()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var recorder = new RecordingDriverFactory("Modbus");
|
var recorder = new RecordingDriverFactory("Modbus");
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
var deploymentId = SeedV3Deployment(db, RevA);
|
||||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
@@ -125,49 +138,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
recorder.Writes.ShouldBeEmpty();
|
recorder.Writes.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
|
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
|
||||||
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
|
/// immediate negative reply mentioning "stale".</summary>
|
||||||
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
|
|
||||||
/// <c>(DriverInstanceId, <raw-blob>)</c> and the driver receives that exact string as its
|
|
||||||
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
|
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
|
||||||
public void Primary_routes_write_for_raw_protocol_blob_tag()
|
|
||||||
{
|
|
||||||
var db = NewInMemoryDbFactory();
|
|
||||||
var recorder = new RecordingDriverFactory("Modbus");
|
|
||||||
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
|
|
||||||
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
|
|
||||||
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
|
|
||||||
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
|
|
||||||
equip: "eq-2", driver: "drv-2", name: "torque");
|
|
||||||
|
|
||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
||||||
|
|
||||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
|
|
||||||
var asker = CreateTestProbe();
|
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
|
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
|
||||||
result.Success.ShouldBeTrue();
|
|
||||||
|
|
||||||
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
|
|
||||||
// there is no top-level "FullName" key in the blob.
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
recorder.Writes.Count.ShouldBe(1);
|
|
||||||
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
|
|
||||||
recorder.Writes[0].Value.ShouldBe(456.0);
|
|
||||||
}, duration: Timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
|
|
||||||
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
|
|
||||||
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
|
|
||||||
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Stale_host_fast_fails_route_node_write()
|
public void Stale_host_fast_fails_route_node_write()
|
||||||
{
|
{
|
||||||
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
|
|
||||||
var db = new ThrowingDbFactory();
|
var db = new ThrowingDbFactory();
|
||||||
var coordinator = CreateTestProbe();
|
var coordinator = CreateTestProbe();
|
||||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||||
@@ -175,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
localRoles: new HashSet<string> { "driver" }));
|
localRoles: new HashSet<string> { "driver" }));
|
||||||
|
|
||||||
var asker = CreateTestProbe();
|
var asker = CreateTestProbe();
|
||||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||||
|
|
||||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
@@ -183,10 +158,49 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
result.Reason!.ShouldContain("stale");
|
result.Reason!.ShouldContain("stale");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
|
/// <summary>
|
||||||
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
|
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
|
||||||
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
|
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
|
||||||
/// only the write path, which doesn't depend on the publish actor.</summary>
|
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
|
||||||
|
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
|
||||||
|
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new PerInstanceRecordingDriverFactory("Modbus");
|
||||||
|
var deploymentId = SeedCollisionDeployment(db, RevA);
|
||||||
|
|
||||||
|
var actor = SpawnHostAndApply(db, deploymentId, factory);
|
||||||
|
|
||||||
|
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
|
||||||
|
var asker1 = CreateTestProbe();
|
||||||
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
|
||||||
|
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
|
||||||
|
var asker2 = CreateTestProbe();
|
||||||
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
|
||||||
|
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
|
||||||
|
var w1 = factory.WritesFor("drv-1");
|
||||||
|
var w2 = factory.WritesFor("drv-2");
|
||||||
|
w1.Count.ShouldBe(1);
|
||||||
|
w1[0].FullReference.ShouldBe("A/B/C/D");
|
||||||
|
w1[0].Value.ShouldBe(111.0);
|
||||||
|
w2.Count.ShouldBe(1);
|
||||||
|
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
|
||||||
|
w2[0].Value.ShouldBe(222.0);
|
||||||
|
}, duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
|
||||||
|
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
|
||||||
|
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
|
||||||
private IActorRef SpawnHostAndApply(
|
private IActorRef SpawnHostAndApply(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
||||||
{
|
{
|
||||||
@@ -198,49 +212,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
|
||||||
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
|
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
|
||||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
|
/// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
|
||||||
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
|
/// <c>filling/line1/station1/speed</c>).</summary>
|
||||||
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
|
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||||
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
|
|
||||||
/// </summary>
|
|
||||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
||||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
|
||||||
{
|
{
|
||||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
|
||||||
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
Namespaces = new[]
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||||
|
DriverInstances = new[]
|
||||||
{
|
{
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
||||||
},
|
},
|
||||||
DriverInstances = driverIds.Select(d => new
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||||
|
TagGroups = Array.Empty<object>(),
|
||||||
|
Tags = new[]
|
||||||
{
|
{
|
||||||
DriverInstanceRowId = Guid.NewGuid(),
|
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
||||||
DriverInstanceId = d,
|
},
|
||||||
Name = d,
|
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
|
||||||
Enabled = true,
|
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
|
||||||
DriverConfig = "{}",
|
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
|
||||||
NamespaceId = "ns-eq",
|
|
||||||
}).ToArray(),
|
|
||||||
Tags = tags.Select((t, i) => new
|
|
||||||
{
|
|
||||||
TagId = $"tag-{i}",
|
|
||||||
EquipmentId = t.Equip,
|
|
||||||
DriverInstanceId = t.Driver,
|
|
||||||
Name = t.Name,
|
|
||||||
FolderPath = t.Folder,
|
|
||||||
DataType = "Double",
|
|
||||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
|
||||||
}).ToArray(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var id = DeploymentId.NewId();
|
var id = DeploymentId.NewId();
|
||||||
@@ -258,77 +255,54 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
|
||||||
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
|
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
|
||||||
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
|
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
|
||||||
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
|
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||||
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
|
|
||||||
/// returning the raw blob string verbatim — that raw string becomes the
|
|
||||||
/// <c>(DriverInstanceId, <raw-blob>)</c> reverse-map key, and the driver receives it as
|
|
||||||
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
|
|
||||||
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
|
|
||||||
/// </summary>
|
|
||||||
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
||||||
string equip, string driver, string name)
|
|
||||||
{
|
{
|
||||||
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
|
|
||||||
var rawBlobString = JsonSerializer.Serialize(
|
|
||||||
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
|
|
||||||
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
Namespaces = new[]
|
RawFolders = new[]
|
||||||
{
|
{
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
|
||||||
|
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
|
||||||
},
|
},
|
||||||
DriverInstances = new[]
|
DriverInstances = new[]
|
||||||
{
|
{
|
||||||
new
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
||||||
{
|
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
||||||
DriverInstanceRowId = Guid.NewGuid(),
|
|
||||||
DriverInstanceId = driver,
|
|
||||||
Name = driver,
|
|
||||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
|
||||||
Enabled = true,
|
|
||||||
DriverConfig = "{}",
|
|
||||||
NamespaceId = "ns-eq",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
Devices = new[]
|
||||||
|
{
|
||||||
|
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
|
||||||
|
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
|
||||||
|
},
|
||||||
|
TagGroups = Array.Empty<object>(),
|
||||||
Tags = new[]
|
Tags = new[]
|
||||||
{
|
{
|
||||||
new
|
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
||||||
{
|
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
||||||
TagId = "tag-raw",
|
|
||||||
EquipmentId = equip,
|
|
||||||
DriverInstanceId = driver,
|
|
||||||
Name = name,
|
|
||||||
FolderPath = (string?)null,
|
|
||||||
DataType = "Double",
|
|
||||||
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
|
|
||||||
TagConfig = rawBlobString,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
|
||||||
|
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
|
||||||
|
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
|
||||||
|
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
|
||||||
|
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
var id = DeploymentId.NewId();
|
var id = DeploymentId.NewId();
|
||||||
using var ctx = db.CreateDbContext();
|
using var ctx = db.CreateDbContext();
|
||||||
ctx.Deployments.Add(new Deployment
|
ctx.Deployments.Add(new Deployment
|
||||||
{
|
{
|
||||||
DeploymentId = id.Value,
|
DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed,
|
||||||
RevisionHash = rev.Value,
|
CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact,
|
||||||
Status = DeploymentStatus.Sealed,
|
|
||||||
CreatedBy = "test",
|
|
||||||
SealedAtUtc = DateTime.UtcNow,
|
|
||||||
ArtifactBlob = artifact,
|
|
||||||
});
|
});
|
||||||
ctx.SaveChanges();
|
ctx.SaveChanges();
|
||||||
return (id, rawBlobString);
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
|
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
|
||||||
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
|
/// into the Stale path.</summary>
|
||||||
/// so a write can be routed at a Stale host.</summary>
|
|
||||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -336,15 +310,49 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
throw new InvalidOperationException("config DB unreachable (test stub)");
|
throw new InvalidOperationException("config DB unreachable (test stub)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
|
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
|
||||||
/// recorded write list is exposed for assertions.</summary>
|
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
|
||||||
|
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
|
||||||
|
{
|
||||||
|
private readonly string _supportedType;
|
||||||
|
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
|
||||||
|
private readonly object _lock = new();
|
||||||
|
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||||
|
|
||||||
|
/// <summary>The writes the given driver instance received.</summary>
|
||||||
|
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
|
||||||
|
{
|
||||||
|
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_byId.TryGetValue(driverInstanceId, out var d))
|
||||||
|
{
|
||||||
|
d = new RecordingDriver();
|
||||||
|
d.Bind(driverInstanceId, driverType);
|
||||||
|
_byId[driverInstanceId] = d;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
|
||||||
private sealed class RecordingDriverFactory : IDriverFactory
|
private sealed class RecordingDriverFactory : IDriverFactory
|
||||||
{
|
{
|
||||||
private readonly string _supportedType;
|
private readonly string _supportedType;
|
||||||
private readonly RecordingDriver _driver = new();
|
private readonly RecordingDriver _driver = new();
|
||||||
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||||
|
|
||||||
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
|
/// <summary>The writes the spawned driver received.</summary>
|
||||||
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
|
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -389,7 +397,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
|||||||
{
|
{
|
||||||
foreach (var w in writes) _writes.Enqueue(w);
|
foreach (var w in writes) _writes.Enqueue(w);
|
||||||
return Task.FromResult<IReadOnlyList<WriteResult>>(
|
return Task.FromResult<IReadOnlyList<WriteResult>>(
|
||||||
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
|
writes.Select(_ => new WriteResult(0u)).ToArray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -175,15 +175,19 @@ public sealed class AddHistorianProvisioningTests
|
|||||||
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
|
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
// v3 Batch 4: historized value tags are RAW tags (the applier provisions from AddedRawTags). NodeId plays
|
||||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
|
// the RawPath role (== mux ref + historian default).
|
||||||
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
|
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||||
|
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||||
|
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||||
|
IsHistorized: true, HistorianTagname: historianName);
|
||||||
|
|
||||||
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
|
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
|
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||||
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
|
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||||
|
IsHistorized: false, HistorianTagname: null);
|
||||||
|
|
||||||
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
|
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
|
||||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||||
@@ -194,6 +198,6 @@ public sealed class AddHistorianProvisioningTests
|
|||||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||||
{
|
{
|
||||||
AddedEquipmentTags = tags,
|
AddedRawTags = tags,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-7
@@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
NodeId: "ns=2;s=tag-1",
|
NodeId: "ns=2;s=tag-1",
|
||||||
Value: 42,
|
Value: 42,
|
||||||
Quality: OpcUaQuality.Good,
|
Quality: OpcUaQuality.Good,
|
||||||
TimestampUtc: DateTime.UtcNow));
|
TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
AwaitAssertion(() =>
|
AwaitAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -194,24 +194,24 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
/// <param name="value">The value being written.</param>
|
/// <param name="value">The value being written.</param>
|
||||||
/// <param name="quality">The OPC UA quality status.</param>
|
/// <param name="quality">The OPC UA quality status.</param>
|
||||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
|
||||||
/// <summary>Records an alarm condition write.</summary>
|
/// <summary>Records an alarm condition write.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++;
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
|
||||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||||
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
|
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
|
||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
/// <summary>Ensures folder exists (stub implementation).</summary>
|
/// <summary>Ensures folder exists (stub implementation).</summary>
|
||||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||||
/// <param name="displayName">The display name for the folder.</param>
|
/// <param name="displayName">The display name for the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
/// <summary>Ensures variable exists (stub implementation).</summary>
|
/// <summary>Ensures variable exists (stub implementation).</summary>
|
||||||
/// <param name="variableNodeId">The variable node identifier.</param>
|
/// <param name="variableNodeId">The variable node identifier.</param>
|
||||||
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
|
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
|
||||||
@@ -219,10 +219,12 @@ 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, 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) { }
|
||||||
/// <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>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -118,25 +118,29 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
|||||||
/// <summary>A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed).</summary>
|
/// <summary>A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed).</summary>
|
||||||
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { }
|
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, 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) { }
|
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, 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) { }
|
||||||
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
|
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A no-op sink — a clean apply (no failures).</summary>
|
/// <summary>A no-op sink — a clean apply (no failures).</summary>
|
||||||
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
||||||
{
|
{
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { }
|
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, 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) { }
|
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, 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) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Listens to a single instrument by name on the central meter and tallies value + tags.</summary>
|
/// <summary>Listens to a single instrument by name on the central meter and tallies value + tags.</summary>
|
||||||
|
|||||||
+13
-11
@@ -437,13 +437,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="quality">The OPC UA quality code.</param>
|
/// <param name="quality">The OPC UA quality code.</param>
|
||||||
/// <param name="ts">The timestamp of the write.</param>
|
/// <param name="ts">The timestamp of the write.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> Calls.Enqueue($"WV:{nodeId}");
|
=> Calls.Enqueue($"WV:{nodeId}");
|
||||||
/// <summary>Records an alarm condition write call.</summary>
|
/// <summary>Records an alarm condition write call.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="ts">The timestamp of the state change.</param>
|
/// <param name="ts">The timestamp of the state change.</param>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts)
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||||
@@ -451,13 +451,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||||
=> Calls.Enqueue($"MA:{alarmNodeId}");
|
=> Calls.Enqueue($"MA:{alarmNodeId}");
|
||||||
/// <summary>Records a folder ensure call.</summary>
|
/// <summary>Records a folder ensure call.</summary>
|
||||||
/// <param name="folderNodeId">The folder node ID.</param>
|
/// <param name="folderNodeId">The folder node ID.</param>
|
||||||
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
|
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
|
||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
=> Calls.Enqueue($"EF:{folderNodeId}");
|
=> Calls.Enqueue($"EF:{folderNodeId}");
|
||||||
/// <summary>Records a variable ensure call.</summary>
|
/// <summary>Records a variable ensure call.</summary>
|
||||||
/// <param name="variableNodeId">The variable node ID.</param>
|
/// <param name="variableNodeId">The variable node ID.</param>
|
||||||
@@ -466,15 +466,17 @@ 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, 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)
|
||||||
=> Calls.Enqueue($"EV:{variableNodeId}");
|
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||||
/// <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>Records a NodeAdded model-change announcement.</summary>
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}");
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
|
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
|
||||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
Calls.Enqueue($"UT:{variableNodeId}");
|
Calls.Enqueue($"UT:{variableNodeId}");
|
||||||
return true;
|
return true;
|
||||||
@@ -482,25 +484,25 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</summary>
|
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</summary>
|
||||||
/// <param name="folderNodeId">The folder node ID to update in place.</param>
|
/// <param name="folderNodeId">The folder node ID to update in place.</param>
|
||||||
/// <param name="displayName">The new display name to apply.</param>
|
/// <param name="displayName">The new display name to apply.</param>
|
||||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/// <summary>Records a surgical variable-node removal (always succeeds in this recording sink).</summary>
|
/// <summary>Records a surgical variable-node removal (always succeeds in this recording sink).</summary>
|
||||||
public bool RemoveVariableNode(string variableNodeId)
|
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
Calls.Enqueue($"RV:{variableNodeId}");
|
Calls.Enqueue($"RV:{variableNodeId}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/// <summary>Records a surgical alarm-condition-node removal (always succeeds in this recording sink).</summary>
|
/// <summary>Records a surgical alarm-condition-node removal (always succeeds in this recording sink).</summary>
|
||||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
Calls.Enqueue($"RA:{alarmNodeId}");
|
Calls.Enqueue($"RA:{alarmNodeId}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/// <summary>Records a surgical equipment-subtree removal (always succeeds in this recording sink).</summary>
|
/// <summary>Records a surgical equipment-subtree removal (always succeeds in this recording sink).</summary>
|
||||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||||
{
|
{
|
||||||
Calls.Enqueue($"RE:{equipmentNodeId}");
|
Calls.Enqueue($"RE:{equipmentNodeId}");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
|
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
|
||||||
{
|
{
|
||||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
|
||||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow));
|
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||||
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
|
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
var sink = new RecordingSink();
|
var sink = new RecordingSink();
|
||||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||||
|
|
||||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow));
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow));
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||||
|
|
||||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||||
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow));
|
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
||||||
|
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
@@ -606,14 +606,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
/// <param name="value">The attribute value.</param>
|
/// <param name="value">The attribute value.</param>
|
||||||
/// <param name="quality">The OPC UA quality code.</param>
|
/// <param name="quality">The OPC UA quality code.</param>
|
||||||
/// <param name="ts">The timestamp of the update.</param>
|
/// <param name="ts">The timestamp of the update.</param>
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||||
ValueQueue.Enqueue((nodeId, value, quality, ts));
|
ValueQueue.Enqueue((nodeId, value, quality, ts));
|
||||||
|
|
||||||
/// <summary>Records an alarm condition update.</summary>
|
/// <summary>Records an alarm condition update.</summary>
|
||||||
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
|
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
|
||||||
/// <param name="state">The full condition state snapshot.</param>
|
/// <param name="state">The full condition state snapshot.</param>
|
||||||
/// <param name="ts">The timestamp of the update.</param>
|
/// <param name="ts">The timestamp of the update.</param>
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) =>
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||||
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
||||||
|
|
||||||
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
||||||
@@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
/// <param name="displayName">The condition display name.</param>
|
/// <param name="displayName">The condition display name.</param>
|
||||||
/// <param name="alarmType">The domain alarm type.</param>
|
/// <param name="alarmType">The domain alarm type.</param>
|
||||||
/// <param name="severity">The domain severity.</param>
|
/// <param name="severity">The domain severity.</param>
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||||
|
|
||||||
/// <summary>Records a folder ensure call.</summary>
|
/// <summary>Records a folder ensure call.</summary>
|
||||||
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
||||||
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
|
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
|
||||||
/// <param name="displayName">The display name of the folder.</param>
|
/// <param name="displayName">The display name of the folder.</param>
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) =>
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||||
FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
|
||||||
/// <summary>Records a variable ensure call.</summary>
|
/// <summary>Records a variable ensure call.</summary>
|
||||||
@@ -638,7 +638,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, 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) =>
|
||||||
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>
|
||||||
@@ -646,7 +646,9 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
||||||
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||||
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
|
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
|
||||||
|
|||||||
+4
-4
@@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
|
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
|
||||||
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
|
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an
|
||||||
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
|
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dependency_change_above_threshold_activates_alarm()
|
public void Dependency_change_above_threshold_activates_alarm()
|
||||||
@@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
|
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
|
||||||
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
|
/// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared").</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dependency_change_below_threshold_clears_alarm()
|
public void Dependency_change_below_threshold_clears_alarm()
|
||||||
{
|
{
|
||||||
@@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
|
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
|
||||||
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
|
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
|
||||||
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
|
/// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
|
||||||
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
|
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
|
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
|
||||||
@@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
/// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound
|
/// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound
|
||||||
/// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active
|
/// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active
|
||||||
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true)
|
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns)
|
||||||
/// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary>
|
/// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Inbound_AlarmCommand_is_processed_regardless_of_role()
|
public void Inbound_AlarmCommand_is_processed_regardless_of_role()
|
||||||
|
|||||||
@@ -476,6 +476,104 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
|
|||||||
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
|
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a
|
||||||
|
/// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must
|
||||||
|
/// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad
|
||||||
|
/// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child:
|
||||||
|
/// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a
|
||||||
|
/// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes
|
||||||
|
/// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0)));
|
||||||
|
|
||||||
|
var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
|
||||||
|
|
||||||
|
// Capture the spawned child from the RegisterInterest sender so we can drive a dependency change
|
||||||
|
// through the real evaluate → parent-Tell → bridge path.
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||||
|
var child = mux.LastSender;
|
||||||
|
|
||||||
|
// First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value.
|
||||||
|
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
|
||||||
|
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
first.Value.ShouldBe(7.0);
|
||||||
|
first.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
|
||||||
|
// The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED
|
||||||
|
// plan — the child survives with its dedup state. The fix re-asserts the last value; without it the
|
||||||
|
// node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out.
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
|
||||||
|
var reasserted = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
reasserted.NodeId.ShouldBe("eq-1/speed-rpm");
|
||||||
|
reasserted.Value.ShouldBe(7.0);
|
||||||
|
reasserted.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
|
||||||
|
// No child churn on the unchanged redeploy — the re-assert must not respawn or re-register.
|
||||||
|
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1 (reassert review): a re-assert must PUBLISH (to repair the reset OPC UA node) but must NOT
|
||||||
|
/// re-record to the historian. Otherwise every deploy appends an artificial historian sample carrying a
|
||||||
|
/// stale value with a fresh deploy-time timestamp — a data-quality bug the moment a VT history sink is
|
||||||
|
/// bound. Historize=true survivor: first real evaluation records exactly once; the redeploy's re-assert
|
||||||
|
/// publishes a second time but the historian count stays 1.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var mux = CreateTestProbe();
|
||||||
|
var writer = new CapturingHistoryWriter();
|
||||||
|
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0), writer));
|
||||||
|
|
||||||
|
var plan = new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) };
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
|
||||||
|
|
||||||
|
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||||
|
var child = mux.LastSender;
|
||||||
|
|
||||||
|
// First real evaluation → Good publish + exactly one historian record.
|
||||||
|
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
|
||||||
|
|
||||||
|
// Redeploy (unchanged plan) → the survivor re-asserts: a SECOND publish repairs the reset node.
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
|
||||||
|
// The publish landing means the reassert's OnResult turn is complete (Record runs on the same turn,
|
||||||
|
// right after the Tell) — so a second Record would already be visible. It must NOT be: still 1.
|
||||||
|
writer.Calls.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Evaluator that always returns <c>Ok(value)</c> — a static-output VirtualTag whose recompute
|
||||||
|
/// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node
|
||||||
|
/// recovers.</summary>
|
||||||
|
private sealed class ConstOkEvaluator : IVirtualTagEvaluator
|
||||||
|
{
|
||||||
|
private readonly object _value;
|
||||||
|
|
||||||
|
/// <summary>Creates an evaluator that always returns <paramref name="value"/>.</summary>
|
||||||
|
/// <param name="value">The constant value to return.</param>
|
||||||
|
public ConstOkEvaluator(object value) => _value = value;
|
||||||
|
|
||||||
|
/// <summary>Returns <c>Ok(value)</c> regardless of inputs.</summary>
|
||||||
|
/// <param name="id">The tag identifier.</param>
|
||||||
|
/// <param name="expr">The expression string.</param>
|
||||||
|
/// <param name="deps">The dependency values.</param>
|
||||||
|
/// <returns>A successful result carrying the fixed value.</returns>
|
||||||
|
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
|
||||||
|
=> VirtualTagEvalResult.Ok(_value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
|
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
|
||||||
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
|
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
|
||||||
/// evaluation thread.</summary>
|
/// evaluation thread.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user