Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be87ddeb0b | |||
| 5f72ff851d | |||
| c878fbbd03 | |||
| 2254ae3dea | |||
| 1ccc237cb6 | |||
| b3d1a26f38 | |||
| 3336ec08c7 | |||
| e27c19c49d | |||
| f347762350 | |||
| 2cae4c8f01 | |||
| 043e237dba | |||
| 8c5e2be92e | |||
| 6dda0549e2 | |||
| db751d12a5 | |||
| f6a3c31b60 | |||
| e08b6b0e69 | |||
| 50426d4790 | |||
| 7339a4af07 | |||
| 872cf7e37a | |||
| f1534920de | |||
| 9bb237b794 | |||
| 1424a21419 | |||
| ce383df39a | |||
| a0be76b5f0 | |||
| 73d8439412 | |||
| 8843418c54 | |||
| 772d3a5f34 | |||
| ec6598ceae | |||
| 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 | |||
| 0c65e4412e | |||
| bdcb84bd7d | |||
| 96af69e3d2 | |||
| 29bb4f176e | |||
| 940303276c | |||
| dc0d7653b9 | |||
| ac12eec924 | |||
| ac8d4eef30 | |||
| 8c05a9fe0a | |||
| b610a8dde5 | |||
| 77bc010ba9 | |||
| 09ff43910c | |||
| b68c313372 | |||
| 6dc5af7aa2 | |||
| a88dc86173 |
@@ -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
|
||||
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
||||
exclusively.
|
||||
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
|
||||
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
|
||||
reads/writes/subscriptions are translated to that reference for MXAccess.
|
||||
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
|
||||
(see below). Galaxy tags are bound by `TagConfig.FullName`
|
||||
(`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
|
||||
|
||||
@@ -217,6 +253,10 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
|
||||
|
||||
**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor` → `RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.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`.
|
||||
|
||||
## Scripting / Script Editor
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.2.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||||
<package pattern="ZB.MOM.WW.Secrets" />
|
||||
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -47,6 +47,29 @@
|
||||
|
||||
name: otopcua-dev
|
||||
|
||||
# ── Shared secret-replication env (all six host nodes) ──────────────────────────
|
||||
# Akka peer-to-peer clustered secret replication rides the existing `otopcua` Akka
|
||||
# mesh (DistributedPubSub topic `zb-mom-ww-secrets`) across all six host nodes. Two
|
||||
# hard requirements are wired here, identically, on every node:
|
||||
# 1. Secrets__Replication__Enabled=true → SecretsRegistration.AddOtOpcUaSecrets
|
||||
# switches from plain AddZbSecrets to AddZbSecretsAkkaReplication + the eager
|
||||
# SecretReplicationStarter hosted service, so every node joins anti-entropy.
|
||||
# 2. ZB_SECRETS_MASTER_KEY → the ONE shared 32-byte base64 KEK. Every node MUST
|
||||
# carry the SAME key or peers fail closed decrypting each other's rows
|
||||
# (kek_id mismatch). appsettings.json binds Secrets:MasterKey:Source=Environment
|
||||
# / EnvVarName=ZB_SECRETS_MASTER_KEY, so this env var IS the KEK on every node.
|
||||
# The KEK below is a committed DEV-ONLY default (matches the rig's zero-operator-step
|
||||
# philosophy, like the committed dev SQL password). Override for a fresh key with:
|
||||
# OTOPCUA_SECRETS_KEK=$(openssl rand -base64 32) docker compose ... up -d
|
||||
# NEVER reuse this key outside this local dev rig.
|
||||
# AnnounceInterval is shortened to 5s (default 30s) so anti-entropy convergence is
|
||||
# observable quickly during dev exercise. Each node keeps its own local SQLite store
|
||||
# (Secrets:SqlitePath=otopcua-secrets.db, per-container); replication syncs them.
|
||||
x-secrets-env: &secrets-env
|
||||
Secrets__Replication__Enabled: "true"
|
||||
Secrets__Replication__AnnounceInterval: "00:00:05"
|
||||
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
|
||||
|
||||
services:
|
||||
|
||||
sql:
|
||||
@@ -145,6 +168,7 @@ services:
|
||||
sql: { condition: service_healthy }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
@@ -210,6 +234,7 @@ services:
|
||||
central-1: { condition: service_started }
|
||||
migrator: { condition: service_completed_successfully }
|
||||
environment:
|
||||
<<: *secrets-env
|
||||
OTOPCUA_ROLES: "admin,driver"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
@@ -280,6 +305,7 @@ services:
|
||||
Cluster__PublicHostname: "site-a-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
|
||||
# mem_reservation are inherited from the *otopcua-host anchor.
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
@@ -304,6 +330,7 @@ services:
|
||||
Cluster__PublicHostname: "site-a-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
@@ -326,6 +353,7 @@ services:
|
||||
Cluster__PublicHostname: "site-b-1"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
@@ -346,6 +374,7 @@ services:
|
||||
Cluster__PublicHostname: "site-b-2"
|
||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
|
||||
Cluster__Roles__0: "driver"
|
||||
<<: *secrets-env
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
|
||||
@@ -24,6 +24,163 @@ condition — the dedup logic prefers the richer driver-native record
|
||||
because it carries the full operator + raise-time + category metadata
|
||||
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.
|
||||
|
||||
## Condition event identity fields (what a client reads on the wire)
|
||||
|
||||
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
||||
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
||||
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
||||
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
||||
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
|
||||
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
|
||||
|
||||
| Field | Value | Notes |
|
||||
|---|---|---|
|
||||
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
||||
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
||||
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
||||
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
||||
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
||||
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
||||
| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below |
|
||||
|
||||
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
||||
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
||||
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
|
||||
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
||||
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
||||
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
||||
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
||||
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
||||
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
||||
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
||||
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
||||
|
||||
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
||||
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
||||
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
||||
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
||||
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
||||
|
||||
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
||||
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
||||
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
||||
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
||||
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
||||
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
||||
|
||||
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
|
||||
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
|
||||
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
|
||||
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
|
||||
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
|
||||
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
|
||||
|
||||
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
||||
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
||||
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
||||
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
||||
> alarm-history writer stamps that field with the **EquipmentPath**
|
||||
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
||||
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
||||
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||
> live-path fix above does not change the history path.)
|
||||
|
||||
### Condition source-data Quality (#477)
|
||||
|
||||
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
|
||||
because `StatusCodes.Good == 0x00000000` an unassigned `StatusCode` **is** `Good` — so every condition
|
||||
reported `Good` unconditionally (a wrong *value*, not a null like #473/#475). A native alarm whose device
|
||||
went offline still read `Good`, so an operator could not tell *"genuinely inactive"* from *"we have lost
|
||||
contact and do not know"*.
|
||||
|
||||
**How it is driven now (native alarms).** An alarm-bearing raw tag materializes a condition with **no
|
||||
sibling value variable**, so the value/quality path (`WriteValue`) never touches it, and a comms-lost
|
||||
driver emits **no alarm transitions** (the feed goes silent). The quality therefore comes from the
|
||||
**driver's connectivity**, out of band from alarm transitions:
|
||||
|
||||
- `DriverInstanceActor` Tells its host `ConnectivityChanged(driverInstanceId, connected)` on every
|
||||
transition into `Connected` (`true`) / `Reconnecting` (`false`).
|
||||
- `DriverHostActor.OnDriverConnectivityChanged` fans that out to **every** native condition the driver
|
||||
owns as an `OpcUaPublishActor.AlarmQualityUpdate` (`Good` on connect, `Bad` on disconnect).
|
||||
- `OtOpcUaNodeManager.WriteAlarmQuality` sets **only** the condition's `Quality` and fires a Part 9
|
||||
event **only on a quality-bucket change** — it never touches Active/Acked/Severity/Retain (an active
|
||||
alarm that loses comms stays active). This is a dedicated path, *not* a full-snapshot re-projection, so
|
||||
it cannot clobber a condition's severity/message and works for a condition that never fired a transition.
|
||||
- A freshly materialized native condition starts `BadWaitingForInitialData` (the "no driver data yet"
|
||||
convention value variables use); the first `Connected` confirms it `Good`.
|
||||
- The connectivity annotation is **ungated by redundancy role** (a Secondary keeps its condition quality
|
||||
warm for failover) and publishes **no `/alerts` row** — driver comms health already has its own status
|
||||
surface (`IDriverHealthPublisher`); a row per condition would be alarm-fatigue.
|
||||
|
||||
**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags,
|
||||
so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this
|
||||
condition's state?") — mirroring the native OT semantic:
|
||||
|
||||
- The mux now forwards each input's source quality (`DependencyValueChanged.Quality`), and the scripted host
|
||||
pushes it into the engine's read cache (previously every mux value was treated as `Good`).
|
||||
- The `ScriptedAlarmEngine` computes the worst input quality each evaluation. A **real transition** carries
|
||||
it on the emitted event → `ScriptedAlarmHostActor.ToSnapshot` projects it (so a transition fired while an
|
||||
input is `Uncertain` does not clobber quality back to `Good`).
|
||||
- A **Bad input freezes the condition** (`AreInputsReady` holds its state — no transition), exactly like a
|
||||
comms-lost native driver. So a quality-bucket change with no transition is emitted as
|
||||
`EmissionKind.QualityChanged` and routed to the **same** dedicated `AlarmQualityUpdate → WriteAlarmQuality`
|
||||
node path native uses (quality only, one Part 9 event on a bucket change, **no `/alerts` row**, no
|
||||
historian write). `ScriptedAlarmSource` skips `QualityChanged` so it never fabricates a phantom
|
||||
`IAlarmSource` event.
|
||||
- An input that has **not been published yet** (cold start) is *not* a quality signal (that is the readiness
|
||||
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
|
||||
actually-`Bad` published value flips the bucket and annotates.
|
||||
|
||||
**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
|
||||
data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
|
||||
per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
|
||||
goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
|
||||
`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||
condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
|
||||
`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
|
||||
`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
|
||||
as the Layer-4 follow-up (#481).
|
||||
|
||||
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
|
||||
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
|
||||
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
|
||||
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
|
||||
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
|
||||
`Transition_snapshot_carries_worst_input_quality`).
|
||||
|
||||
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests.Condition_event_Quality_tracks_source_connectivity_on_the_wire`
|
||||
subscribes with a `[Quality, Message]` clause (Quality is declared on `ConditionType`) and asserts a
|
||||
healthy source reports `Good`, a comms-lost source reports non-`Good`, and recovery returns to `Good` — on
|
||||
a real client subscription. `NodeManagerAlarmSourceFieldsTests` guards the node itself + the no-clobber /
|
||||
unknown-node-no-op invariants.
|
||||
|
||||
## Galaxy driver path (driver-native)
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
# 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 \
|
||||
-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"
|
||||
|
||||
# Limit to 100 values
|
||||
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
|
||||
otopcua-cli historyread \
|
||||
-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" \
|
||||
--max 100
|
||||
|
||||
# 1-hour average aggregate
|
||||
otopcua-cli historyread \
|
||||
-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" \
|
||||
--aggregate Average --interval 3600000
|
||||
|
||||
# Authenticated read (ReadOnly role or higher required)
|
||||
otopcua-cli historyread \
|
||||
-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" \
|
||||
-U reader -P password
|
||||
```
|
||||
|
||||
+59
-40
@@ -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
|
||||
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
|
||||
|
||||
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
||||
@@ -307,76 +314,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
|
||||
|
||||
### Why
|
||||
|
||||
Today each VirtualTag bound to a script typically needs its own near-duplicate
|
||||
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`).
|
||||
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at
|
||||
a single template script, and each resolves the token to its own equipment's tag
|
||||
base prefix at deploy time. No schema change is required — sharing a `Script`
|
||||
record across VirtualTags already works; `{{equip}}` is what makes the shared
|
||||
script resolve per-equipment.
|
||||
Each VirtualTag bound to a script would otherwise need its own near-duplicate
|
||||
script because tag paths are hard-coded absolute RawPaths (e.g.
|
||||
`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
|
||||
point many VirtualTags' `ScriptId` at a single template script, and each resolves
|
||||
the token through **its own equipment's tag references** at deploy time. No schema
|
||||
change is required — sharing a `Script` record across VirtualTags already works;
|
||||
`{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
|
||||
|
||||
### Before / after
|
||||
|
||||
**Before — one script per machine:**
|
||||
**Before — one script per machine (hard-coded RawPath):**
|
||||
|
||||
```csharp
|
||||
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
|
||||
return ctx.GetTag("TestMachine_001.Speed").Value;
|
||||
return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
|
||||
```
|
||||
|
||||
**After — one shared template:**
|
||||
|
||||
```csharp
|
||||
// Script "Calc_Speed" — works for any machine
|
||||
return ctx.GetTag("{{equip}}.Speed").Value;
|
||||
return ctx.GetTag("{{equip}}/Speed").Value;
|
||||
```
|
||||
|
||||
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`.
|
||||
At deploy, each VirtualTag receives its own expanded copy:
|
||||
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively.
|
||||
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
|
||||
each has a **tag reference** whose effective name is `Speed`. At deploy, each
|
||||
VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that
|
||||
equipment's referenced raw tag's RawPath.
|
||||
|
||||
### Token rules
|
||||
### Token rules (v3)
|
||||
|
||||
- The token is `{{equip}}` (double braces, lowercase).
|
||||
- The token joint is a **slash**: `{{equip}}/<RefName>` (double-brace stem,
|
||||
lowercase). `<RefName>` is a **reference effective name** — the reference's
|
||||
`DisplayNameOverride`, else the backing raw tag's `Name`.
|
||||
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
|
||||
string literals** — comments, logger strings, and other code are untouched.
|
||||
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`,
|
||||
`ctx.GetTag("{{equip}}.Sub.Field")`, etc.
|
||||
- The token expands to the equipment's **tag base prefix** — the common
|
||||
substring-before-the-first-dot of that equipment's configured driver-tag
|
||||
`FullName` values. Example: tags `TestMachine_001.Speed` and
|
||||
`TestMachine_001.Temp` → base `TestMachine_001`.
|
||||
- The whole post-prefix literal content is the reference name, so effective names
|
||||
with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
|
||||
- `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
|
||||
rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
|
||||
reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
|
||||
`Cell1/Modbus/Dev1/Speed`.
|
||||
- Scripted-alarm predicate scripts resolve `{{equip}}/<RefName>` the same way; alarm
|
||||
**message-template** tokens follow the same resolution rule.
|
||||
|
||||
### Validation requirement
|
||||
|
||||
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in
|
||||
the AdminUI if the equipment does not have at least one configured driver tag, or
|
||||
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or
|
||||
absent). The rejection message is surfaced as a clear validation error on the save
|
||||
form. This check is enforced eagerly so that an unresolved `{{equip}}` token —
|
||||
which would leave a path that resolves to nothing at runtime (Bad quality) — can
|
||||
never reach the deployed artifact.
|
||||
Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
|
||||
rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
|
||||
reference effective names. The same rule runs at the deploy gate
|
||||
(`DraftValidator.ValidateEquipReferenceResolution`, error code
|
||||
`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
|
||||
authoring check never saw. The rejection names the script/alarm, the equipment, and
|
||||
the missing ref — so an unresolved token can never reach the deployed artifact.
|
||||
|
||||
### Editor support
|
||||
|
||||
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
|
||||
inline script panel):
|
||||
|
||||
- **Hover** — hovering a `{{equip}}` path literal shows an
|
||||
*"Equipment-relative path — resolved at deploy"* note.
|
||||
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal
|
||||
offers completion of attribute leaf names (the part after the first dot of known
|
||||
tag references in the catalog).
|
||||
- **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
|
||||
*"Equipment-relative path — resolved through the equipment's tag reference at
|
||||
deploy"* note.
|
||||
- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
|
||||
offers the owning equipment's **reference effective names** (needs an equipment
|
||||
context; inert on the shared ScriptEdit page).
|
||||
- **Diagnostics** — an unresolved `{{equip}}/<RefName>` is flagged (`OTSCRIPT_EQUIPREF`)
|
||||
identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*.
|
||||
|
||||
### Maintainer note
|
||||
|
||||
Substitution runs at the two compose seams —
|
||||
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans`
|
||||
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper,
|
||||
**before** dependency extraction. The runtime, the static change-trigger
|
||||
dependency graph, and the literal-only path rule are therefore all unchanged:
|
||||
by the time they see the script, `{{equip}}` has been replaced with a concrete
|
||||
tag-base prefix and the path is a normal string literal.
|
||||
Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
|
||||
`DeploymentArtifact.ParseComposition` — via the shared
|
||||
`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
|
||||
helper, fed the equipment's reference map (effective name → RawPath) built by
|
||||
`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
|
||||
**before** dependency extraction, so the runtime, the static change-trigger
|
||||
dependency graph, and the literal-only path rule are unchanged: by the time they
|
||||
see the script, `{{equip}}/<RefName>` has been replaced with a concrete RawPath and
|
||||
the path is a normal string literal. An unresolved ref is left un-substituted (the
|
||||
validator rejects it first — substitution never throws). The two seams build the
|
||||
reference map identically, so their plans stay byte-parity.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-4
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
|
||||
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
||||
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
||||
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.
|
||||
|
||||
> **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
|
||||
|
||||
| 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
|
||||
for failover.
|
||||
|
||||
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
|
||||
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
|
||||
other configuration is required.
|
||||
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
|
||||
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
|
||||
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
|
||||
|
||||
|
||||
+74
-12
@@ -74,26 +74,88 @@ changed by editing the area's cluster in the Area modal, which moves the
|
||||
whole branch. There is no separate "served-by" concept and no migration —
|
||||
it is simply `UnsArea.ClusterId`.
|
||||
|
||||
### Tags
|
||||
### Tags — reference-only (v3)
|
||||
|
||||
Tags created on the equipment page are **equipment-bound** and require a driver
|
||||
instance. The driver list on the Tags tab is scoped to the equipment's cluster
|
||||
and to drivers on an **Equipment-kind** namespace, so a driver-less equipment
|
||||
shows no eligible drivers until you bind one (edit the equipment on the Details
|
||||
tab and pick a driver).
|
||||
> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
|
||||
> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
|
||||
> equipment's **Tags** tab holds **references** to those raw tags. The old
|
||||
> driver-bound Tag modal on this tab is retired.
|
||||
|
||||
**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to
|
||||
a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the
|
||||
standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy
|
||||
so you can select the attribute and set `TagConfig.FullName`. There is no
|
||||
separate alias concept or `SystemPlatform`-kind namespace.
|
||||
The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
|
||||
**effective name**, the backing tag's **RawPath**, its inherited **DataType** and
|
||||
**AccessLevel** (read-only — they come from the raw tag), and an editable
|
||||
**display-name override**. The effective name is the override else the raw tag's
|
||||
`Name`.
|
||||
|
||||
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
|
||||
**scoped to the equipment's cluster** — cross-cluster tags are structurally
|
||||
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
|
||||
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
|
||||
tags below"* menu to pull many at once.
|
||||
|
||||
**Effective-name uniqueness:** within an equipment the effective name must be
|
||||
unique across references, VirtualTags, and ScriptedAlarms (they share the
|
||||
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
|
||||
both at authoring (a readable rejection naming the colliding source) and at the
|
||||
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
|
||||
what catches **rename-induced** collisions a raw rename produced after the
|
||||
reference was authored, naming both colliding sources and the equipment.
|
||||
|
||||
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
|
||||
(the error names the referencing equipment); renaming a raw tag or any ancestor
|
||||
warns when a beneath-it tag is historized (no `historianTagname` override),
|
||||
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
|
||||
|
||||
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
|
||||
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
||||
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
|
||||
|
||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
|
||||
data type is chosen from the standard OPC UA type list and the Monaco script
|
||||
editor is available inline.
|
||||
editor is available inline. Scripts may read the equipment's references
|
||||
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
|
||||
resolves through the equipment's `UnsTagReference` rows (by effective name) to
|
||||
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
|
||||
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
|
||||
[`ScriptEditor.md`](ScriptEditor.md).
|
||||
|
||||
### Galaxy tags
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Secrets: Clustered Master-Key Posture (All Roles)
|
||||
|
||||
## Purpose
|
||||
|
||||
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
||||
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
|
||||
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
|
||||
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
|
||||
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
|
||||
registered unconditionally, independent of node role.
|
||||
|
||||
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
|
||||
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
|
||||
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
|
||||
fused admin+driver node, or a driver-only node). This runbook covers what a
|
||||
production deployment needs so that secret resolution behaves identically on
|
||||
**every node regardless of role** — not just admin nodes. That matters here more
|
||||
than it might elsewhere: the pre-host expander and the runtime resolver both run
|
||||
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
|
||||
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
|
||||
runtime, not just at boot. It does **not** change any code; it is an
|
||||
operations/deployment posture, delivered out-of-band from the committed config.
|
||||
|
||||
## The two hard requirements
|
||||
|
||||
For the pre-host expander (and the runtime resolver) to resolve the same
|
||||
plaintext secret on every node, no matter its role:
|
||||
|
||||
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
|
||||
combination — must unwrap the store with the exact same master key. A
|
||||
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
||||
decrypt every *other* node's ciphertext rows to garbage.
|
||||
2. **Identical store rows on every node.** All nodes must read the same SQLite
|
||||
database (same file, or a replicated/shared copy with the same rows) — not
|
||||
independently-seeded stores that happen to share a KEK.
|
||||
|
||||
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
|
||||
— there is no built-in cross-node replication today. Meeting both requirements in
|
||||
production is a deployment concern, covered below.
|
||||
|
||||
## Recommended interim posture (G-5)
|
||||
|
||||
Until real replication exists (G-7, below), the recommended production posture is:
|
||||
|
||||
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
|
||||
key file that is identical on every node of every role** — a base64-encoded
|
||||
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
|
||||
to each node's filesystem/secret-mount by the deployment tooling, and **never
|
||||
committed** to the repo. Treat it with the same discipline as any other
|
||||
production secret (restrictive file ACLs, no logging, rotated via a future
|
||||
KEK-rotation runbook — not yet built).
|
||||
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
|
||||
that every node mounts (admin, driver, and dev/fused alike), so every node's
|
||||
migrator opens and reads the same rows at boot.
|
||||
|
||||
Writes to the store are rare and human-driven — an operator using the
|
||||
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
|
||||
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
|
||||
regardless of role. The access pattern is read-mostly / effectively
|
||||
single-writer, which is what makes a shared SQLite volume viable as an interim
|
||||
posture (see caveat below).
|
||||
|
||||
## How it's delivered (do NOT commit these values)
|
||||
|
||||
The File-KEK + shared-store posture is supplied per-node at deployment time —
|
||||
**never** by editing the committed `appsettings.json` or the role-overlay files
|
||||
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
|
||||
Those committed overlays are also consumed by local dev and the
|
||||
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
|
||||
into them would break every dev/test/CI boot. Two acceptable delivery
|
||||
mechanisms instead:
|
||||
|
||||
**Option A — environment variable overrides** (Windows Service / NSSM env block,
|
||||
container `env_file`, etc.), applied identically on every node regardless of role:
|
||||
|
||||
```
|
||||
# production deployment — do not commit to the dev appsettings
|
||||
Secrets__MasterKey__Source=File
|
||||
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
|
||||
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
|
||||
```
|
||||
|
||||
**Option B — a production-only config layer** that is *not* the committed dev
|
||||
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
|
||||
binaries, or an orchestrator-injected config mount):
|
||||
|
||||
```jsonc
|
||||
// production deployment — do not commit to the dev appsettings
|
||||
{
|
||||
"Secrets": {
|
||||
"MasterKey": {
|
||||
"Source": "File",
|
||||
"FilePath": "/run/secrets/otopcua-master.key"
|
||||
},
|
||||
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Either way, the file/path referenced must exist and be identical on every node
|
||||
**before** that node boots — the pre-host expander runs unconditionally on every
|
||||
role and will throw (`SecretNotFoundException` / migration failure) if the store
|
||||
or key is missing.
|
||||
|
||||
## Caveat: SQLite over a shared volume is not real replication
|
||||
|
||||
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
||||
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
||||
block volume only one writer should be active at a time). The interim posture
|
||||
above is acceptable because:
|
||||
|
||||
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
||||
- Writes are rare, human-initiated, and effectively single-writer in practice
|
||||
(an operator runs the CLI/UI against one admin node at a time).
|
||||
|
||||
It is **not** a substitute for real replication, and it is not safe if multiple
|
||||
nodes attempt concurrent writes. Do not build automation that writes secrets
|
||||
from more than one node simultaneously.
|
||||
|
||||
## Data Protection is independent — do not touch it here
|
||||
|
||||
OtOpcUa's cookie/session protection already has its own clustered-key story:
|
||||
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
|
||||
which shares the Data Protection key ring across every node via the existing
|
||||
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
|
||||
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
||||
work — doing so risks invalidating active sessions for an unrelated reason.
|
||||
|
||||
It is, however, the model for where the *next* iteration of secret storage
|
||||
should go — see the G-7 hand-off below.
|
||||
|
||||
## The G-7 hand-off
|
||||
|
||||
The posture above is an interim, ops-only workaround. The long-term shape,
|
||||
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
|
||||
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
|
||||
the Data Protection key ring:
|
||||
|
||||
```csharp
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
.SetApplicationName("OtOpcUa");
|
||||
```
|
||||
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
|
||||
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
|
||||
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
|
||||
the secret store is the natural next tenant of that same shared database instead
|
||||
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
|
||||
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
|
||||
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
|
||||
built as part of this cut. This runbook's shared-SQLite-volume posture is the
|
||||
bridge until G-7 lands.
|
||||
|
||||
## Dev/test/default posture (unchanged)
|
||||
|
||||
The committed default in `appsettings.json` is:
|
||||
|
||||
```json
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||
"RunMigrationsOnStartup": true
|
||||
}
|
||||
```
|
||||
|
||||
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
|
||||
path is relative to the working directory, so local dev, the role-overlay
|
||||
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
|
||||
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
|
||||
tests all boot cleanly with no external mount. The File-KEK + shared-volume
|
||||
posture in this runbook applies only to real clustered production deployments —
|
||||
it must never be baked into the committed dev/role-overlay base, because the
|
||||
expander runs unconditionally at every node boot (any role) and would break
|
||||
dev/CI if pointed at a nonexistent `/shared` mount.
|
||||
@@ -0,0 +1,89 @@
|
||||
# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/<RefName>` reference-relative resolution
|
||||
|
||||
Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1–WP4), executed via the
|
||||
`/v3-batch 3` 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 5-item live gate on
|
||||
docker-dev.
|
||||
|
||||
## What landed
|
||||
|
||||
- **Contracts** — `IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements,
|
||||
WP1 consumes).
|
||||
- **Wave A** (3 parallel agents):
|
||||
- **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited
|
||||
DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in
|
||||
`PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped**
|
||||
(structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD;
|
||||
consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput`
|
||||
dropped `DriverInstanceId`; deleted the retired equipment `TagModal`.
|
||||
- **WP2** — `EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate
|
||||
`UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name,
|
||||
so it catches rename-induced collisions; ordinal; names both sources + equipment).
|
||||
- **WP3** — `RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without**
|
||||
a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode`
|
||||
for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal).
|
||||
- **Wave B** (1 integration agent):
|
||||
- **WP4** — `{{equip}}/<RefName>` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase`
|
||||
deleted; slash joint replaces the dot joint; new shared `EquipmentReferenceMap` (`equipmentId →
|
||||
effectiveName → RawPath`) built identically at both compose seams (`AddressSpaceComposer` +
|
||||
`DeploymentArtifact`, byte-parity). Unresolved-ref deploy error (`EquipReferenceUnresolved`) covering VT
|
||||
scripts, SA predicates, and SA message-template tokens; authoring guard (`ValidateEquipTokenAsync`,
|
||||
the Wave-A M1 fix); Monaco `{{equip}}/` completion of reference effective names + unresolved diagnostic
|
||||
(`OTSCRIPT_EQUIPREF`), `EquipmentId` threaded request→model→JS.
|
||||
|
||||
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; Commons **309**,
|
||||
Configuration **121**, AdminUI **659**, OpcUaServer **335/4-skip**, Runtime **361/42-skip** (the skips are
|
||||
the Batch-1 dark-address-space tests Batch 4 un-skips).
|
||||
|
||||
## Reviewer findings fixed in-branch
|
||||
|
||||
- **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the
|
||||
NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping
|
||||
structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation.
|
||||
L1 (misleading concurrency comment) fixed.
|
||||
- **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy
|
||||
invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs
|
||||
validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact
|
||||
+ folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null).
|
||||
- **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not
|
||||
the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths
|
||||
in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
|
||||
message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4.
|
||||
|
||||
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt on Batch-3 code)
|
||||
|
||||
Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is
|
||||
demonstrable. (Hand-seeded equipment surfaced the Batch-1 `EquipmentIdNotDerived` invariant — the canonical
|
||||
`EQ-<hash>` id was applied before the gate.)
|
||||
|
||||
1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*;
|
||||
only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy
|
||||
expansion; a device **"Select all tags below"** pulled tags across a collapsed group → **4 references in
|
||||
one commit**. Rows show effective name / RawPath (incl. deep `opcua1/plc/OpcPlc/Telemetry/Basic/…`
|
||||
ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated
|
||||
`HR200 → MainPressure`, RawPath unchanged.
|
||||
2. **Collision** — *authoring:* setting a reference override to an existing VirtualTag's name → red banner
|
||||
**"effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'"** (not persisted).
|
||||
*rename-induced:* renamed a raw tag so two references collide (allowed at rename) → deploy **422**
|
||||
`[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…',
|
||||
reference '…'`.
|
||||
3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled
|
||||
`{{equip}}/MainPresure` deploys **422** `[EquipReferenceUnresolved] … has no reference named 'MainPresure'`.
|
||||
Monaco: red squiggle + hover *"'{{equip}}/MainPresure' does not resolve — … no reference named
|
||||
'MainPresure' … (OTSCRIPT_EQUIPREF)"*; typing `{{equip}}/` completes the four reference **effective**
|
||||
names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32).
|
||||
4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced
|
||||
+ named in a script literal → **"Renamed — with warnings"** listing all three (historized fork,
|
||||
UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming
|
||||
an unrelated driver → **no warning dialog**. (Note: direct *tag* rename flows through `UpdateTagAsync`
|
||||
which has no warnings surface — warnings fire on container renames affecting descendants; documented
|
||||
follow-up if per-tag-rename warnings are wanted.)
|
||||
5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId`
|
||||
(+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**.
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the
|
||||
slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph.
|
||||
|
||||
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
|
||||
@@ -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
|
||||
@@ -0,0 +1,187 @@
|
||||
# Alarm condition Quality (issue #477) — design
|
||||
|
||||
**Status:** implemented (L1+L2) · **Date:** 2026-07-17 · **Issue:** #477 (follow-up chain #473 → #475 → #477)
|
||||
**Scope decision:** Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → **#478**.
|
||||
|
||||
## Problem
|
||||
|
||||
`AlarmConditionState.Quality` is never assigned anywhere in `src/` — neither by
|
||||
`OtOpcUaNodeManager.MaterialiseAlarmCondition` nor by the `WriteAlarmCondition` transition path.
|
||||
Because `StatusCodes.Good == 0x00000000`, `default(StatusCode)` **is** `Good`, so the field is
|
||||
*accidentally valid* — clients parse it, but it reports **`Good` unconditionally regardless of the
|
||||
backing tag's real quality**.
|
||||
|
||||
This is a wrong-*value* bug, not the null-value bug class of #473/#475. Part 9 defines
|
||||
`ConditionType.Quality` as "the quality of the Condition's source data". OT impact: when a native
|
||||
alarm's device goes offline (comms lost) the condition still reports `Quality = Good`, so an operator
|
||||
(or an HMI bucketing on `IsGood`) cannot distinguish *"genuinely not active"* from *"we have lost
|
||||
contact and do not know"*.
|
||||
|
||||
## Why it isn't a 2-line default (confirmed by code)
|
||||
|
||||
1. **Alarm-bearing raw tags have no value variable.** `AddressSpaceApplier` materialises a raw tag as
|
||||
*either* a condition node (`tag.Alarm is not null`) *or* a value variable (`else`) — never both,
|
||||
since they'd share the same `s=<RawPath>` NodeId. So `WriteValue` (the only path carrying
|
||||
`OpcUaQuality`) is never invoked for an alarm node. Quality has nowhere to land today.
|
||||
2. **The alarm channel is quality-blind.** `AlarmEventArgs` (driver → host) and `AlarmConditionSnapshot`
|
||||
(host → SDK sink) both carry no quality field.
|
||||
3. **On comms-loss the alarm feed goes silent.** `DriverInstanceActor` on `DisconnectObserved` detaches
|
||||
the alarm subscription and re-enters `Reconnecting` — no transition event ever arrives to carry Bad.
|
||||
So the "device offline" signal must come from **driver connectivity**, independently of alarm
|
||||
transitions.
|
||||
|
||||
## Decisions (the issue's open questions)
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
|
||||
| 2 | Direct status code vs. policy map | **Direct Bad** on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for `IsGood` bucketing. |
|
||||
| 3 | Does Bad also suppress transitions / touch Retain? | **No — annotate only** | A comms-lost *active* condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
|
||||
| 4 | Scripted alarms: worst-of-inputs quality? | **Deferred (Layer 3)** | Scripted conditions stay `Good`. Filed as a follow-up issue. |
|
||||
|
||||
## Architecture — reuse the existing publish path, add no sink method
|
||||
|
||||
The key move: **do not add a new `IOpcUaAddressSpaceSink` method.** A new sink-interface surface would
|
||||
have to be forwarded through `DeferredAddressSpaceSink` or it is inert on driver hosts (the F10b
|
||||
prod-inertness trap). Instead the `NativeAlarmProjector` becomes the single owner of per-condition
|
||||
state *and* quality, and a connectivity change re-projects the *last* snapshot with a swapped quality
|
||||
through the **existing** `AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition` path.
|
||||
|
||||
### Layer 1 — make Quality a real, plumbed field
|
||||
|
||||
- `AlarmConditionSnapshot` (Commons) gains `OpcUaQuality Quality` (last positional param, default
|
||||
`OpcUaQuality.Good` so scripted callers and existing tests keep compiling; Commons already knows
|
||||
`OpcUaQuality` via `IOpcUaAddressSpaceSink`).
|
||||
- `MaterialiseAlarmCondition` sets `alarm.Quality.Value` at build time:
|
||||
**native → `BadWaitingForInitialData`** (honest until connectivity confirms Good, matching the
|
||||
value-variable "waiting for initial data" convention), **scripted → `Good`** (script-computed, always
|
||||
live in this scope).
|
||||
- `WriteAlarmCondition` projects `StatusFromQuality(state.Quality)` onto `condition.Quality.Value`
|
||||
(+ `SourceTimestamp`).
|
||||
- The delta-gate (`AlarmConditionDelta` / `ReadConditionDelta` / `ToConditionDelta`) gains a `Quality`
|
||||
member, so a Good→Bad bucket change is a genuine delta and **fires a Part 9 condition event**.
|
||||
|
||||
### Layer 2 — drive native quality from driver connectivity
|
||||
|
||||
- `DriverInstanceActor`: new `public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected)`.
|
||||
`Context.Parent.Tell` it on `Become(Connected)` entry (`true`) and on the transitions into
|
||||
`Reconnecting` (`DisconnectObserved` / `ForceReconnect`) (`false`). Fire-and-forget, mirrors
|
||||
`DeltaApplied`.
|
||||
- `NativeAlarmProjector`: per-node state becomes `(bool Active, bool Acked, OpcUaQuality Quality)`.
|
||||
`Project(transition)` preserves the current quality; new `ProjectQuality(nodeId, quality)` preserves
|
||||
Active/Acked and swaps only the quality, returning a full snapshot.
|
||||
- `DriverHostActor`: `Receive<ConnectivityChanged>` iterates `_alarmNodeIdByDriverRef` for that driver
|
||||
instance and Tells one `AlarmStateUpdate` per condition with the re-projected snapshot
|
||||
(`connected ? Good : Bad`). **Ungated** — both redundancy nodes track their own driver's comms, matching
|
||||
the existing "condition write stays ungated (Secondary keeps its address space warm)" rule.
|
||||
**No `/alerts` row** for a quality-only change — driver health already has its own status/alerts surface
|
||||
via `IDriverHealthPublisher`; a row here would be alarm-fatigue.
|
||||
|
||||
Scripted alarms are unaffected: they are not driver instances, receive no `ConnectivityChanged`, and
|
||||
their snapshot quality stays `Good`.
|
||||
|
||||
## Files
|
||||
|
||||
**Layer 1**
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`MaterialiseAlarmCondition`,
|
||||
`WriteAlarmCondition`, `AlarmConditionDelta`/`ReadConditionDelta`/`ToConditionDelta`)
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` (`ToSnapshot` — Quality=Good, or rely on default)
|
||||
|
||||
**Layer 2**
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
|
||||
|
||||
## Tests (TDD, RED-first)
|
||||
|
||||
1. **Wire-level (the issue's suggested guard)** — extend `NativeAlarmEventIdentityFieldDeliveryTests`
|
||||
(OpcUaServer.IntegrationTests): active alarm → event `Quality.IsGood`; driver disconnect → condition
|
||||
event `Quality.IsGood == false`; reconnect → Good. Verify RED against pre-fix.
|
||||
2. **Node-level** — `NodeManagerAlarmSourceFieldsTests`: materialise sets Quality (native
|
||||
`BadWaitingForInitialData`, scripted `Good`); `WriteAlarmCondition` projects snapshot quality and
|
||||
fires on a quality-bucket change only.
|
||||
3. **`NativeAlarmProjector`** unit: `ProjectQuality` keeps Active/Acked + swaps quality; `Project`
|
||||
preserves quality.
|
||||
4. **`DriverInstanceActor`**: `Connected` entry Tells `ConnectivityChanged(true)`; `DisconnectObserved`
|
||||
Tells `ConnectivityChanged(false)`.
|
||||
5. **`DriverHostActor`**: `ConnectivityChanged(false)` pushes a Bad-quality `AlarmStateUpdate` to every
|
||||
condition of that driver instance.
|
||||
|
||||
## Deferred / notes
|
||||
|
||||
- **Layer 3** (scripted worst-of-input quality) → **Gitea #478**.
|
||||
- **Implementation note:** L2 uses a **dedicated `IOpcUaAddressSpaceSink.WriteAlarmQuality`** path (not a
|
||||
full-snapshot re-projection). Rationale: a connectivity change must set *only* Quality; re-projecting a full
|
||||
snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a
|
||||
transition. The new sink method is forwarded through `DeferredAddressSpaceSink` (the F10b inertness trap) —
|
||||
auto-verified by `DeferredSinkForwardingReflectionTests` (reflection guard) + its realm-discriminator guard.
|
||||
- **Test-harness note:** the new `DriverInstanceActor → parent` `ConnectivityChanged` Tell polluted existing
|
||||
parent-`TestProbe` assertions in 3 `DriverInstanceActor*Tests` files; those tests now
|
||||
`parent.IgnoreMessages(m => m is ConnectivityChanged)` since they assert on data/alarm/discovery forwards,
|
||||
not connectivity.
|
||||
- `Bad_NoCommunication` vs generic `Bad`: v1 maps `OpcUaQuality.Bad → StatusCodes.Bad`; refining
|
||||
`StatusFromQuality` to emit `BadNoCommunication` for the comms-loss case is a one-line nicety, noted in
|
||||
the issue.
|
||||
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
|
||||
semantics, annotation-not-state-change, quality-bucket change fires an event).
|
||||
|
||||
## Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17)
|
||||
|
||||
**Problem.** A scripted alarm is computed from one or more input tags. Its condition should report the
|
||||
**worst** quality of those inputs ("can I trust this condition's state?"), not the hardcoded `Good` Layer 1
|
||||
left at `ScriptedAlarmHostActor.ToSnapshot`.
|
||||
|
||||
**Two blockers discovered in the live path (both silently discard quality):**
|
||||
1. `DependencyMuxActor.OnAttributeValuePublished` builds `VirtualTagActor.DependencyValueChanged` **without**
|
||||
the `AttributeValuePublished.Quality` it already carries.
|
||||
2. `ScriptedAlarmHostActor.OnDependencyChanged` pushes each mux value into the engine's upstream with a
|
||||
**hardcoded `0u` (Good)** StatusCode.
|
||||
So even a `Bad` driver value reached the scripted engine as Good — Layer 3 has to plumb quality first.
|
||||
|
||||
**Design (mirrors Layer 2's native OT semantic through the scripted channel):**
|
||||
- **Plumb quality end-to-end.** `DependencyValueChanged` gains `OpcUaQuality Quality` (defaulted `Good`, so the
|
||||
virtual-tag engine's calls are unchanged); the mux forwards `msg.Quality`; `OnDependencyChanged` maps it to a
|
||||
StatusCode (`Good→0`, `Uncertain→0x40000000`, `Bad→0x80000000`) on the pushed `DataValueSnapshot`.
|
||||
- **Engine computes the worst input quality** each evaluation (over the refilled read cache, **before** the
|
||||
`AreInputsReady` short-circuit so a `Bad` input is still observed) and carries it as
|
||||
`ScriptedAlarmEvent.WorstInputStatusCode` (a raw `uint` StatusCode — `Core.ScriptedAlarms` doesn't reference
|
||||
Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it to `OpcUaQuality`).
|
||||
- **Transitions carry the current worst quality** → `ToSnapshot` projects it (no clobber-back-to-Good when a
|
||||
transition fires while an input is `Uncertain`).
|
||||
- **Quality-only changes emit out of band.** A `Bad` input freezes the condition (`AreInputsReady` returns
|
||||
false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The
|
||||
engine tracks the last worst-quality **bucket** per alarm and, when the bucket changes with **no** transition
|
||||
emission, emits a new `EmissionKind.QualityChanged` event. The host routes that to the **existing** Layer 2
|
||||
`OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQuality` path (sets ONLY Quality,
|
||||
one Part 9 event on a bucket change, **no `/alerts` row**, no historian write). No new sink surface.
|
||||
- **`ScriptedAlarmSource` (the `IAlarmSource` fan-out adapter) skips `QualityChanged`** — quality is delivered
|
||||
through the dedicated node path, never as a phantom `AlarmEventArgs` (which would materialize/historize a
|
||||
native condition).
|
||||
|
||||
**Files (Layer 3):**
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — `EmissionKind.QualityChanged`.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — worst-of-input, bucket tracking,
|
||||
`WorstInputStatusCode` on the event, quality-only emission.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — skip `QualityChanged`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — `DependencyValueChanged.Quality`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — forward `msg.Quality`.
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — push real quality;
|
||||
`ToSnapshot` maps `WorstInputStatusCode`; `OnEngineEmission` routes `QualityChanged → AlarmQualityUpdate`.
|
||||
|
||||
**Tests (RED-first):** engine — transition carries `Uncertain` worst; `Bad` input with no transition emits
|
||||
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; no spurious emit when the bucket is unchanged.
|
||||
`ScriptedAlarmSource` — `QualityChanged` raises no `OnAlarmEvent`. Mux — `DependencyValueChanged` carries the
|
||||
published quality. Host — `Bad` dependency → `AlarmQualityUpdate(Bad)`, no `/alerts` publish; `ToSnapshot`
|
||||
maps the event's worst quality.
|
||||
|
||||
**Coverage boundary → Layer 4 (#481).** L3 covers inputs whose driver **publishes a Bad/Uncertain-status
|
||||
data change** (the mux quality path). It does **not** cover a driver **comms loss**: a poll driver
|
||||
(Modbus/S7) whose device goes unreachable emits only `ConnectivityChanged` and goes silent on the value feed
|
||||
(`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
|
||||
condition stays Good — the same silent-feed problem native solved in L2, but native's `OnDriverConnectivityChanged`
|
||||
bridge fans only to **native** condition nodes (`_alarmNodeIdByDriverRef`), not into the mux the scripted
|
||||
engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start
|
||||
asymmetry (a runtime `Bad` with a null value is currently indistinguishable from cold start and contributes
|
||||
`Good`) and its ripple into virtual-tag quality — is **Gitea #481 (Layer 4)**. Found by the post-implementation
|
||||
code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality).
|
||||
@@ -380,6 +380,55 @@ polling the node.
|
||||
|
||||
---
|
||||
|
||||
## Config secrets (`${secret:}` delivery)
|
||||
|
||||
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
|
||||
supplied at deploy time — either as a plain environment variable, or as a
|
||||
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
|
||||
|
||||
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
||||
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
|
||||
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
|
||||
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
|
||||
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
|
||||
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
|
||||
the encrypted SQLite store lives at `Secrets:SqlitePath`.
|
||||
|
||||
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
|
||||
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
|
||||
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
|
||||
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
|
||||
|
||||
The five config secrets and their canonical secret names:
|
||||
|
||||
| Config key | Owner | Secret name |
|
||||
|---|---|---|
|
||||
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
|
||||
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
|
||||
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
|
||||
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
|
||||
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
|
||||
|
||||
**Delivery.** By default these are delivered as plain environment variables (e.g.
|
||||
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
|
||||
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
|
||||
the token in place of the literal — via env var or a deployment appsettings overlay:
|
||||
|
||||
```
|
||||
secret set otopcua/historian/api-key <value> # seed the encrypted store first
|
||||
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
|
||||
```
|
||||
|
||||
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
|
||||
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
|
||||
secret value.
|
||||
|
||||
For the production posture needed so every clustered node (admin, driver, and any
|
||||
fused role) resolves the same secrets from the same store, see
|
||||
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
@@ -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="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="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(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
|
||||
DateTime TimestampUtc,
|
||||
string AlarmTypeName = "AlarmCondition",
|
||||
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,
|
||||
}
|
||||
@@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
|
||||
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
|
||||
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
|
||||
/// <param name="Quality">
|
||||
/// Quality of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
|
||||
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
|
||||
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
|
||||
/// <see cref="OpcUaQuality.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
|
||||
/// </param>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
bool Active,
|
||||
bool Acknowledged,
|
||||
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
|
||||
bool Enabled,
|
||||
AlarmShelvingKind Shelving,
|
||||
ushort Severity,
|
||||
string Message);
|
||||
string Message,
|
||||
OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
|
||||
/// <summary>
|
||||
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
|
||||
|
||||
@@ -23,30 +23,44 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
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, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||
// 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 />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _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 />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
/// <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 />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
@@ -55,9 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// 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
|
||||
// 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
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
|
||||
@@ -65,9 +79,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// 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
|
||||
// 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
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||
|
||||
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
|
||||
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
|
||||
@@ -75,14 +89,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
// (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.
|
||||
/// <inheritdoc />
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||
=> _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="quality">The quality status of the value.</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
|
||||
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
|
||||
@@ -25,7 +29,20 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <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="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>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
|
||||
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>,
|
||||
/// restored → <see cref="OpcUaQuality.Good"/>). Sets ONLY the condition's Quality — never
|
||||
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
|
||||
/// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node.</summary>
|
||||
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="quality">The source-data quality to annotate.</param>
|
||||
/// <param name="sourceTimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||
/// <param name="realm">The namespace realm the condition was materialised under.</param>
|
||||
void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
|
||||
|
||||
/// <summary>
|
||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||
@@ -41,7 +58,31 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <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
|
||||
/// 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>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
||||
@@ -52,7 +93,8 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <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="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>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||
@@ -77,7 +119,10 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// rank + dimensions carry the array-ness.</param>
|
||||
/// <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>
|
||||
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>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||
@@ -91,7 +136,28 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
|
||||
/// </summary>
|
||||
/// <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
|
||||
@@ -106,23 +172,30 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
private NullOpcUaAddressSpaceSink() { }
|
||||
|
||||
/// <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 />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <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 />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
/// <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 />
|
||||
public void RebuildAddressSpace() { }
|
||||
|
||||
/// <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
|
||||
/// <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="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>
|
||||
/// <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>
|
||||
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
||||
private NullOpcUaNodeWriteGateway() { }
|
||||
/// <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"));
|
||||
}
|
||||
|
||||
@@ -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="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="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>
|
||||
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
|
||||
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
||||
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// should rebuild instead).</summary>
|
||||
/// <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="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>
|
||||
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
|
||||
/// 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
|
||||
/// (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="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>
|
||||
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
|
||||
/// 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
|
||||
/// rebuilds).</summary>
|
||||
/// <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>
|
||||
bool RemoveAlarmConditionNode(string alarmNodeId);
|
||||
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
|
||||
|
||||
/// <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,
|
||||
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
|
||||
/// rebuilds).</summary>
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-equipment reference map — <c>equipmentId → (effectiveName → backing-tag RawPath)</c> —
|
||||
/// the single authority both compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>)
|
||||
/// and the draft validator use to resolve <c>{{equip}}/<RefName></c> script paths. A reference's
|
||||
/// <b>effective name</b> is its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>; the
|
||||
/// RawPath is computed through the shared <see cref="RawPathResolver"/> (the same identity authority the
|
||||
/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree
|
||||
/// byte-for-byte.
|
||||
/// <para>Input-shape agnostic + pure: callers flatten their references (EF entities on one side,
|
||||
/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the
|
||||
/// effective-name rule, and the collision policy live ONLY here. References are consumed in
|
||||
/// <c>UnsTagReferenceId</c>-ordinal order and effective-name collisions keep the FIRST — a valid draft
|
||||
/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two
|
||||
/// seams parity-stable on any input.</para>
|
||||
/// </summary>
|
||||
public static class EquipmentReferenceMap
|
||||
{
|
||||
/// <summary>A flattened UNS tag reference: which equipment references which raw tag under an optional override.</summary>
|
||||
/// <param name="UnsTagReferenceId">Stable logical id — the ordering key for deterministic first-wins.</param>
|
||||
/// <param name="EquipmentId">The referencing equipment.</param>
|
||||
/// <param name="TagId">The backing raw tag.</param>
|
||||
/// <param name="DisplayNameOverride">Optional effective-name override; <see langword="null"/> = use the raw tag's Name.</param>
|
||||
public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride);
|
||||
|
||||
/// <summary>The backing raw tag's identity inputs for its RawPath + effective name.</summary>
|
||||
/// <param name="Name">The raw tag's leaf name (also the default effective name).</param>
|
||||
/// <param name="DeviceId">The tag's owning device id.</param>
|
||||
/// <param name="TagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
|
||||
public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId);
|
||||
|
||||
/// <summary>
|
||||
/// Build the reference map. For each reference (ordered by <c>UnsTagReferenceId</c>), resolve the
|
||||
/// backing tag's RawPath via <paramref name="resolver"/> and key it under the effective name within
|
||||
/// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built
|
||||
/// (broken chain) are skipped. Effective-name collisions within an equipment keep the first.
|
||||
/// </summary>
|
||||
/// <param name="references">The flattened UNS tag references (any order; sorted internally).</param>
|
||||
/// <param name="tagsById">Backing raw tags keyed by <c>TagId</c>.</param>
|
||||
/// <param name="resolver">The shared RawPath resolver over the raw topology.</param>
|
||||
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>. Equipments with no resolvable reference are absent.</returns>
|
||||
public static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Build(
|
||||
IEnumerable<ReferenceRow> references,
|
||||
IReadOnlyDictionary<string, TagRow> tagsById,
|
||||
RawPathResolver resolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(references);
|
||||
ArgumentNullException.ThrowIfNull(tagsById);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var byEquip = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue;
|
||||
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
||||
if (string.IsNullOrEmpty(effectiveName)) continue;
|
||||
var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
||||
|
||||
if (!byEquip.TryGetValue(r.EquipmentId, out var map))
|
||||
byEquip[r.EquipmentId] = map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions)
|
||||
}
|
||||
|
||||
return byEquip.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (IReadOnlyDictionary<string, string>)kv.Value,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The set of effective names a single equipment's references contribute (for the authoring
|
||||
/// gate + Monaco completion, which need only the names — not the RawPaths).</summary>
|
||||
/// <param name="references">The flattened UNS tag references for (typically) one equipment.</param>
|
||||
/// <param name="tagNameById">Backing raw tag Name keyed by <c>TagId</c>.</param>
|
||||
/// <returns>The distinct effective names, ordinal.</returns>
|
||||
public static IReadOnlySet<string> EffectiveNames(
|
||||
IEnumerable<ReferenceRow> references,
|
||||
IReadOnlyDictionary<string, string> tagNameById)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(references);
|
||||
ArgumentNullException.ThrowIfNull(tagNameById);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in references)
|
||||
{
|
||||
var effectiveName = r.DisplayNameOverride
|
||||
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,29 @@ using System.Text.RegularExpressions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
|
||||
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
||||
/// replaced at the compose seams with the owning equipment's tag base prefix (derived
|
||||
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer
|
||||
/// composer and the Runtime artifact-decode path can both share it. Also the single home
|
||||
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
|
||||
/// duplicate.
|
||||
/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
|
||||
/// <c>{{equip}}/<RefName></c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
||||
/// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
|
||||
/// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
|
||||
/// <c>UnsTagReference</c> rows by effective name (<c><RefName></c>).
|
||||
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/<RefName></c> — replacing
|
||||
/// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c><RefName></c> is a reference's effective name
|
||||
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
|
||||
/// <c><RefName></c> is left un-substituted (substitution never throws); the deploy-time validator +
|
||||
/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant
|
||||
/// <b>the editor accepts ⇔ publish accepts</b>.</para>
|
||||
/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can
|
||||
/// both share it. Also the single home for the <c>ctx.GetTag("…")</c> dependency-ref extraction those
|
||||
/// two seams used to duplicate.
|
||||
/// </summary>
|
||||
public static class EquipmentScriptPaths
|
||||
{
|
||||
/// <summary>The reserved equipment-base token.</summary>
|
||||
/// <summary>The reserved equipment token stem.</summary>
|
||||
public const string EquipToken = "{{equip}}";
|
||||
|
||||
/// <summary>The reserved equipment reference-relative prefix — the token stem plus the slash joint.</summary>
|
||||
public const string EquipTokenPrefix = "{{equip}}/";
|
||||
|
||||
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
|
||||
private static readonly Regex GetTagRefRegex =
|
||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
|
||||
private static readonly Regex PathLiteralRegex =
|
||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
|
||||
|
||||
// {{equip}}/<RefName> occurrence in FREE TEXT (message templates). <RefName> runs until the next
|
||||
// delimiter that cannot appear in an intra-literal RawPath segment used inline in a template —
|
||||
// whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may
|
||||
// contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the
|
||||
// free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.)
|
||||
private static readonly Regex EquipRefTextRegex =
|
||||
new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled);
|
||||
|
||||
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
|
||||
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
|
||||
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
|
||||
@@ -46,44 +64,89 @@ public static class EquipmentScriptPaths
|
||||
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Equipment tag base = the single shared substring-before-first-dot across the
|
||||
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
|
||||
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
|
||||
/// Replace each <c>{{equip}}/<RefName></c> reference-relative path with the backing raw tag's
|
||||
/// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. A path literal whose content is
|
||||
/// <c>{{equip}}/<RefName></c> is treated as a single reference: <c><RefName></c> is the
|
||||
/// entire remainder after the prefix (so reference effective names may contain internal spaces).
|
||||
/// Identity when <paramref name="source"/> is null/empty, <paramref name="referenceMap"/> is empty,
|
||||
/// or the token is absent (so every existing script — none of which use the token — is byte-unchanged).
|
||||
/// An <c><RefName></c> absent from the map is left un-substituted (never throws) — the validator
|
||||
/// rejects it first at deploy.
|
||||
/// </summary>
|
||||
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
|
||||
/// <returns>The shared base prefix, or null when none/ambiguous.</returns>
|
||||
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames)
|
||||
/// <param name="source">The script source.</param>
|
||||
/// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
|
||||
/// <returns>The source with each resolvable <c>{{equip}}/<RefName></c> substituted inside path literals.</returns>
|
||||
public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary<string, string> referenceMap)
|
||||
{
|
||||
string? found = null;
|
||||
foreach (var fn in childFullNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fn)) continue;
|
||||
var dot = fn.IndexOf('.');
|
||||
var prefix = dot < 0 ? fn : fn.Substring(0, dot);
|
||||
if (prefix.Length == 0) continue;
|
||||
if (found is null) found = prefix;
|
||||
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null;
|
||||
}
|
||||
return found;
|
||||
if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
|
||||
if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
|
||||
return PathLiteralRegex.Replace(source, m =>
|
||||
m.Groups[1].Value
|
||||
+ SubstituteContent(m.Groups[2].Value, referenceMap)
|
||||
+ m.Groups[3].Value);
|
||||
}
|
||||
|
||||
// Substitute the reference-relative token inside a single path-literal's content. The content is one
|
||||
// whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name.
|
||||
private static string SubstituteContent(string content, IReadOnlyDictionary<string, string> referenceMap)
|
||||
{
|
||||
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content;
|
||||
var refName = content.Substring(EquipTokenPrefix.Length);
|
||||
if (refName.Length == 0) return content;
|
||||
return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
|
||||
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
|
||||
/// script — none of which use the token — is byte-unchanged).
|
||||
/// Distinct <c><RefName></c> values used via <c>{{equip}}/<RefName></c> inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
|
||||
/// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
|
||||
/// string is not reported), and the entire post-prefix remainder is the reference name (matching
|
||||
/// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what
|
||||
/// resolves. Feeds the deploy-gate + authoring rejection + editor completion.
|
||||
/// </summary>
|
||||
/// <param name="source">The script source.</param>
|
||||
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
|
||||
/// <returns>The source with the token substituted inside path literals.</returns>
|
||||
public static string SubstituteEquipmentToken(string source, string? equipBase)
|
||||
/// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
|
||||
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
|
||||
public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source;
|
||||
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source;
|
||||
return PathLiteralRegex.Replace(source, m =>
|
||||
m.Groups[1].Value
|
||||
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal)
|
||||
+ m.Groups[3].Value);
|
||||
if (string.IsNullOrEmpty(scriptSource)
|
||||
|| !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return Array.Empty<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
foreach (Match m in PathLiteralRegex.Matches(scriptSource))
|
||||
{
|
||||
var content = m.Groups[2].Value;
|
||||
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue;
|
||||
var refName = content.Substring(EquipTokenPrefix.Length);
|
||||
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct <c><RefName></c> values used via <c>{{equip}}/<RefName></c> in FREE TEXT — the
|
||||
/// scripted-alarm <c>MessageTemplate</c>, which is not a path literal — in first-seen order. Best-effort:
|
||||
/// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so
|
||||
/// a space-bearing effective name used inline in a template resolves only up to its first space (a
|
||||
/// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule
|
||||
/// for alarm message tokens.
|
||||
/// </summary>
|
||||
/// <param name="text">The free text (e.g. a scripted-alarm message template).</param>
|
||||
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
|
||||
public static IReadOnlyList<string> ExtractEquipReferenceNamesFromText(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)
|
||||
|| !text.Contains(EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return Array.Empty<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
foreach (Match m in EquipRefTextRegex.Matches(text))
|
||||
{
|
||||
var refName = m.Groups[1].Value;
|
||||
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -115,10 +178,11 @@ public static class EquipmentScriptPaths
|
||||
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
|
||||
/// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
|
||||
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
|
||||
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
|
||||
/// tags do) — pass the predicate source as-is.
|
||||
/// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
|
||||
/// compose seams substitute <c>{{equip}}/<RefName></c> before extraction, identically), so the
|
||||
/// merged refs are resolved RawPaths.
|
||||
/// </summary>
|
||||
/// <param name="predicateSource">The resolved predicate script source.</param>
|
||||
/// <param name="predicateSource">The resolved (substituted) predicate script source.</param>
|
||||
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
|
||||
@@ -154,7 +218,7 @@ public static class EquipmentScriptPaths
|
||||
/// <param name="source">The virtual-tag script source to inspect.</param>
|
||||
/// <param name="tagReference">
|
||||
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
|
||||
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}.Speed</c>);
|
||||
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}/Speed</c>);
|
||||
/// otherwise <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class DraftValidator
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateEquipReferenceResolution(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
@@ -238,6 +239,64 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>v3: every <c>{{equip}}/<RefName></c> used in an equipment's VirtualTag script,
|
||||
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
|
||||
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
|
||||
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c><RefName></c>
|
||||
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
|
||||
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
|
||||
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
|
||||
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
|
||||
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
|
||||
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
|
||||
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var r in draft.UnsTagReferences)
|
||||
{
|
||||
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (string.IsNullOrEmpty(effective)) continue;
|
||||
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
|
||||
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(effective!);
|
||||
}
|
||||
|
||||
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||
|
||||
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
|
||||
{
|
||||
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
|
||||
foreach (var name in refNames)
|
||||
{
|
||||
if (have is not null && have.Contains(name)) continue;
|
||||
errors.Add(new("EquipReferenceUnresolved",
|
||||
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
|
||||
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
|
||||
equipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var v in draft.VirtualTags)
|
||||
{
|
||||
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
|
||||
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
|
||||
}
|
||||
|
||||
foreach (var a in draft.ScriptedAlarms)
|
||||
{
|
||||
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
|
||||
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSegment(string? s) =>
|
||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||
|
||||
|
||||
@@ -377,4 +377,9 @@ public enum EmissionKind
|
||||
Enabled,
|
||||
Disabled,
|
||||
CommentAdded,
|
||||
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
|
||||
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
|
||||
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
|
||||
/// state change and must not materialize or historize a condition).</summary>
|
||||
QualityChanged,
|
||||
}
|
||||
|
||||
@@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
|
||||
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
|
||||
/// with no accompanying Part 9 state transition drives a standalone
|
||||
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
|
||||
/// transition — so quality can't ride one, exactly like a comms-lost native driver). Only ever
|
||||
/// mutated under <c>_evalGate</c>; cleared alongside <see cref="_alarms"/> on load/dispose.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, int> _lastQualityBucketByAlarmId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
|
||||
@@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// have changed (different Inputs, different Logger), so any reuse would be
|
||||
// unsafe.
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
|
||||
// recompile this one. Skipping this is what made the earlier fix a
|
||||
// no-op in production.
|
||||
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// OnEvent dispatch until after Release() so a slow subscriber or a
|
||||
// subscriber that re-enters the engine doesn't block / deadlock.
|
||||
if (result.Emission != EmissionKind.None)
|
||||
pending = BuildEmission(state, result.State, result.Emission);
|
||||
pending = BuildEmission(state, result.State, result.Emission, LastWorstStatus(alarmId));
|
||||
else if (result.NoOpReason is { } reason)
|
||||
{
|
||||
// The Part9StateMachine remarks promise a diagnostic log line for
|
||||
@@ -513,13 +525,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
|
||||
RefillReadCache(scratch.ReadCache, state.Inputs);
|
||||
|
||||
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
|
||||
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
|
||||
// transition is delivered out of band as a QualityChanged emission (see below).
|
||||
var worstStatus = WorstInputStatus(scratch.ReadCache);
|
||||
var qualityBucketChanged = TrackQualityBucket(state.Definition.AlarmId, worstStatus);
|
||||
|
||||
// Cold-start guard — skip the predicate when any referenced upstream tag has no
|
||||
// cached value yet (the upstream subscription hasn't delivered its first push).
|
||||
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
|
||||
// every tick until the cache fills, spamming the log with identical stack traces.
|
||||
// Bad quality is treated the same: the input isn't available at the predicate's
|
||||
// expected type, so the only defensible move is to hold the prior condition state.
|
||||
if (!AreInputsReady(scratch.ReadCache)) return seed;
|
||||
if (!AreInputsReady(scratch.ReadCache))
|
||||
{
|
||||
// The condition is frozen (can't trust its state), but its source quality just changed
|
||||
// bucket — annotate it out of band so a comms-lost / Bad-input scripted condition reports
|
||||
// Bad, mirroring the native OT path.
|
||||
if (qualityBucketChanged)
|
||||
pendingEmissions.Add(BuildQualityEmission(state, seed, worstStatus));
|
||||
return seed;
|
||||
}
|
||||
|
||||
var context = scratch.Context;
|
||||
|
||||
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
}
|
||||
|
||||
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
||||
if (result.Emission != EmissionKind.None)
|
||||
var transition = result.Emission != EmissionKind.None
|
||||
? BuildEmission(state, result.State, result.Emission, worstStatus)
|
||||
: null;
|
||||
if (transition is not null)
|
||||
{
|
||||
var evt = BuildEmission(state, result.State, result.Emission);
|
||||
if (evt is not null) pendingEmissions.Add(evt);
|
||||
// A real transition carries the current worst quality so the projected full-snapshot
|
||||
// write doesn't clobber quality back to Good (e.g. a transition while an input is Uncertain).
|
||||
pendingEmissions.Add(transition);
|
||||
}
|
||||
else if (qualityBucketChanged)
|
||||
{
|
||||
// No transition (or a Suppressed one) but the quality bucket moved — annotate out of band.
|
||||
pendingEmissions.Add(BuildQualityEmission(state, result.State, worstStatus));
|
||||
}
|
||||
return result.State;
|
||||
}
|
||||
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
|
||||
/// released.
|
||||
/// </summary>
|
||||
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||
private ScriptedAlarmEvent? BuildEmission(
|
||||
AlarmState state, AlarmConditionState condition, EmissionKind kind, uint worstInputStatus)
|
||||
{
|
||||
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||
// but the state record still advanced so startup recovery reflects reality.
|
||||
@@ -629,9 +665,89 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
// Carry the per-alarm durable-historization opt-out through to subscribers. The historian
|
||||
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
|
||||
// unaffected (it is not gated on this flag).
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva);
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||
// #478 — the worst input quality at evaluation time rides the transition so the projected
|
||||
// full snapshot keeps quality consistent (no clobber-to-Good).
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
|
||||
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
|
||||
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
|
||||
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
|
||||
/// no <c>/alerts</c> row, no historian write); the <see cref="IAlarmSource"/> fan-out skips it.
|
||||
/// </summary>
|
||||
private ScriptedAlarmEvent BuildQualityEmission(
|
||||
AlarmState state, AlarmConditionState condition, uint worstInputStatus)
|
||||
=> new(
|
||||
AlarmId: state.Definition.AlarmId,
|
||||
EquipmentPath: state.Definition.EquipmentPath,
|
||||
AlarmName: state.Definition.AlarmName,
|
||||
Kind: state.Definition.Kind,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: condition,
|
||||
Emission: EmissionKind.QualityChanged,
|
||||
TimestampUtc: _clock(),
|
||||
HistorizeToAveva: state.Definition.HistorizeToAveva,
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
|
||||
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
|
||||
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
|
||||
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
|
||||
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
|
||||
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
|
||||
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
|
||||
/// cache ⇒ Good (0).</summary>
|
||||
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
uint worst = 0u;
|
||||
var worstSeverity = 0u;
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
|
||||
var status = kv.Value.StatusCode;
|
||||
var severity = status >> 30;
|
||||
if (severity > worstSeverity)
|
||||
{
|
||||
worstSeverity = severity;
|
||||
worst = status;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
|
||||
/// (0 = Good, 1 = Uncertain, 2 = Bad) changed from the last observed value. Only called under
|
||||
/// <c>_evalGate</c>.</summary>
|
||||
private bool TrackQualityBucket(string alarmId, uint worstStatus)
|
||||
{
|
||||
var bucket = QualityBucket(worstStatus);
|
||||
var prior = _lastQualityBucketByAlarmId.TryGetValue(alarmId, out var b) ? b : 0; // default Good
|
||||
_lastQualityBucketByAlarmId[alarmId] = bucket;
|
||||
return bucket != prior;
|
||||
}
|
||||
|
||||
/// <summary>Collapse an OPC UA StatusCode's 2 severity bits (00/01/10/11) to a 3-state quality bucket
|
||||
/// (0 = Good, 1 = Uncertain, 2 = Bad).</summary>
|
||||
private static int QualityBucket(uint statusCode)
|
||||
{
|
||||
var severity = statusCode >> 30;
|
||||
return severity >= 2 ? 2 : (int)severity;
|
||||
}
|
||||
|
||||
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
|
||||
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
|
||||
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
|
||||
private uint LastWorstStatus(string alarmId)
|
||||
=> (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch
|
||||
{
|
||||
2 => 0x80000000u, // Bad
|
||||
1 => 0x40000000u, // Uncertain
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
|
||||
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
|
||||
@@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarms[id] = state with { Condition = result.State };
|
||||
if (result.Emission != EmissionKind.None)
|
||||
{
|
||||
var evt = BuildEmission(state, result.State, result.Emission);
|
||||
var evt = BuildEmission(state, result.State, result.Emission, LastWorstStatus(id));
|
||||
if (evt is not null) pending.Add(evt);
|
||||
}
|
||||
}
|
||||
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
_scratchByAlarmId.Clear();
|
||||
_lastQualityBucketByAlarmId.Clear();
|
||||
// Dispose every compiled-predicate ALC so the engine's shutdown actually
|
||||
// releases the emitted assemblies. The drain above ensures no evaluator is
|
||||
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
|
||||
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
|
||||
EmissionKind Emission,
|
||||
DateTime TimestampUtc,
|
||||
string? Comment = null,
|
||||
bool HistorizeToAveva = true);
|
||||
bool HistorizeToAveva = true,
|
||||
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint
|
||||
// (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
|
||||
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
||||
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
||||
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
||||
if (evt.Emission == EmissionKind.QualityChanged) return;
|
||||
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
{
|
||||
if (!Matches(sub, evt)) continue;
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
|
||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
};
|
||||
|
||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
|
||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
||||
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
|
||||
{
|
||||
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
|
||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||
|
||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
||||
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||
// an AdminUI cancel still wins early.
|
||||
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
|
||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
|
||||
/// object initializer (you can't await inside one).
|
||||
/// </summary>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||
/// supported, in priority order:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||
/// no startup warning.</item>
|
||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||
/// forms supported, evaluated in order:
|
||||
/// <list type="number">
|
||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
||||
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
||||
/// doesn't emit a warning; production should never use this arm.</item>
|
||||
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||
/// throws rather than falling through to the literal arm — the production path
|
||||
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||
/// <item>Anything else — used as the literal API key for back-compat with
|
||||
/// configs that pre-date this resolver. When a logger is supplied the
|
||||
/// resolver emits a startup warning so an operator who accidentally
|
||||
/// committed a cleartext key sees it.</item>
|
||||
/// </list>
|
||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
||||
/// A future PR can swap any of these arms for a different backing store without
|
||||
/// changing the call site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
public static class GalaxySecretRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
||||
/// opt-in path that doesn't warn.
|
||||
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
|
||||
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||
/// <returns>The resolved API-key string.</returns>
|
||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
||||
public static async Task<string> ResolveApiKeyAsync(
|
||||
string secretRef,
|
||||
ISecretResolver resolver,
|
||||
ILogger? logger = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
||||
return secretRef[4..];
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Production path: resolve the name through the shared encrypted secret store.
|
||||
// Fail-closed — an absent/tombstoned secret throws rather than falling through
|
||||
// to the literal arm (which would silently treat the ref string as the key).
|
||||
var name = secretRef["secret:".Length..];
|
||||
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||
}
|
||||
|
||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||
// API key — but emit a warning so an operator who accidentally committed a
|
||||
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
||||
|
||||
+1
@@ -13,5 +13,6 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
private readonly ILogger<GalaxyDriver> _logger;
|
||||
|
||||
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
||||
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
||||
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
||||
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
|
||||
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
||||
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ISecretResolver secretResolver,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options,
|
||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||
alarmAcknowledger: null, alarmFeed: null, logger)
|
||||
alarmAcknowledger: null, alarmFeed: null, logger,
|
||||
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
|
||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
||||
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
||||
/// don't exercise a <c>secret:</c> ref need not supply one.
|
||||
/// </param>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
||||
IGalaxySubscriber? subscriber = null,
|
||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||
IGalaxyAlarmFeed? alarmFeed = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
ILogger<GalaxyDriver>? logger = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
? driverInstanceId
|
||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
||||
_driverInstanceId);
|
||||
}
|
||||
|
||||
StartDeployWatcher();
|
||||
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
||||
/// </summary>
|
||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ownedMxSession is null) return;
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
||||
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
||||
// warning rather than silently shipping the key. The resolver lives in
|
||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
||||
// AdminUI browser share one implementation.
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||
// async and you can't await inside an initializer. Pass the logger so the
|
||||
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private void StartDeployWatcher()
|
||||
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Repository.WatchDeployEvents) return;
|
||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||
|
||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
||||
// rather than overwriting the field and leaking the first instance.
|
||||
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||
// runs it reuses this client rather than overwriting the field and leaking the
|
||||
// first instance — the client-options build is now async (secret: arm).
|
||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||
BuildClientOptions(_options.Gateway));
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||
_deployWatcher = new DeployWatcher(source, _logger);
|
||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
||||
var source = _hierarchySource ??=
|
||||
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
||||
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
|
||||
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||
/// internal ctor.
|
||||
/// </summary>
|
||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
||||
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
||||
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
||||
// than always overwriting the field and leaking the first instance. Both paths
|
||||
// produce equivalent clients from the same options.
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
||||
// produce equivalent clients from the same options. The client-options build is
|
||||
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||
return new TracedGalaxyHierarchySource(
|
||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "GalaxyMxGateway";
|
||||
|
||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
||||
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
|
||||
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ISecretResolver secretResolver,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
|
||||
}
|
||||
|
||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
||||
/// <summary>
|
||||
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
|
||||
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
|
||||
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
|
||||
/// threads the DI resolver.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
|
||||
|
||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
|
||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||
public static GalaxyDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
||||
RawTags = dto.RawTags ?? [],
|
||||
};
|
||||
|
||||
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||
/// (absent). Used as the default for the internal test ctor and the parse-only
|
||||
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
|
||||
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
|
||||
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
|
||||
/// null object throws fail-closed (the secret is reported absent), which is the correct
|
||||
/// behaviour for a mis-wired deployment.
|
||||
/// </summary>
|
||||
internal sealed class NullSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <summary>The shared singleton instance.</summary>
|
||||
public static readonly NullSecretResolver Instance = new();
|
||||
|
||||
private NullSecretResolver()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
+3
-1
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
|
||||
/// protections cover it.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriverOptions
|
||||
// A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
|
||||
// credential-resolved copy with a `with` expression — every property keeps its init setter.
|
||||
public sealed record OpcUaClientDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
|
||||
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
|
||||
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
|
||||
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
|
||||
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
|
||||
/// <c>secret:</c> literal is never sent verbatim as a password.
|
||||
/// </summary>
|
||||
internal sealed class NullSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <summary>The shared singleton instance.</summary>
|
||||
public static readonly NullSecretResolver Instance = new();
|
||||
|
||||
private NullSecretResolver()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <param name="options">Driver configuration.</param>
|
||||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||||
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||||
/// secret absent) for the test/parse-only path; the production factory threads the
|
||||
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||||
/// </param>
|
||||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||||
ILogger<OpcUaClientDriver>? logger = null)
|
||||
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly string _driverInstanceId;
|
||||
// ---- IAlarmSource state ----
|
||||
|
||||
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var candidates = ResolveEndpointCandidates(_options);
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||
// are consumed. _options stays the raw config (with secret: refs) — only this
|
||||
// connect-scoped local carries plaintext, and only for the duration of the connect.
|
||||
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
|
||||
// Both credential consumers — the Username password below and the Certificate password
|
||||
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||
// single resolve covers both paths.
|
||||
var resolvedOptions = await OpcUaClientSecretResolution
|
||||
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var identity = BuildUserIdentity(resolvedOptions);
|
||||
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
|
||||
+23
-5
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Register the OpcUaClient factory with the driver registry.</summary>
|
||||
/// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
/// <param name="secretResolver">
|
||||
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
|
||||
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
|
||||
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
|
||||
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
|
||||
/// </param>
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
var resolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
|
||||
}
|
||||
|
||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||
/// <param name="secretResolver">
|
||||
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
|
||||
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
|
||||
/// </param>
|
||||
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
|
||||
public static OpcUaClientDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null)
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>());
|
||||
return new OpcUaClientDriver(
|
||||
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
|
||||
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
|
||||
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
|
||||
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
|
||||
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
|
||||
// credentials are actually consumed.
|
||||
OpcUaClientDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
|
||||
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
|
||||
/// session-open path consumes them — retiring the cleartext password-in-DB model for
|
||||
/// production.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
|
||||
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
|
||||
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
|
||||
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
|
||||
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
|
||||
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
|
||||
/// which would otherwise be sent verbatim to the remote server as the password.
|
||||
/// </remarks>
|
||||
internal static class OpcUaClientSecretResolution
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
|
||||
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
|
||||
/// returned unchanged.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||
/// <param name="resolver">The shared secret resolver.</param>
|
||||
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
@* No AdditionalAssemblies: the Secrets.Ui RCL's routable page is NOT routed directly in this
|
||||
host — Pages/Secrets.razor wraps it so the route can carry this host's per-page
|
||||
@rendermode (lmxopcua#483); registering the RCL routes too would make /admin/secrets
|
||||
ambiguous. *@
|
||||
<Routes />
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<ThemeScripts />
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Scripting" Key="scripting">
|
||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||
|
||||
@@ -64,7 +64,22 @@ else
|
||||
<tr>
|
||||
<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 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 class="num">@e.Severity</td>
|
||||
<td>@e.User</td>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/admin/secrets"
|
||||
@attribute [Authorize(Policy = ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using ZB.MOM.WW.Secrets.Ui
|
||||
|
||||
@* Host-side wrapper for the ZB.MOM.WW.Secrets.Ui secrets page (lmxopcua#483).
|
||||
|
||||
This host's <Routes/> is deliberately static (cookie SignInAsync needs SSR), so every
|
||||
interactive page opts in per-page with @rendermode — a directive the RCL's own routable
|
||||
SecretsPage cannot carry, because the other three family hosts render it under a globally
|
||||
interactive router where a nested render mode throws. Routing straight to the RCL page
|
||||
therefore produced a statically-rendered, dead page here: no circuit, @onclick inert.
|
||||
|
||||
So this wrapper owns the /admin/secrets route in THIS host (the RCL assembly is no longer
|
||||
passed to either AdditionalAssemblies registration — doing both would make the route
|
||||
ambiguous), carries the standard per-page render mode, re-states the RCL page's own
|
||||
authorization policy (the router only enforces [Authorize] on the routed page itself,
|
||||
which is now this one), and renders the RCL page as an ordinary child component. *@
|
||||
|
||||
<SecretsPage />
|
||||
@@ -78,17 +78,9 @@ else
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-driver">Driver instance</label>
|
||||
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
||||
<option value="">(none / driver-less)</option>
|
||||
@foreach (var (id, display) in _driverOptions)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
|
||||
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
||||
@@ -138,38 +130,48 @@ else
|
||||
}
|
||||
else if (_activeTab == "tags")
|
||||
{
|
||||
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
|
||||
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
|
||||
override is the only per-reference editable field. *@
|
||||
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_tagError))
|
||||
@if (!string.IsNullOrWhiteSpace(_refError))
|
||||
{
|
||||
<div class="text-danger small mb-2">@_tagError</div>
|
||||
<div class="text-danger small mb-2">@_refError</div>
|
||||
}
|
||||
@if (_tags is null)
|
||||
@if (_refs is null)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (_tags.Count == 0)
|
||||
else if (_refs.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No tags yet.</p>
|
||||
<p class="text-muted">No tag references yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
|
||||
<tr>
|
||||
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
|
||||
<th>Display-name override</th><th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
@foreach (var r in _refs)
|
||||
{
|
||||
<tr @key="t.TagId">
|
||||
<td>@t.Name</td>
|
||||
<td class="mono">@t.DriverInstanceId</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@t.AccessLevel</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
|
||||
<tr @key="r.UnsTagReferenceId">
|
||||
<td>@r.EffectiveName</td>
|
||||
<td class="mono small">@r.RawPath</td>
|
||||
<td>@r.DataType</td>
|
||||
<td>@r.AccessLevel</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
|
||||
placeholder="(uses raw name)" />
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -177,9 +179,8 @@ else
|
||||
</table>
|
||||
}
|
||||
|
||||
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
||||
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
||||
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
|
||||
<AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
|
||||
OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
|
||||
}
|
||||
else if (_activeTab == "vtags")
|
||||
{
|
||||
@@ -290,15 +291,13 @@ else
|
||||
private EquipmentEditDto? _equipment;
|
||||
private FormModel _form = new();
|
||||
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
||||
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
|
||||
|
||||
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). ---
|
||||
private IReadOnlyList<EquipmentTagRow>? _tags;
|
||||
private string? _tagError;
|
||||
private bool _tagModalVisible;
|
||||
private bool _tagModalIsNew;
|
||||
private TagEditDto? _tagModalExisting;
|
||||
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
|
||||
// --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
|
||||
// spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
|
||||
private IReadOnlyList<EquipmentReferenceRow>? _refs;
|
||||
private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
|
||||
private string? _refError;
|
||||
private bool _refModalVisible;
|
||||
|
||||
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
||||
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
||||
@@ -329,54 +328,48 @@ else
|
||||
{
|
||||
_activeTab = tab;
|
||||
if (IsNew) { return; }
|
||||
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); }
|
||||
if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
|
||||
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
|
||||
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
|
||||
}
|
||||
|
||||
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) ---
|
||||
// --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
|
||||
|
||||
private async Task ReloadTagsAsync()
|
||||
private async Task ReloadReferencesAsync()
|
||||
{
|
||||
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
|
||||
_refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
|
||||
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
|
||||
_overrideEdits.Clear();
|
||||
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
|
||||
}
|
||||
|
||||
private async Task OpenAddTag()
|
||||
private void OpenAddReference()
|
||||
{
|
||||
_tagError = null;
|
||||
_tagModalIsNew = true;
|
||||
_tagModalExisting = null;
|
||||
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
||||
_tagModalVisible = true;
|
||||
_refError = null;
|
||||
_refModalVisible = true;
|
||||
}
|
||||
|
||||
private async Task OpenEditTag(string tagId)
|
||||
private async Task OnReferencesCommittedAsync()
|
||||
{
|
||||
_tagError = null;
|
||||
var dto = await Svc.LoadTagAsync(tagId);
|
||||
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
|
||||
_tagModalIsNew = false;
|
||||
_tagModalExisting = dto;
|
||||
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
|
||||
_tagModalVisible = true;
|
||||
_refModalVisible = false;
|
||||
await ReloadReferencesAsync();
|
||||
}
|
||||
|
||||
private async Task OnTagSavedAsync()
|
||||
private async Task SaveOverride(EquipmentReferenceRow r)
|
||||
{
|
||||
_tagModalVisible = false;
|
||||
await ReloadTagsAsync();
|
||||
_refError = null;
|
||||
var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
|
||||
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
|
||||
if (res.Ok) { await ReloadReferencesAsync(); }
|
||||
else { _refError = res.Error; }
|
||||
}
|
||||
|
||||
private async Task DeleteTag(string tagId)
|
||||
private async Task RemoveReference(EquipmentReferenceRow r)
|
||||
{
|
||||
_tagModalVisible = false;
|
||||
_tagError = null;
|
||||
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
|
||||
var dto = await Svc.LoadTagAsync(tagId);
|
||||
if (dto is null) { await ReloadTagsAsync(); return; }
|
||||
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
|
||||
if (r.Ok) { await ReloadTagsAsync(); }
|
||||
else { _tagError = r.Error; }
|
||||
_refError = null;
|
||||
var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
|
||||
if (res.Ok) { await ReloadReferencesAsync(); }
|
||||
else { _refError = res.Error; }
|
||||
}
|
||||
|
||||
// --- Virtual Tags tab handlers ---
|
||||
@@ -478,7 +471,7 @@ else
|
||||
// path lands on Details because the field initializes to "details" and a fresh page instance
|
||||
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
|
||||
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
|
||||
_tags = null;
|
||||
_refs = null;
|
||||
_vtags = null;
|
||||
_alarms = null;
|
||||
if (!IsNew)
|
||||
@@ -489,7 +482,6 @@ else
|
||||
LoadFormFrom(_equipment);
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -497,7 +489,6 @@ else
|
||||
_form = new FormModel { UnsLineId = LineId ?? "" };
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
@@ -510,7 +501,6 @@ else
|
||||
Name = e.Name,
|
||||
MachineCode = e.MachineCode,
|
||||
UnsLineId = e.UnsLineId,
|
||||
DriverInstanceId = e.DriverInstanceId,
|
||||
ZTag = e.ZTag,
|
||||
SAPID = e.SAPID,
|
||||
Manufacturer = e.Manufacturer,
|
||||
@@ -536,7 +526,6 @@ else
|
||||
_form.Name,
|
||||
_form.MachineCode,
|
||||
_form.UnsLineId,
|
||||
_form.DriverInstanceId,
|
||||
_form.ZTag,
|
||||
_form.SAPID,
|
||||
_form.Manufacturer,
|
||||
@@ -582,7 +571,6 @@ else
|
||||
public string Name { get; set; } = "";
|
||||
[Required] public string MachineCode { get; set; } = "";
|
||||
[Required] public string UnsLineId { get; set; } = "";
|
||||
public string? DriverInstanceId { get; set; }
|
||||
public string? ZTag { get; set; }
|
||||
public string? SAPID { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Owning-equipment context for equipment-relative <c>{{equip}}/<RefName></c> completion +
|
||||
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
|
||||
/// ScriptEdit page (where the token cannot resolve to a single equipment).
|
||||
/// </summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
|
||||
@@ -61,7 +68,8 @@
|
||||
{
|
||||
value = Value ?? "",
|
||||
language = Language,
|
||||
readOnly = ReadOnly
|
||||
readOnly = ReadOnly,
|
||||
equipmentId = EquipmentId
|
||||
},
|
||||
_dotNetRef);
|
||||
_initialized = true;
|
||||
|
||||
@@ -69,6 +69,28 @@
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRootsChanged { get; set; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
|
||||
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
|
||||
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
|
||||
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
|
||||
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
|
||||
[Parameter] public bool PickerMode { get; set; }
|
||||
|
||||
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
|
||||
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
|
||||
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
|
||||
|
||||
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
|
||||
[Parameter] public EventCallback OnSelectionChanged { get; set; }
|
||||
|
||||
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
|
||||
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
|
||||
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
|
||||
|
||||
// --- Configure-driver modal (edit) ---
|
||||
private bool _driverCfgVisible;
|
||||
private string? _driverCfgId;
|
||||
@@ -198,6 +220,12 @@
|
||||
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
|
||||
{
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
@if (PickerMode && node.Kind == RawNodeKind.Tag)
|
||||
{
|
||||
<input type="checkbox" class="form-check-input me-1"
|
||||
checked="@IsTagSelected(node)"
|
||||
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
|
||||
}
|
||||
<span aria-hidden="true">@KindIcon(node.Kind)</span>
|
||||
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
|
||||
@if (muted)
|
||||
@@ -270,17 +298,67 @@
|
||||
// named handlers below are the seam Wave B/C replaces with the real modals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
|
||||
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
|
||||
{
|
||||
RawNodeKind.Cluster => ClusterMenu(node),
|
||||
RawNodeKind.Folder => FolderMenu(node),
|
||||
RawNodeKind.Driver => DriverMenu(node),
|
||||
RawNodeKind.Device => DeviceMenu(node),
|
||||
RawNodeKind.TagGroup => TagGroupMenu(node),
|
||||
RawNodeKind.Tag => TagMenu(node),
|
||||
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
||||
// Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
|
||||
// affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
|
||||
if (PickerMode)
|
||||
{
|
||||
return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
|
||||
? PickerContainerMenu(node)
|
||||
: Array.Empty<ContextMenuItem>();
|
||||
}
|
||||
|
||||
return node.Kind switch
|
||||
{
|
||||
RawNodeKind.Cluster => ClusterMenu(node),
|
||||
RawNodeKind.Folder => FolderMenu(node),
|
||||
RawNodeKind.Driver => DriverMenu(node),
|
||||
RawNodeKind.Device => DeviceMenu(node),
|
||||
RawNodeKind.TagGroup => TagGroupMenu(node),
|
||||
RawNodeKind.Tag => TagMenu(node),
|
||||
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
|
||||
};
|
||||
}
|
||||
|
||||
// --- Picker-mode menu + selection helpers ---
|
||||
|
||||
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
|
||||
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
|
||||
};
|
||||
|
||||
private bool IsTagSelected(RawNode node) =>
|
||||
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
|
||||
|
||||
private async Task ToggleTagSelection(RawNode node, bool selected)
|
||||
{
|
||||
if (SelectedTagIds is null || node.EntityId is null) { return; }
|
||||
if (selected) { SelectedTagIds.Add(node.EntityId); }
|
||||
else { SelectedTagIds.Remove(node.EntityId); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SelectAllUnderAsync(RawNode node)
|
||||
{
|
||||
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
|
||||
var ids = await ResolveDescendantTagIds(node);
|
||||
foreach (var id in ids) { SelectedTagIds.Add(id); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ClearAllUnderAsync(RawNode node)
|
||||
{
|
||||
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
|
||||
var ids = await ResolveDescendantTagIds(node);
|
||||
foreach (var id in ids) { SelectedTagIds.Remove(id); }
|
||||
await OnSelectionChanged.InvokeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
|
||||
{
|
||||
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to
|
||||
multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural —
|
||||
IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any
|
||||
other cluster are simply unreachable through the tree, not merely hidden. On commit the selected
|
||||
tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add tag references</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Pick raw tags to reference under this equipment. The tree is scoped to the
|
||||
equipment's cluster. Tick individual tags, or use a device / tag-group's
|
||||
<em>“Select all tags below”</em> menu to pull many at once.
|
||||
</p>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (_roots.Count == 0)
|
||||
{
|
||||
<p class="text-muted">This equipment does not resolve to a cluster, so there are no raw tags to reference.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
|
||||
<RawTree Roots="_roots" PickerMode="true" SelectedTagIds="_selected"
|
||||
OnSelectionChanged="OnSelectionChanged"
|
||||
ResolveDescendantTagIds="ResolveDescendantsAsync" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@(_busy || _selected.Count == 0)">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Add @_selected.Count reference@(_selected.Count == 1 ? "" : "s")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>The equipment the selected raw tags are referenced under.</summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Raised after references are committed so the host can reload its Tags list and close.</summary>
|
||||
[Parameter] public EventCallback OnCommitted { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
|
||||
private readonly HashSet<string> _selected = new(StringComparer.Ordinal);
|
||||
private bool _busy;
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
private bool _wasVisible;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
|
||||
if (Visible && !_wasVisible)
|
||||
{
|
||||
_selected.Clear();
|
||||
_error = null;
|
||||
_roots = Array.Empty<RawNode>();
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var root = string.IsNullOrEmpty(EquipmentId)
|
||||
? null
|
||||
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
|
||||
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
|
||||
_loading = false;
|
||||
}
|
||||
_wasVisible = Visible;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<string>> ResolveDescendantsAsync(RawNode node) =>
|
||||
Svc.LoadDescendantTagIdsAsync(node);
|
||||
|
||||
// RawTree mutates _selected in place; this just re-renders the footer count.
|
||||
private void OnSelectionChanged() => StateHasChanged();
|
||||
|
||||
private async Task CommitAsync()
|
||||
{
|
||||
if (_selected.Count == 0) { return; }
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList());
|
||||
if (res.Ok) { await OnCommitted.InvokeAsync(); }
|
||||
else { _error = res.Error; }
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task CancelAsync() => OnCancel.InvokeAsync();
|
||||
}
|
||||
+12
-12
@@ -2,9 +2,10 @@
|
||||
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
|
||||
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
|
||||
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
|
||||
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
|
||||
Required header columns (in order): Name, MachineCode, UnsLineId.
|
||||
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
|
||||
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
|
||||
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
|
||||
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Paste CSV below. Required header columns (in order):
|
||||
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
|
||||
<span class="mono">Name, MachineCode, UnsLineId</span>.
|
||||
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
|
||||
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
|
||||
are detected by MachineCode and skipped (additive-only — no updates).
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
<textarea class="form-control form-control-sm mono" rows="12"
|
||||
@bind="_csv" @bind:event="oninput" disabled="@_busy"
|
||||
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_parseError))
|
||||
@@ -72,8 +73,8 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
|
||||
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
@@ -133,7 +134,7 @@
|
||||
|
||||
/// <summary>
|
||||
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
|
||||
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
|
||||
/// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
|
||||
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
|
||||
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
|
||||
/// </summary>
|
||||
@@ -169,11 +170,10 @@
|
||||
Name: parts[0],
|
||||
MachineCode: parts[1],
|
||||
UnsLineId: parts[2],
|
||||
DriverInstanceId: NullIfEmpty(parts, 3),
|
||||
ZTag: NullIfEmpty(parts, 4),
|
||||
SAPID: NullIfEmpty(parts, 5),
|
||||
Manufacturer: NullIfEmpty(parts, 6),
|
||||
Model: NullIfEmpty(parts, 7),
|
||||
ZTag: NullIfEmpty(parts, 3),
|
||||
SAPID: NullIfEmpty(parts, 4),
|
||||
Manufacturer: NullIfEmpty(parts, 5),
|
||||
Model: NullIfEmpty(parts, 6),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
|
||||
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
|
||||
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
|
||||
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
|
||||
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
|
||||
refresh the equipment's children in place. *@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-id">TagId</label>
|
||||
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="tag-line3-temp-01" />
|
||||
<ValidationMessage For="@(() => _form.TagId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-name">Name</label>
|
||||
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
|
||||
placeholder="Temperature setpoint" />
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-driver">Driver instance</label>
|
||||
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
|
||||
<option value="">— pick a driver —</option>
|
||||
@foreach (var d in Drivers)
|
||||
{
|
||||
<option value="@d.Id">@d.Display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-dtype">Data type</label>
|
||||
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-access">Access level</label>
|
||||
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">WriteIdempotent</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
|
||||
<label class="form-check-label">Safe to retry writes (decision #44–45)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
|
||||
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tag config</label>
|
||||
@{
|
||||
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
|
||||
}
|
||||
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="form-text">Pick a driver above to configure this tag.</div>
|
||||
}
|
||||
else if (IsGalaxyDriver)
|
||||
{
|
||||
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
|
||||
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
|
||||
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@onclick="@(() => _showGalaxyPicker = true)">
|
||||
Browse Galaxy
|
||||
</button>
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
|
||||
<div class="form-text">
|
||||
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
|
||||
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
|
||||
</div>
|
||||
|
||||
@if (_showGalaxyPicker)
|
||||
{
|
||||
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
|
||||
Title="Galaxy address"
|
||||
CurrentAddress="@_galaxyAddress"
|
||||
OnPickAddress="@OnGalaxyAddressPicked">
|
||||
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
|
||||
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
|
||||
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
|
||||
GetConfigJson="@(() => SelectedDriverConfig)" />
|
||||
</DriverTagPicker>
|
||||
}
|
||||
}
|
||||
else if (editorType is not null)
|
||||
{
|
||||
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
|
||||
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
|
||||
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
|
||||
`isHistorized` / `historianTagname`, read by AddressSpaceComposer.ExtractTagHistorize),
|
||||
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
|
||||
composes with the typed editor's driver-specific fields (both preserve unknown keys).
|
||||
Shown for EVERY driver once one is picked. *@
|
||||
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">History</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tag-historize"
|
||||
checked="@_historizeState.IsHistorized"
|
||||
@onchange="OnHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When checked, the server serves OPC UA HistoryRead over this tag's value
|
||||
from the configured historian.
|
||||
</div>
|
||||
@if (_historizeState.IsHistorized)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
|
||||
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
|
||||
value="@_historizeState.HistorianTagname"
|
||||
@onchange="OnHistorianTagnameChanged" />
|
||||
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
|
||||
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
|
||||
with the typed editor's driver-specific fields (both preserve unknown keys). When
|
||||
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
|
||||
driver once one is picked — same place/pattern as the historize control above. *@
|
||||
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Array</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="tag-is-array"
|
||||
checked="@_arrayState.IsArray"
|
||||
@onchange="OnIsArrayChanged" />
|
||||
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When checked, the server materialises this tag as a fixed-length 1-D array node.
|
||||
</div>
|
||||
@if (_arrayState.IsArray)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<label class="form-label" for="tag-array-length">Array length</label>
|
||||
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
|
||||
value="@_arrayState.ArrayLength"
|
||||
@onchange="OnArrayLengthChanged" />
|
||||
<div class="form-text">Number of elements (must be a positive whole number).</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
|
||||
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
|
||||
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
|
||||
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
|
||||
@if (HasNativeAlarm)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Native alarm</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
|
||||
checked="@AlarmHistorizeToAveva"
|
||||
@onchange="OnAlarmHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
|
||||
(the live alerts feed is unaffected). Checked is the default.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary><c>true</c> to create a new tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
|
||||
[Parameter] public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
|
||||
[Parameter] public TagEditDto? Existing { get; set; }
|
||||
|
||||
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
|
||||
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
|
||||
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
|
||||
|
||||
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private FormModel _form = new();
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
|
||||
private bool _showGalaxyPicker;
|
||||
private string _galaxyAddress = "";
|
||||
|
||||
// True when the attribute most-recently selected in the Galaxy picker is itself an alarm
|
||||
// (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the
|
||||
// TagConfig (when none is present) so the operator can author the alarm in one pass.
|
||||
private bool _galaxyPickedIsAlarm;
|
||||
|
||||
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
|
||||
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
|
||||
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
|
||||
private TagHistorizeConfig.HistorizeState _historizeState;
|
||||
|
||||
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
|
||||
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
|
||||
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
|
||||
private TagArrayConfig.ArrayState _arrayState;
|
||||
|
||||
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
|
||||
private string? SelectedDriverType =>
|
||||
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
|
||||
|
||||
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
|
||||
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
|
||||
private string SelectedDriverConfig
|
||||
{
|
||||
get
|
||||
{
|
||||
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
|
||||
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
|
||||
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
|
||||
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
|
||||
|
||||
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
|
||||
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
|
||||
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
|
||||
private void OnDriverChanged()
|
||||
{
|
||||
_form.TagConfig = "{}";
|
||||
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
|
||||
_galaxyAddress = "";
|
||||
_galaxyPickedIsAlarm = false;
|
||||
// The reset TagConfig carries no history intent — reflect that in the historize controls.
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
// Likewise the reset TagConfig carries no array intent.
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
|
||||
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
|
||||
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
|
||||
//
|
||||
// When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default
|
||||
// native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's
|
||||
// "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel
|
||||
// .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName.
|
||||
private void OnGalaxyAddressPicked(string address)
|
||||
{
|
||||
_galaxyAddress = address;
|
||||
// Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING
|
||||
// TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm`
|
||||
// object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array
|
||||
// intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom
|
||||
// as the historize/array merge seams.
|
||||
var config = TagConfigJson.SetFullName(_form.TagConfig, address);
|
||||
_form.TagConfig = _galaxyPickedIsAlarm
|
||||
? NativeAlarmModel.SeedDefaultAlarm(config)
|
||||
: config;
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
["DriverType"] = SelectedDriverType ?? "",
|
||||
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
|
||||
};
|
||||
|
||||
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
|
||||
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
|
||||
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
|
||||
private string? _nativeAlarmSource;
|
||||
|
||||
private NativeAlarmModel NativeAlarm
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_nativeAlarmSource != _form.TagConfig)
|
||||
{
|
||||
_nativeAlarmSource = _form.TagConfig;
|
||||
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||
}
|
||||
return _nativeAlarm;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
|
||||
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
|
||||
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
|
||||
|
||||
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
|
||||
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
|
||||
// explicit false leaves it unchecked.
|
||||
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
|
||||
|
||||
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
|
||||
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
|
||||
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
|
||||
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var model = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||
if (!model.IsAlarm) { return; }
|
||||
model.HistorizeToAveva = e.Value is true ? null : false;
|
||||
_form.TagConfig = model.ToJson();
|
||||
}
|
||||
|
||||
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
|
||||
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
|
||||
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
|
||||
private void OnHistorizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var isHistorized = e.Value is true;
|
||||
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
|
||||
private void OnHistorianTagnameChanged(ChangeEventArgs e)
|
||||
{
|
||||
var tagname = e.Value?.ToString() ?? string.Empty;
|
||||
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
|
||||
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
|
||||
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
|
||||
private void OnIsArrayChanged(ChangeEventArgs e)
|
||||
{
|
||||
var isArray = e.Value is true;
|
||||
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
|
||||
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
|
||||
// an array with no positive length).
|
||||
private void OnArrayLengthChanged(ChangeEventArgs e)
|
||||
{
|
||||
var length = ParsePositiveLength(e.Value?.ToString());
|
||||
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
|
||||
private static uint? ParsePositiveLength(string? raw)
|
||||
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||
if (IsNew)
|
||||
{
|
||||
_form = new FormModel();
|
||||
}
|
||||
else if (Existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
TagId = Existing.TagId,
|
||||
Name = Existing.Name,
|
||||
DriverInstanceId = Existing.DriverInstanceId,
|
||||
DataType = Existing.DataType,
|
||||
AccessLevel = Existing.AccessLevel,
|
||||
WriteIdempotent = Existing.WriteIdempotent,
|
||||
PollGroupId = Existing.PollGroupId,
|
||||
TagConfig = Existing.TagConfig,
|
||||
};
|
||||
}
|
||||
_error = null;
|
||||
_showGalaxyPicker = false;
|
||||
_galaxyPickedIsAlarm = false;
|
||||
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
|
||||
_galaxyAddress = ReadFullName(_form.TagConfig);
|
||||
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
|
||||
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
|
||||
// Seed the array controls from any existing root isArray/arrayLength keys.
|
||||
_arrayState = TagArrayConfig.Read(_form.TagConfig);
|
||||
}
|
||||
|
||||
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
|
||||
private static string ReadFullName(string? configJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return "";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fn)
|
||||
&& fn.ValueKind == JsonValueKind.String
|
||||
? fn.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
// Client-side per-driver config validation (the typed editor's Validate()), so a blank
|
||||
// required field is caught here rather than silently saving and failing at deploy/connect.
|
||||
var configError = TagConfigValidator.Validate(SelectedDriverType, _form.TagConfig);
|
||||
if (configError is not null)
|
||||
{
|
||||
_error = configError;
|
||||
return;
|
||||
}
|
||||
|
||||
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
|
||||
// per-driver config validation above so a missing length is caught here rather than at deploy.
|
||||
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
|
||||
if (arrayError is not null)
|
||||
{
|
||||
_error = arrayError;
|
||||
return;
|
||||
}
|
||||
|
||||
var input = new TagInput(
|
||||
_form.TagId,
|
||||
_form.Name,
|
||||
_form.DriverInstanceId,
|
||||
_form.DataType,
|
||||
_form.AccessLevel,
|
||||
_form.WriteIdempotent,
|
||||
_form.PollGroupId,
|
||||
_form.TagConfig);
|
||||
|
||||
var result = IsNew
|
||||
? await Svc.CreateTagAsync(EquipmentId!, input)
|
||||
: await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion);
|
||||
|
||||
if (result.Ok)
|
||||
{
|
||||
await OnSaved.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Error;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task CancelAsync() => OnCancel.InvokeAsync();
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string DriverInstanceId { get; set; } = "";
|
||||
public string DataType { get; set; } = "Float";
|
||||
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
|
||||
public bool WriteIdempotent { get; set; }
|
||||
public string? PollGroupId { get; set; }
|
||||
[Required] public string TagConfig { get; set; } = "{}";
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
||||
</div>
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptError</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
using ZB.MOM.WW.Secrets.Ui;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
|
||||
@@ -28,6 +30,12 @@ public static class EndpointRouteBuilderExtensions
|
||||
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
|
||||
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
|
||||
// UseAuthentication() — see Program.cs.
|
||||
// The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
|
||||
// this host's router is static with per-page @rendermode opt-in, so routing straight to
|
||||
// the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
|
||||
// Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL
|
||||
// page with the render mode); registering the RCL routes too would make the route
|
||||
// ambiguous.
|
||||
app.MapRazorComponents<TApp>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
return app;
|
||||
@@ -43,12 +51,25 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
services.AddOtOpcUaDriverStatusServices();
|
||||
|
||||
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
|
||||
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
|
||||
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
|
||||
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
|
||||
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
|
||||
// composition layer that already references the RCL) rather than in the core Security lib.
|
||||
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||
|
||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
// v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by
|
||||
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
|
||||
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
|
||||
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
|
||||
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
|
||||
@@ -23,13 +23,16 @@ public interface IScriptTagCatalog
|
||||
/// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
|
||||
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
|
||||
|
||||
/// <summary>Distinct attribute leaf names — the substring after the first dot of each
|
||||
/// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion,
|
||||
/// which needs the per-equipment object prefix stripped.</summary>
|
||||
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param>
|
||||
/// <summary>v3: the owning equipment's <c>UnsTagReference</c> effective names (its
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the resolvable set for
|
||||
/// <c>{{equip}}/<RefName></c> completion + diagnostics, matching what the compose seams substitute
|
||||
/// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has
|
||||
/// no references.</summary>
|
||||
/// <param name="equipmentId">The equipment whose references to list; blank returns empty.</param>
|
||||
/// <param name="filter">Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Distinct leaf names.</returns>
|
||||
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
|
||||
/// <returns>Distinct reference effective names.</returns>
|
||||
Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
|
||||
@@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
var entries = await BuildEntriesAsync(ct);
|
||||
var leaves = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var e in entries)
|
||||
if (string.IsNullOrEmpty(equipmentId)) return Array.Empty<string>();
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
if (refs.Count == 0) return Array.Empty<string>();
|
||||
|
||||
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
|
||||
var tagNameById = await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
|
||||
|
||||
// Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set).
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
var dot = e.Path.IndexOf('.');
|
||||
if (dot < 0 || dot + 1 >= e.Path.Length) continue;
|
||||
leaves.Add(e.Path.Substring(dot + 1));
|
||||
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (!string.IsNullOrEmpty(eff)) names.Add(eff!);
|
||||
}
|
||||
IEnumerable<string> q = leaves;
|
||||
|
||||
IEnumerable<string> q = names;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
public record DiagnoseRequest(string Code);
|
||||
// EquipmentId (optional) carries the owning-equipment context the {{equip}}/<RefName> completion +
|
||||
// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single
|
||||
// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per
|
||||
// owning equipment).
|
||||
public record DiagnoseRequest(string Code, string? EquipmentId = null);
|
||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
|
||||
int EndLineNumber, int EndColumn, string Message, string Code);
|
||||
|
||||
public record CompletionsRequest(string CodeText, int Line, int Column);
|
||||
public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
|
||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
|
||||
|
||||
public record HoverRequest(string CodeText, int Line, int Column);
|
||||
public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
|
||||
public record HoverResponse(string? Markdown);
|
||||
|
||||
public record SignatureHelpRequest(string CodeText, int Line, int Column);
|
||||
|
||||
@@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints
|
||||
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
|
||||
var group = endpoints.MapGroup("/api/script-analysis")
|
||||
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
||||
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
|
||||
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
|
||||
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
|
||||
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
|
||||
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
|
||||
|
||||
@@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
|
||||
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
|
||||
// SAME path literals the compose seams substitute (never a comment / logger string).
|
||||
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
|
||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/<RefName></c> resolution check.
|
||||
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
|
||||
/// are present, appends a marker for each <c>{{equip}}/<RefName></c> whose <c><RefName></c> is
|
||||
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
|
||||
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
||||
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
||||
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
||||
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
||||
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
|
||||
{
|
||||
var baseResp = Diagnose(req);
|
||||
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
|
||||
return baseResp;
|
||||
try
|
||||
{
|
||||
var code = Normalize(req.Code);
|
||||
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return baseResp;
|
||||
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
|
||||
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
|
||||
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
|
||||
{
|
||||
var content = m.Groups[2].Value;
|
||||
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
|
||||
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
|
||||
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
|
||||
// Mark the whole literal content span (the {{equip}}/<RefName> path).
|
||||
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
|
||||
markers.Add(ToUserMarker(code, span,
|
||||
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
|
||||
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
||||
"OTSCRIPT_EQUIPREF"));
|
||||
}
|
||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
|
||||
return baseResp;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Sev(DiagnosticSeverity s) => s switch
|
||||
{
|
||||
DiagnosticSeverity.Error => 8,
|
||||
@@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService
|
||||
|
||||
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
|
||||
{
|
||||
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
|
||||
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
|
||||
// v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
|
||||
// (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
|
||||
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
|
||||
if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
|
||||
pathPrefix.Substring(equipDot.Length), CancellationToken.None);
|
||||
return new CompletionsResponse(leaves
|
||||
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
|
||||
if (string.IsNullOrEmpty(req.EquipmentId))
|
||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
||||
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
|
||||
return new CompletionsResponse(names
|
||||
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService
|
||||
{
|
||||
return new HoverResponse(
|
||||
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
|
||||
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
|
||||
"`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
|
||||
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
|
||||
}
|
||||
|
||||
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IEffectiveNameGuard"/>. Loads the owning equipment's current effective-name
|
||||
/// set — references (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>),
|
||||
/// VirtualTags (<c>Name</c>), and ScriptedAlarms (<c>Name</c>) — and reports the first collision
|
||||
/// with a proposed name. Comparison is <see cref="StringComparer.Ordinal"/> to match the
|
||||
/// <c>{EquipmentId}/{EffectiveName}</c> NodeId semantics and the deploy-time
|
||||
/// <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each call creates and disposes its own pooled context — the same per-call pattern
|
||||
/// <see cref="UnsTreeService"/> uses — so the guard is safe to register <c>Scoped</c>.
|
||||
/// </remarks>
|
||||
public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IEffectiveNameGuard
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> CheckAsync(
|
||||
string equipmentId,
|
||||
string proposedEffectiveName,
|
||||
EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// References for this equipment. The effective name is the display-name override else the
|
||||
// backing raw tag's current Name, so the two sets (references + tags) are loaded and joined
|
||||
// in memory — the per-equipment set is small and this mirrors the deploy rule exactly
|
||||
// (a reference whose backing tag is missing contributes no effective name, so it is skipped).
|
||||
var references = await db.UnsTagReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList();
|
||||
var tagNameById = (await db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
|
||||
foreach (var r in references)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId))
|
||||
continue;
|
||||
var effective = r.DisplayNameOverride
|
||||
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (Collides(effective, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId);
|
||||
}
|
||||
|
||||
var virtualTags = await db.VirtualTags
|
||||
.AsNoTracking()
|
||||
.Where(v => v.EquipmentId == equipmentId)
|
||||
.Select(v => new { v.VirtualTagId, v.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var v in virtualTags)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId))
|
||||
continue;
|
||||
if (Collides(v.Name, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId);
|
||||
}
|
||||
|
||||
var scriptedAlarms = await db.ScriptedAlarms
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EquipmentId == equipmentId)
|
||||
.Select(a => new { a.ScriptedAlarmId, a.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var a in scriptedAlarms)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId))
|
||||
continue;
|
||||
if (Collides(a.Name, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when a row is the self-row being edited — same kind AND same logical id — so it
|
||||
/// is excluded from the collision set (a rename / override-change of a row cannot collide with itself).</summary>
|
||||
private static bool IsSelf(
|
||||
EffectiveNameSourceKind rowKind,
|
||||
string rowId,
|
||||
EffectiveNameSourceKind editedKind,
|
||||
string? excludeSourceId) =>
|
||||
excludeSourceId is not null
|
||||
&& rowKind == editedKind
|
||||
&& string.Equals(rowId, excludeSourceId, StringComparison.Ordinal);
|
||||
|
||||
private static bool Collides(string? existing, string proposed) =>
|
||||
existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal);
|
||||
|
||||
private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) =>
|
||||
$"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'";
|
||||
}
|
||||
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
|
||||
|
||||
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
|
||||
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
|
||||
/// row projects a <see cref="Configuration.Entities.UnsTagReference"/> together with the backing raw
|
||||
/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
|
||||
/// <c>DisplayNameOverride</c> when set, else the backing raw tag's <c>Name</c>. <c>RowVersion</c> is the
|
||||
/// reference row's concurrency token, echoed on the override-set / remove mutations.
|
||||
/// </summary>
|
||||
/// <param name="UnsTagReferenceId">The reference's stable logical id (the mutation key).</param>
|
||||
/// <param name="EffectiveName">Override else the raw tag's Name — the UNS leaf name.</param>
|
||||
/// <param name="RawPath">The backing raw tag's computed RawPath (folder/driver/device/group/tag).</param>
|
||||
/// <param name="DataType">Inherited OPC UA built-in type name from the raw tag (read-only).</param>
|
||||
/// <param name="AccessLevel">Inherited access level from the raw tag (read-only).</param>
|
||||
/// <param name="DisplayNameOverride">The optional display-name override; <c>null</c> = use the raw name.</param>
|
||||
/// <param name="SortOrder">Sibling display ordering within the equipment's Tags list.</param>
|
||||
/// <param name="RowVersion">The reference row's optimistic-concurrency token.</param>
|
||||
public sealed record EquipmentReferenceRow(
|
||||
string UnsTagReferenceId,
|
||||
string EffectiveName,
|
||||
string RawPath,
|
||||
string DataType,
|
||||
TagAccessLevel AccessLevel,
|
||||
string? DisplayNameOverride,
|
||||
int SortOrder,
|
||||
byte[] RowVersion);
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
|
||||
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
|
||||
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
|
||||
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
|
||||
/// <param name="ZTag">Optional ERP equipment id.</param>
|
||||
/// <param name="SAPID">Optional SAP PM equipment id.</param>
|
||||
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
|
||||
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
|
||||
string Name,
|
||||
string MachineCode,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId,
|
||||
string? ZTag,
|
||||
string? SAPID,
|
||||
string? Manufacturer,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Which authoring surface a proposed effective name comes from. Used to word the collision
|
||||
/// error and to exclude the correct same-kind self-row on an edit (rename / override change).
|
||||
/// </summary>
|
||||
public enum EffectiveNameSourceKind
|
||||
{
|
||||
/// <summary>A <see cref="Configuration.Entities.UnsTagReference"/> (effective name =
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>).</summary>
|
||||
Reference,
|
||||
|
||||
/// <summary>An equipment-bound <see cref="Configuration.Entities.VirtualTag"/> (effective name = <c>Name</c>).</summary>
|
||||
VirtualTag,
|
||||
|
||||
/// <summary>An equipment-bound <see cref="Configuration.Entities.ScriptedAlarm"/> (effective name = <c>Name</c>).</summary>
|
||||
ScriptedAlarm,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 3 authoring-time guard for the UNS effective-name uniqueness rule. Within an
|
||||
/// equipment the EFFECTIVE leaf name must be unique across its <c>UnsTagReferences</c>
|
||||
/// (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>), its <c>VirtualTags</c>
|
||||
/// (<c>Name</c>), and its <c>ScriptedAlarms</c> (<c>Name</c>) — the three share the equipment's
|
||||
/// UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>). This is the authoring mirror of the
|
||||
/// deploy-time <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule; comparison is
|
||||
/// <see cref="StringComparer.Ordinal"/> to match NodeId semantics and the validator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Injectable (registered <c>Scoped</c>, its own pooled context per call — the same pattern
|
||||
/// <c>UnsTreeService</c> uses). Consumed by <c>UnsTreeService</c>'s reference / virtual-tag /
|
||||
/// scripted-alarm mutations, which call <see cref="CheckAsync"/> before persisting and surface a
|
||||
/// non-null result as the mutation's readable failure. The deploy gate remains the backstop for
|
||||
/// rename-induced collisions the authoring check never saw.
|
||||
/// </remarks>
|
||||
public interface IEffectiveNameGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests whether <paramref name="proposedEffectiveName"/> would collide with an existing
|
||||
/// effective name within <paramref name="equipmentId"/>.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment's logical id.</param>
|
||||
/// <param name="proposedEffectiveName">The effective name to be authored (override else raw name for a reference; Name for a VT/alarm).</param>
|
||||
/// <param name="kind">Which surface the proposed name is for (words the error; identifies the self-row kind to exclude).</param>
|
||||
/// <param name="excludeSourceId">
|
||||
/// On an edit, the logical id of the row being changed (its <c>UnsTagReferenceId</c> /
|
||||
/// <c>VirtualTagId</c> / <c>ScriptedAlarmId</c>) so it is excluded from the collision set;
|
||||
/// <see langword="null"/> on a create.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// <see langword="null"/> when the name is free; otherwise a readable error naming the
|
||||
/// colliding existing source and the equipment.
|
||||
/// </returns>
|
||||
Task<string?> CheckAsync(
|
||||
string equipmentId,
|
||||
string proposedEffectiveName,
|
||||
EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -139,6 +139,82 @@ public interface IUnsTreeService
|
||||
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
|
||||
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
|
||||
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
|
||||
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
|
||||
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
|
||||
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
|
||||
/// equipment has no references.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose references to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The equipment's reference rows; empty if it has none.</returns>
|
||||
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
|
||||
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
|
||||
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
|
||||
/// override on add) must be unique within the equipment across references, VirtualTags, and
|
||||
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
|
||||
/// the equipment is rejected. The batch is all-or-nothing.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The referencing equipment.</param>
|
||||
/// <param name="tagIds">The raw tag ids to reference.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
|
||||
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="unsTagReferenceId">The reference to remove.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
|
||||
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
|
||||
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
|
||||
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
|
||||
/// optimistic concurrency.
|
||||
/// </summary>
|
||||
/// <param name="unsTagReferenceId">The reference to update.</param>
|
||||
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a name-collision failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
|
||||
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
|
||||
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
|
||||
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
|
||||
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
|
||||
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
|
||||
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
|
||||
/// </summary>
|
||||
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
|
||||
/// <param name="ct">A token to cancel the query.</param>
|
||||
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
|
||||
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
|
||||
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
|
||||
@@ -284,24 +360,22 @@ public interface IUnsTreeService
|
||||
/// <summary>
|
||||
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
|
||||
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
|
||||
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
|
||||
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
|
||||
/// collapse to <c>null</c>.
|
||||
/// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
|
||||
/// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
|
||||
/// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
|
||||
/// </summary>
|
||||
/// <param name="input">The operator-editable equipment fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns>
|
||||
/// <returns>Success, a missing-line failure, or a duplicate-MachineCode failure.</returns>
|
||||
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
|
||||
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
|
||||
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
|
||||
/// a driver-bound row whose driver is in a different cluster than its line fails the cluster
|
||||
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
|
||||
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
|
||||
/// exist is an error; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier
|
||||
/// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a
|
||||
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
|
||||
/// the end.
|
||||
/// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
|
||||
/// </summary>
|
||||
/// <param name="rows">The parsed equipment rows to import.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
@@ -309,16 +383,16 @@ public interface IUnsTreeService
|
||||
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
|
||||
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks
|
||||
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
|
||||
/// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010
|
||||
/// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
|
||||
/// no longer binds a driver, so there is no driver-cluster guard.)
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to update.</param>
|
||||
/// <param name="input">The new operator-editable equipment fields.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
|
||||
/// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and
|
||||
/// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname /
|
||||
/// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here.
|
||||
/// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and
|
||||
/// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved).
|
||||
/// </summary>
|
||||
private readonly record struct AffectedTag(
|
||||
string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the
|
||||
/// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal —
|
||||
/// changes with the rename): (a) historized tags WITHOUT a <c>historianTagname</c> override (their
|
||||
/// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected),
|
||||
/// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any
|
||||
/// <see cref="Script"/> body (tolerates false positives by design — no AST).
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct)
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (tags.Count == 0) return warnings;
|
||||
|
||||
var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized);
|
||||
if (historized > 0)
|
||||
// (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is
|
||||
// moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn.
|
||||
var forking = tags.Count(t =>
|
||||
{
|
||||
warnings.Add(historized == 1
|
||||
? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path."
|
||||
: $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths.");
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
return intent.IsHistorized && intent.HistorianTagname is null;
|
||||
});
|
||||
if (forking > 0)
|
||||
{
|
||||
warnings.Add(forking == 1
|
||||
? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it."
|
||||
: $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them.");
|
||||
}
|
||||
|
||||
// (b) UNS-referenced: name the referencing equipment.
|
||||
var tagIds = tags.Select(t => t.TagId).ToList();
|
||||
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => tagIds.Contains(r.TagId))
|
||||
@@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
|
||||
}
|
||||
|
||||
// Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append
|
||||
// a "N script(s) reference tags beneath this node" warning to this same list.
|
||||
// (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths.
|
||||
var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct);
|
||||
if (scriptWarning is not null) warnings.Add(scriptWarning);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
|
||||
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
|
||||
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
|
||||
/// naming the matched scripts, or <see langword="null"/> when none match.
|
||||
/// </summary>
|
||||
private static async Task<string?> BuildScriptReferenceWarningAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
|
||||
{
|
||||
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
|
||||
if (oldPaths.Count == 0) return null;
|
||||
|
||||
var scripts = await db.Scripts.AsNoTracking()
|
||||
.Select(s => new { s.ScriptId, s.Name, s.SourceCode })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var matched = scripts
|
||||
.Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(s => s.Name)
|
||||
.ToList();
|
||||
if (matched.Count == 0) return null;
|
||||
|
||||
var names = string.Join(", ", matched.Select(n => $"'{n}'"));
|
||||
return matched.Count == 1
|
||||
? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
|
||||
: $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
|
||||
/// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
|
||||
/// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups.
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
|
||||
{
|
||||
var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
var deviceRows = await db.Devices.AsNoTracking()
|
||||
.Where(d => deviceIds.Contains(d.DeviceId))
|
||||
.Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList();
|
||||
var driverRows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => driverIds.Contains(d.DriverInstanceId))
|
||||
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList();
|
||||
var folderRows = await db.RawFolders.AsNoTracking()
|
||||
.Where(f => clusterIds.Contains(f.ClusterId))
|
||||
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var groupRows = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => deviceIds.Contains(g.DeviceId))
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var folders = folderRows.ToDictionary(
|
||||
f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
|
||||
var drivers = driverRows.ToDictionary(
|
||||
d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
|
||||
var devices = deviceRows.ToDictionary(
|
||||
d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal);
|
||||
var groups = groupRows.ToDictionary(
|
||||
g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
var paths = new List<string>(tags.Count);
|
||||
foreach (var t in tags)
|
||||
{
|
||||
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
if (path is not null) paths.Add(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
|
||||
private static async Task<string> DescribeEquipmentAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
|
||||
@@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
|
||||
|
||||
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync(
|
||||
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathFolderAsync(
|
||||
OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
|
||||
{
|
||||
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
|
||||
if (folder is null) return Array.Empty<(string, string)>();
|
||||
if (folder is null) return Array.Empty<AffectedTag>();
|
||||
|
||||
// All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
|
||||
var clusterFolders = await db.RawFolders.AsNoTracking()
|
||||
@@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToListAsync(ct);
|
||||
if (driverIds.Count == 0) return Array.Empty<(string, string)>();
|
||||
if (driverIds.Count == 0) return Array.Empty<AffectedTag>();
|
||||
|
||||
var deviceIds = await db.Devices.AsNoTracking()
|
||||
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
|
||||
.Select(dev => dev.DeviceId)
|
||||
.ToListAsync(ct);
|
||||
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
|
||||
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
|
||||
|
||||
return await TagsForDevicesAsync(db, deviceIds, ct);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync(
|
||||
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDriverAsync(
|
||||
OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
|
||||
{
|
||||
var deviceIds = await db.Devices.AsNoTracking()
|
||||
.Where(dev => dev.DriverInstanceId == driverInstanceId)
|
||||
.Select(dev => dev.DeviceId)
|
||||
.ToListAsync(ct);
|
||||
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
|
||||
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
|
||||
return await TagsForDevicesAsync(db, deviceIds, ct);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync(
|
||||
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDeviceAsync(
|
||||
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
|
||||
{
|
||||
return (await db.Tags.AsNoTracking()
|
||||
.Where(t => t.DeviceId == deviceId)
|
||||
.Select(t => new { t.TagId, t.TagConfig })
|
||||
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.Select(t => (t.TagId, t.TagConfig))
|
||||
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync(
|
||||
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathGroupAsync(
|
||||
OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
|
||||
{
|
||||
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
||||
if (group is null) return Array.Empty<(string, string)>();
|
||||
if (group is null) return Array.Empty<AffectedTag>();
|
||||
|
||||
var deviceGroups = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == group.DeviceId)
|
||||
@@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
return (await db.Tags.AsNoTracking()
|
||||
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
|
||||
.Select(t => new { t.TagId, t.TagConfig })
|
||||
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.Select(t => (t.TagId, t.TagConfig))
|
||||
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync(
|
||||
private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
|
||||
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
|
||||
{
|
||||
return (await db.Tags.AsNoTracking()
|
||||
.Where(t => deviceIds.Contains(t.DeviceId))
|
||||
.Select(t => new { t.TagId, t.TagConfig })
|
||||
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.Select(t => (t.TagId, t.TagConfig))
|
||||
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
|
||||
/// Blazor circuit.
|
||||
/// </remarks>
|
||||
public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IUnsTreeService
|
||||
public sealed class UnsTreeService(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
IEffectiveNameGuard? guard = null) : IUnsTreeService
|
||||
{
|
||||
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
|
||||
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
|
||||
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
|
||||
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
|
||||
// db factory. Tests that DO exercise the guard pass a fake.
|
||||
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -97,6 +106,371 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
|
||||
string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.UnsTagReferenceId,
|
||||
r.TagId,
|
||||
r.DisplayNameOverride,
|
||||
r.SortOrder,
|
||||
r.RowVersion,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();
|
||||
|
||||
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
|
||||
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
|
||||
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
|
||||
var tags = (await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
|
||||
|
||||
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
|
||||
|
||||
var rows = new List<EquipmentReferenceRow>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (!tags.TryGetValue(r.TagId, out var tag))
|
||||
{
|
||||
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
|
||||
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
|
||||
rows.Add(new EquipmentReferenceRow(
|
||||
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
|
||||
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
|
||||
continue;
|
||||
}
|
||||
|
||||
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
|
||||
rows.Add(new EquipmentReferenceRow(
|
||||
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
|
||||
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> AddReferencesAsync(
|
||||
string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagIds);
|
||||
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (equipmentCluster is null)
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
|
||||
|
||||
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
|
||||
var tags = await db.Tags.AsNoTracking()
|
||||
.Where(t => distinctTagIds.Contains(t.TagId))
|
||||
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
|
||||
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
|
||||
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
|
||||
|
||||
// Existing references on the equipment (to reject re-referencing the same tag).
|
||||
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => r.TagId)
|
||||
.ToListAsync(ct))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var maxSort = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => (int?)r.SortOrder)
|
||||
.MaxAsync(ct) ?? -1;
|
||||
|
||||
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
|
||||
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
|
||||
var batchNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var pending = new List<UnsTagReference>();
|
||||
|
||||
foreach (var tagId in distinctTagIds)
|
||||
{
|
||||
if (!tagById.TryGetValue(tagId, out var tag))
|
||||
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
|
||||
|
||||
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
|
||||
return new UnsMutationResult(false,
|
||||
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
|
||||
|
||||
if (alreadyReferenced.Contains(tagId))
|
||||
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
|
||||
|
||||
// Effective name at add = the raw tag's Name (no override yet).
|
||||
if (!batchNames.Add(tag.Name))
|
||||
return new UnsMutationResult(false,
|
||||
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
|
||||
|
||||
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
|
||||
if (collision is not null) return new UnsMutationResult(false, collision);
|
||||
|
||||
pending.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
|
||||
EquipmentId = equipmentId,
|
||||
TagId = tagId,
|
||||
DisplayNameOverride = null,
|
||||
SortOrder = ++maxSort,
|
||||
});
|
||||
}
|
||||
|
||||
db.UnsTagReferences.AddRange(pending);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// The UX_UnsTagReference_Equip_Tag unique index tripped: a concurrent add slipped a
|
||||
// same-(equipment,tag) row past the pre-check. (Effective-name collisions have no DB
|
||||
// uniqueness — those are caught by the authoring guard and the deploy gate, not here.)
|
||||
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> RemoveReferenceAsync(
|
||||
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(true, null);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.UnsTagReferences.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> SetReferenceOverrideAsync(
|
||||
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
// Effective name = the override when set, else the backing raw tag's Name.
|
||||
var rawName = await db.Tags.AsNoTracking()
|
||||
.Where(t => t.TagId == entity.TagId)
|
||||
.Select(t => t.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var effectiveName = normalized ?? rawName ?? string.Empty;
|
||||
|
||||
var collision = await _guard.CheckAsync(
|
||||
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
|
||||
if (collision is not null) return new UnsMutationResult(false, collision);
|
||||
|
||||
entity.DisplayNameOverride = normalized;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (clusterId is null) return null;
|
||||
|
||||
var cluster = await db.ServerClusters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
if (cluster is null) return null;
|
||||
|
||||
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
|
||||
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
|
||||
var rootFolders = await db.RawFolders.AsNoTracking()
|
||||
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
|
||||
var rootDrivers = await db.DriverInstances.AsNoTracking()
|
||||
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
|
||||
|
||||
return new RawNode
|
||||
{
|
||||
Kind = RawNodeKind.Cluster,
|
||||
Key = $"clu:{clusterId}",
|
||||
DisplayName = cluster.Name,
|
||||
ClusterId = clusterId,
|
||||
EntityId = clusterId,
|
||||
HasLazyChildren = true,
|
||||
ChildCount = rootFolders + rootDrivers,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
if (node.EntityId is null) return Array.Empty<string>();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
switch (node.Kind)
|
||||
{
|
||||
case RawNodeKind.Device:
|
||||
// Every tag under the device (across all its tag groups).
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.DeviceId == node.EntityId)
|
||||
.Select(t => t.TagId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
case RawNodeKind.TagGroup:
|
||||
{
|
||||
// The group + all descendant groups, then every tag in that group set.
|
||||
var group = await db.TagGroups.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
|
||||
if (group is null) return Array.Empty<string>();
|
||||
|
||||
var deviceGroups = await db.TagGroups.AsNoTracking()
|
||||
.Where(g => g.DeviceId == group.DeviceId)
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var childrenByParent = deviceGroups
|
||||
.Where(g => g.ParentTagGroupId is not null)
|
||||
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
|
||||
|
||||
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
|
||||
.Select(t => t.TagId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
default:
|
||||
// The picker only offers "select all" on Device / TagGroup containers.
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
|
||||
private static HashSet<string> DescendantGroupsInclusive(
|
||||
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(rootId);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var id = stack.Pop();
|
||||
if (!result.Add(id)) continue;
|
||||
if (childrenByParent.TryGetValue(id, out var kids))
|
||||
{
|
||||
foreach (var kid in kids) stack.Push(kid);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
|
||||
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
|
||||
/// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
|
||||
/// </summary>
|
||||
private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
|
||||
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clusterId)) return null;
|
||||
|
||||
var folders = (await db.RawFolders.AsNoTracking()
|
||||
.Where(f => f.ClusterId == clusterId)
|
||||
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
|
||||
|
||||
var drivers = (await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == clusterId)
|
||||
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
|
||||
|
||||
var driverIds = drivers.Keys.ToList();
|
||||
|
||||
var devices = (await db.Devices.AsNoTracking()
|
||||
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
|
||||
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
|
||||
|
||||
var deviceIds = devices.Keys.ToList();
|
||||
|
||||
var groups = (await db.TagGroups.AsNoTracking()
|
||||
.Where(g => deviceIds.Contains(g.DeviceId))
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
|
||||
|
||||
return new RawPathResolver(folders, drivers, devices, groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
|
||||
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
|
||||
/// backstop. Production always injects the real WP2 guard.
|
||||
/// </summary>
|
||||
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
|
||||
{
|
||||
public static readonly NoOpEffectiveNameGuard Instance = new();
|
||||
|
||||
public Task<string?> CheckAsync(
|
||||
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -446,11 +820,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
// v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
|
||||
// the old decision-#122 driver-cluster guard is gone.
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
@@ -514,14 +885,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a
|
||||
// driver/line cluster mismatch, and is a no-op for driver-less rows.
|
||||
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
|
||||
continue;
|
||||
}
|
||||
// v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
@@ -578,11 +942,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
// v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
|
||||
|
||||
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
|
||||
entity.UnsLineId = input.UnsLineId;
|
||||
@@ -866,29 +1226,82 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
|
||||
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
|
||||
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
|
||||
/// base exists). The script source is fetched from the supplied (in-scope) context.
|
||||
/// v3 (M1): when the bound script uses <c>{{equip}}/<RefName></c>, every <c><RefName></c> must
|
||||
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
|
||||
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
|
||||
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
|
||||
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
|
||||
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
|
||||
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
|
||||
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
|
||||
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
|
||||
if (used.Count == 0) return null;
|
||||
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
|
||||
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
|
||||
}
|
||||
|
||||
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver
|
||||
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here.
|
||||
var fullNames = Array.Empty<string>();
|
||||
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
|
||||
/// <summary>
|
||||
/// Scripted-alarm variant of <see cref="ValidateEquipTokenAsync"/>: validates <c>{{equip}}/<RefName></c>
|
||||
/// tokens in BOTH the predicate script AND the message template against the equipment's reference set —
|
||||
/// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates).
|
||||
/// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> ValidateEquipTokenForAlarmAsync(
|
||||
OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrEmpty(predicateScriptId))
|
||||
{
|
||||
return new UnsMutationResult(false,
|
||||
$"Equipment '{equipmentId}' has no single tag base, so the "
|
||||
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
|
||||
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
|
||||
var src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId)
|
||||
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
|
||||
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u);
|
||||
}
|
||||
return null;
|
||||
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u);
|
||||
if (used.Count == 0) return null;
|
||||
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
|
||||
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
|
||||
}
|
||||
|
||||
/// <summary>The equipment's reference effective-name set: <c>DisplayNameOverride</c> else the backing raw
|
||||
/// tag's <c>Name</c> (empty override treated as absent, matching <c>EquipmentReferenceMap.Build</c>), ordinal.</summary>
|
||||
private static async Task<HashSet<string>> LoadEquipReferenceNamesAsync(
|
||||
OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct)
|
||||
{
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
var tagIds = refs.Select(r => r.TagId).ToList();
|
||||
var tagNameById = await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
|
||||
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
var eff = string.IsNullOrEmpty(r.DisplayNameOverride)
|
||||
? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null)
|
||||
: r.DisplayNameOverride;
|
||||
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
|
||||
}
|
||||
return effectiveNames;
|
||||
}
|
||||
|
||||
/// <summary>Names the unresolved <c>{{equip}}/<RefName></c> tokens (or null when all resolve).</summary>
|
||||
private static UnsMutationResult? CheckEquipReferencesResolve(
|
||||
IReadOnlyCollection<string> used, HashSet<string> effectiveNames, string equipmentId)
|
||||
{
|
||||
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
|
||||
if (missing.Count == 0) return null;
|
||||
return new UnsMutationResult(false,
|
||||
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
|
||||
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
|
||||
+ "Add a matching reference on the Tags tab, or correct the script.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -920,6 +1333,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
|
||||
// alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
|
||||
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -969,6 +1387,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
|
||||
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
@@ -1193,58 +1615,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Policy:
|
||||
/// <list type="bullet">
|
||||
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
|
||||
/// <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
|
||||
/// line cannot guarantee cluster alignment, so the bind is unsafe).</item>
|
||||
/// <item>Driver bound, line resolves, clusters differ → rejected.</item>
|
||||
/// <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
EquipmentInput input,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
|
||||
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
|
||||
var lineCluster = area?.ClusterId;
|
||||
|
||||
if (lineCluster is null)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
|
||||
}
|
||||
|
||||
var driverCluster = await db.DriverInstances
|
||||
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
|
||||
.Select(d => d.ClusterId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (driverCluster is null)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
|
||||
}
|
||||
|
||||
if (driverCluster != lineCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -1277,6 +1647,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
|
||||
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
|
||||
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = input.ScriptedAlarmId,
|
||||
@@ -1307,6 +1685,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
|
||||
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
|
||||
|
||||
// v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
|
||||
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
|
||||
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
|
||||
|
||||
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
|
||||
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
|
||||
if (equipGuard is not null) return equipGuard.Value;
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = input.Name;
|
||||
entity.AlarmType = input.AlarmType;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
|
||||
|
||||
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
|
||||
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
|
||||
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
|
||||
function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; }
|
||||
|
||||
function registerCSharpProviders() {
|
||||
monaco.languages.registerCompletionItemProvider("csharp", {
|
||||
triggerCharacters: [".", "(", "\""],
|
||||
@@ -31,7 +36,7 @@
|
||||
const resp = await fetch("/api/script-analysis/completions", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return { suggestions: [] };
|
||||
const data = await resp.json();
|
||||
@@ -72,7 +77,7 @@
|
||||
const resp = await fetch("/api/script-analysis/hover", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
@@ -149,7 +154,7 @@
|
||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue() })
|
||||
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
@@ -192,6 +197,9 @@
|
||||
// inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space.
|
||||
quickSuggestions: { other: true, comments: false, strings: true }
|
||||
});
|
||||
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
|
||||
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
||||
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
||||
let diagTimer = null;
|
||||
const scheduleDiagnostics = function () {
|
||||
if (diagTimer) clearTimeout(diagTimer);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Forces this node's secret-replication actor into existence at startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The replication actor is created <b>lazily</b>, on the first resolution of
|
||||
/// <see cref="ISecretStore"/> — resolving the store builds <c>ReplicatingSecretStore</c>,
|
||||
/// which takes <c>ISecretReplicator</c>, whose factory touches
|
||||
/// <c>SecretReplicationActorProvider.ActorRef</c> and thereby spawns the actor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Laziness is correct for the library (the <c>ActorSystem</c> is usually registered after
|
||||
/// the replication call), but it makes participation in anti-entropy accidental: a node that
|
||||
/// happens never to read or write a secret — a plausible steady state for a driver-role node
|
||||
/// whose driver configs carry no <c>${secret:}</c> references — would never create the actor,
|
||||
/// never announce its manifest, and never converge. Nothing would fail; it would just
|
||||
/// silently not replicate. Resolving the store once at startup makes participation
|
||||
/// unconditional.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>History — this hook has met two library defects, both fixed.</b> Against 0.2.0 it was
|
||||
/// inert: the package never bound its own <c>ISecretReplicator</c> (TryAdd registration
|
||||
/// order), so this resolve built a <c>ReplicatingSecretStore</c> around a no-op sink and
|
||||
/// spawned no actor. Against 0.2.1 it was worse: the resolve <b>deadlocked the host at
|
||||
/// startup</b> — the package's DI graph had a circular singleton dependency, invisible to
|
||||
/// the container through factory lambdas (scadaproj#1). Fixed in 0.2.2 by deferring the
|
||||
/// cycle-closing invalidator edge;
|
||||
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>
|
||||
/// exercises this hook against a built provider on a real single-node cluster.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
|
||||
public sealed class SecretReplicationStarter(IServiceProvider services) : IHostedService
|
||||
{
|
||||
/// <summary>Resolves <see cref="ISecretStore"/>, which spawns the replication actor.</summary>
|
||||
/// <param name="cancellationToken">Unused — resolution is synchronous and non-blocking.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The resolution itself is the side effect; the instance is deliberately unused.
|
||||
_ = services.GetRequiredService<ISecretStore>();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>No-op: the actor's lifetime is the actor system's.</summary>
|
||||
/// <param name="cancellationToken">Unused.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Single registration point for the secrets subsystem on every OtOpcUa node, admin- and
|
||||
/// driver-role alike. Exists as a named extension rather than an inline <c>AddZbSecrets</c> call
|
||||
/// in <c>Program.cs</c> so the choice of <c>ISecretStore</c> implementation is covered by a DI
|
||||
/// resolution test — <c>Program.cs</c> is top-level statements and cannot be exercised directly,
|
||||
/// which is exactly how a "registered but never resolvable" wiring defect ships unnoticed.
|
||||
/// </summary>
|
||||
public static class SecretsRegistration
|
||||
{
|
||||
/// <summary>Configuration key gating cluster secret replication. Absent or false = off.</summary>
|
||||
public const string ReplicationEnabledKey = "Secrets:Replication:Enabled";
|
||||
|
||||
/// <summary>Configuration section holding <c>AkkaSecretsReplicationOptions</c>.</summary>
|
||||
public const string ReplicationSectionPath = "Secrets:Replication";
|
||||
|
||||
/// <summary>Configuration section holding the core secrets options.</summary>
|
||||
public const string SecretsSectionPath = "Secrets";
|
||||
|
||||
/// <summary>
|
||||
/// True when cluster secret replication is switched on in configuration. Defaults to false —
|
||||
/// absent configuration must mean "off".
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns><c>true</c> when replication is enabled.</returns>
|
||||
public static bool IsReplicationEnabled(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return configuration.GetValue(ReplicationEnabledKey, defaultValue: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the secrets subsystem. Replication is <b>opt-in</b>: unless
|
||||
/// <see cref="ReplicationEnabledKey"/> is true this is exactly the plain local SQLite wiring
|
||||
/// the host has always used.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gate is deliberately default-deny. This call decides which <c>ISecretStore</c> the
|
||||
/// container hands to <b>every</b> node, including driver-role nodes with no auth or
|
||||
/// AdminUI surface, where a bad store surfaces as drivers failing to open sessions rather
|
||||
/// than as a failing test. Enabling replication by default would change secret resolution
|
||||
/// across a production fleet with nobody having asked for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> internally, so the two
|
||||
/// branches are alternatives, never additive — calling both would double-register.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaSecrets(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
if (!IsReplicationEnabled(configuration))
|
||||
{
|
||||
services.AddZbSecrets(configuration, SecretsSectionPath);
|
||||
return services;
|
||||
}
|
||||
|
||||
services.AddZbSecretsAkkaReplication(configuration, SecretsSectionPath, ReplicationSectionPath);
|
||||
|
||||
// Anti-entropy participation must not hinge on this node happening to touch a secret — see
|
||||
// SecretReplicationStarter for why the library's lazy actor creation is insufficient here.
|
||||
services.AddSingleton<SecretReplicationStarter>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<SecretReplicationStarter>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
|
||||
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
|
||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||
Register(registry, loggerFactory, scriptRoot);
|
||||
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
|
||||
private static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Configuration;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -72,6 +78,27 @@ if (roleSuffix is not null)
|
||||
builder.Configuration.AddCommandLine(args);
|
||||
}
|
||||
|
||||
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
|
||||
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
|
||||
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
|
||||
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
|
||||
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
|
||||
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
|
||||
// it also migrates the secrets SQLite store on startup.
|
||||
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
|
||||
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
|
||||
#pragma warning disable ASP0000
|
||||
await using (var secretsProvider = new ServiceCollection()
|
||||
.AddZbSecrets(builder.Configuration, "Secrets")
|
||||
.BuildServiceProvider())
|
||||
#pragma warning restore ASP0000
|
||||
{
|
||||
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
|
||||
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
||||
await new SecretReferenceExpander(resolver)
|
||||
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
|
||||
}
|
||||
|
||||
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
||||
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
||||
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
||||
@@ -287,6 +314,14 @@ if (hasDriver)
|
||||
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||
{
|
||||
ab.WithOtOpcUaClusterBootstrap(sp);
|
||||
// Secret-replication wire protocol → its own serializer, merged only when replication is on so a
|
||||
// non-replicating node carries no serializer bindings for messages it will never see. Appended
|
||||
// (Akka.Hosting's fallback merge — the same mode WithOtOpcUaClusterBootstrap uses for the base
|
||||
// config) rather than a raw Config.WithFallback, which would fight the builder's own assembly.
|
||||
// Without this the protocol DTOs would round-trip on Akka's default JSON serializer, leaving the
|
||||
// serializer that carries secret ciphertext an inherited default rather than an explicit choice.
|
||||
if (SecretsRegistration.IsReplicationEnabled(builder.Configuration))
|
||||
ab.AddHocon(AkkaSecretsReplication.SerializationConfig, HoconAddMode.Append);
|
||||
if (hasAdmin)
|
||||
{
|
||||
ab.WithOtOpcUaControlPlaneSingletons();
|
||||
@@ -323,6 +358,10 @@ if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
|
||||
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||
// Cluster replication is opt-in behind Secrets:Replication:Enabled (default false) — see SecretsRegistration.
|
||||
builder.Services.AddOtOpcUaSecrets(builder.Configuration);
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,12 +11,27 @@
|
||||
"DisableLogin": false
|
||||
}
|
||||
},
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": {
|
||||
"Source": "Environment",
|
||||
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||
},
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30",
|
||||
"Replication": {
|
||||
"_comment": "Peer-to-peer secret replication over the Akka cluster. OPT-IN: Enabled=false keeps the plain local SQLite store on every node. KNOWN NON-FUNCTIONAL against ZB.MOM.WW.Secrets.Replicator.AkkaDotNet 0.2.0 — that version's ISecretReplicator registration is shadowed by the NoOp one AddZbSecrets registers first, so enabling this decorates the store but replicates nothing and starts no actor. Do not enable until the library is fixed. All nodes must also share the same KEK.",
|
||||
"Enabled": false,
|
||||
"AnnounceInterval": "00:00:30",
|
||||
"ActorName": "zb-secret-replication"
|
||||
}
|
||||
},
|
||||
"ServerHistorian": {
|
||||
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
|
||||
"Enabled": false,
|
||||
"Endpoint": "",
|
||||
"ApiKey": "",
|
||||
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.",
|
||||
"_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
|
||||
"UseTls": true,
|
||||
"AllowUntrustedServerCertificate": false,
|
||||
"CaCertificatePath": null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
|
||||
var failedNodes = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
// 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
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
|
||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
|
||||
// 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 =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedEquipmentTags.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.
|
||||
plan.RenamedFolders.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.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
|
||||
// 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)
|
||||
{
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
|
||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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
|
||||
// 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).
|
||||
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 historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||
: null;
|
||||
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)
|
||||
{
|
||||
_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.
|
||||
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)
|
||||
{
|
||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||
// closed), then remove the condition in place.
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
// top-of-Apply block already wrote the equipment id's terminal condition state.
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
@@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier
|
||||
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
|
||||
/// <returns>The materialise-parent node id.</returns>
|
||||
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>
|
||||
/// 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))
|
||||
{
|
||||
try { _sink.RaiseNodesAddedModelChange(id); }
|
||||
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
|
||||
}
|
||||
}
|
||||
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
|
||||
try
|
||||
{
|
||||
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;
|
||||
|
||||
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
|
||||
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
|
||||
// override falls back to the driver-side FullName.
|
||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
|
||||
// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
|
||||
// falls back to the RawPath (== NodeId).
|
||||
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
|
||||
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
|
||||
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
|
||||
}
|
||||
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
|
||||
List<HistorizedTagRef>? added = null;
|
||||
List<HistorizedTagRef>? removed = null;
|
||||
|
||||
// Added historized value variables → new interest.
|
||||
foreach (var tag in plan.AddedEquipmentTags)
|
||||
// v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Removed historized value variables → drop interest.
|
||||
foreach (var tag in plan.RemovedEquipmentTags)
|
||||
// Removed historized raw value tags → drop interest.
|
||||
foreach (var tag in plan.RemovedRawTags)
|
||||
{
|
||||
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
|
||||
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
|
||||
// mux ref and the historian name) — an unchanged pair is a no-op.
|
||||
foreach (var d in plan.ChangedEquipmentTags)
|
||||
// Changed raw tags: the historized ref may have flipped on/off or the historian override changed
|
||||
// (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
|
||||
// unchanged pair is a no-op.
|
||||
foreach (var d in plan.ChangedRawTags)
|
||||
{
|
||||
var prev = HistorizedRef(d.Previous);
|
||||
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
|
||||
/// value variable (not historized, or a native-alarm condition node).
|
||||
/// </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>
|
||||
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
|
||||
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
|
||||
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(
|
||||
tag.FullName,
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
tag.NodeId,
|
||||
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
|
||||
: null;
|
||||
|
||||
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
|
||||
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
|
||||
var failed = 0;
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
||||
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(
|
||||
@@ -604,6 +661,153 @@ public sealed class AddressSpaceApplier
|
||||
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>
|
||||
/// Materialise Equipment-namespace tags from a composition snapshot.
|
||||
/// For each <see cref="EquipmentTagPlan"/>,
|
||||
@@ -635,9 +839,9 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
{
|
||||
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 (!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
|
||||
@@ -650,13 +854,13 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// 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.
|
||||
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
|
||||
{
|
||||
@@ -670,7 +874,7 @@ public sealed class AddressSpaceApplier
|
||||
// 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).
|
||||
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;
|
||||
// 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 == '/')))
|
||||
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)
|
||||
{
|
||||
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
|
||||
var writable = v.Writable && !v.IsArray;
|
||||
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(
|
||||
"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)
|
||||
{
|
||||
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 (!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.
|
||||
@@ -769,10 +973,10 @@ public sealed class AddressSpaceApplier
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
||||
? v.EquipmentId
|
||||
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||
: UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||
// 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(
|
||||
@@ -805,7 +1009,7 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||
{
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 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>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// count (archreview 01/S-1).</summary>
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 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>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 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>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -53,10 +53,24 @@ public static class AddressSpaceChangeClassifier
|
||||
|
||||
// 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
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
|
||||
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
|
||||
// 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 ||
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedRawContainers.Count > 0 ||
|
||||
plan.ChangedRawTags.Count > 0 ||
|
||||
plan.ChangedUnsReferenceVariables.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(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.
|
||||
var hasAdds =
|
||||
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 =
|
||||
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.
|
||||
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
|
||||
/// constructor + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
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 UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
|
||||
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
|
||||
|
||||
/// <summary>
|
||||
/// 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 —
|
||||
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
|
||||
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
|
||||
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
|
||||
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
|
||||
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
|
||||
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
|
||||
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
|
||||
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
|
||||
/// <c>s=<RawPath></c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
|
||||
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
|
||||
/// <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>
|
||||
/// <param name="unsAreas">The UNS areas.</param>
|
||||
/// <param name="unsLines">The UNS lines.</param>
|
||||
@@ -315,6 +476,11 @@ public static class AddressSpaceComposer
|
||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
|
||||
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
||||
/// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/<RefName></c> reference map. <c>null</c> = none.</param>
|
||||
/// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
|
||||
/// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
@@ -323,7 +489,12 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<VirtualTag>? virtualTags = null,
|
||||
IReadOnlyList<Script>? scripts = null)
|
||||
IReadOnlyList<Script>? scripts = null,
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences = null,
|
||||
IReadOnlyList<Tag>? tags = null,
|
||||
IReadOnlyList<RawFolder>? rawFolders = null,
|
||||
IReadOnlyList<Device>? devices = null,
|
||||
IReadOnlyList<TagGroup>? tagGroups = null)
|
||||
{
|
||||
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
@@ -356,14 +527,25 @@ public static class AddressSpaceComposer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
|
||||
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
|
||||
// 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 baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
|
||||
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
|
||||
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
|
||||
var topology = RawTopology.Build(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>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
||||
// expression source. The {{equip}} token is substituted with the owning equipment's tag
|
||||
// base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
||||
// expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
|
||||
// RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
||||
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
||||
// VirtualTag has no FolderPath today → "".
|
||||
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
||||
@@ -374,7 +556,7 @@ public static class AddressSpaceComposer
|
||||
{
|
||||
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
src, baseByEquip.GetValueOrDefault(v.EquipmentId));
|
||||
src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
|
||||
return new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: v.VirtualTagId,
|
||||
EquipmentId: v.EquipmentId,
|
||||
@@ -412,7 +594,13 @@ public static class AddressSpaceComposer
|
||||
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
||||
continue;
|
||||
}
|
||||
var source = s.SourceCode;
|
||||
// v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
|
||||
// substituted with the owning equipment's reference map BEFORE the dependency merge — so the
|
||||
// stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
|
||||
// artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
|
||||
// shared EquipmentScriptPaths helper.
|
||||
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
|
||||
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: a.ScriptedAlarmId,
|
||||
EquipmentId: a.EquipmentId,
|
||||
@@ -422,21 +610,414 @@ public static class AddressSpaceComposer
|
||||
MessageTemplate: a.MessageTemplate,
|
||||
PredicateScriptId: a.PredicateScriptId,
|
||||
PredicateSource: source,
|
||||
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
|
||||
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
|
||||
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
HistorizeToAveva: a.HistorizeToAveva,
|
||||
Retain: a.Retain,
|
||||
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)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
|
||||
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
|
||||
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
RawPathResolver resolver)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
var tagRows = tags ?? Array.Empty<Tag>();
|
||||
if (refs.Count == 0 || tagRows.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
var tagsById = tagRows
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return EquipmentReferenceMap.Build(
|
||||
refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
|
||||
r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
|
||||
tagsById,
|
||||
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>
|
||||
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>
|
||||
public bool IsEmpty =>
|
||||
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 &&
|
||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.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;
|
||||
|
||||
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 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
|
||||
/// <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"/>
|
||||
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
|
||||
t => t.VirtualTagId,
|
||||
(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
|
||||
// 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
|
||||
@@ -159,6 +238,15 @@ public static class AddressSpacePlanner
|
||||
AddedEquipmentVirtualTags = addedVTags,
|
||||
RemovedEquipmentVirtualTags = removedVTags,
|
||||
ChangedEquipmentVirtualTags = changedVTags,
|
||||
AddedRawContainers = addedRawContainers,
|
||||
RemovedRawContainers = removedRawContainers,
|
||||
ChangedRawContainers = changedRawContainers,
|
||||
AddedRawTags = addedRawTags,
|
||||
RemovedRawTags = removedRawTags,
|
||||
ChangedRawTags = changedRawTags,
|
||||
AddedUnsReferenceVariables = addedUnsRefs,
|
||||
RemovedUnsReferenceVariables = removedUnsRefs,
|
||||
ChangedUnsReferenceVariables = changedUnsRefs,
|
||||
RenamedFolders = renamedFolders,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,48 +21,59 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
|
||||
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, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveVariableNode(string variableNodeId)
|
||||
=> _nodeManager.RemoveVariableNode(variableNodeId);
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId)
|
||||
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveVariableNode(variableNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId)
|
||||
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
|
||||
/// <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 />
|
||||
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();
|
||||
if (driverHost is null)
|
||||
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
try
|
||||
{
|
||||
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)
|
||||
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
|
||||
return new NodeWriteOutcome(result.Success, result.Reason);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
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.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -177,6 +178,218 @@ public static class DeploymentArtifact
|
||||
return byDriver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-equipment <c>{{equip}}/<RefName></c> reference map — <c>equipmentId →
|
||||
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
|
||||
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
|
||||
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
|
||||
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
|
||||
/// the artifact carries no references / tags.
|
||||
/// </summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
|
||||
{
|
||||
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
{
|
||||
var id = ReadString(el, "RawFolderId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||
{
|
||||
var id = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
|
||||
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[id!] = (di!, ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||
{
|
||||
var id = ReadString(el, "TagGroupId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
var tagsById = new Dictionary<string, EquipmentReferenceMap.TagRow>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var tagId = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var name = ReadString(el, "Name");
|
||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId"));
|
||||
}
|
||||
|
||||
var references = new List<EquipmentReferenceMap.ReferenceRow>();
|
||||
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||
{
|
||||
var refId = ReadString(el, "UnsTagReferenceId");
|
||||
var equipmentId = ReadString(el, "EquipmentId");
|
||||
var tagId = ReadString(el, "TagId");
|
||||
if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId))
|
||||
continue;
|
||||
references.Add(new EquipmentReferenceMap.ReferenceRow(
|
||||
refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride")));
|
||||
}
|
||||
|
||||
if (references.Count == 0 || tagsById.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
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
|
||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||
@@ -400,14 +613,27 @@ public static class DeploymentArtifact
|
||||
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
|
||||
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → RawPath), shared by the
|
||||
// VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(root);
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(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)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -469,6 +695,13 @@ public static class DeploymentArtifact
|
||||
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
|
||||
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,30 +795,22 @@ public static class DeploymentArtifact
|
||||
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
||||
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
|
||||
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
|
||||
/// plans agree. The reserved <c>{{equip}}</c> token in the joined Script's <c>SourceCode</c> is
|
||||
/// substituted with the owning equipment's tag base (derived from <paramref name="equipmentTags"/>'
|
||||
/// FullNames) BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the
|
||||
/// substituted source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
|
||||
/// plans agree. Each <c>{{equip}}/<RefName></c> in the joined Script's <c>SourceCode</c> is
|
||||
/// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
|
||||
/// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
|
||||
/// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
|
||||
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
|
||||
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
|
||||
/// ordering.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
|
||||
JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags)
|
||||
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
||||
{
|
||||
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentVirtualTagPlan>();
|
||||
|
||||
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
||||
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
|
||||
// scripts (equipment-relative tag paths). Derived from equipmentTags so the artifact-decode
|
||||
// base matches the composer's exactly.
|
||||
var baseByEquip = equipmentTags
|
||||
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
||||
StringComparer.Ordinal);
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
|
||||
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
@@ -626,11 +851,11 @@ public static class DeploymentArtifact
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
|
||||
// Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting
|
||||
// refs, so both Expression and DependencyRefs are machine-specific — byte-parity with
|
||||
// AddressSpaceComposer.Compose.
|
||||
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
|
||||
// extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
|
||||
// with AddressSpaceComposer.Compose.
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, baseByEquip.GetValueOrDefault(equipmentId!));
|
||||
source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap));
|
||||
|
||||
result.Add(new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: virtualTagId!,
|
||||
@@ -659,11 +884,13 @@ public static class DeploymentArtifact
|
||||
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
|
||||
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
|
||||
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
|
||||
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
|
||||
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
|
||||
/// predicate source's <c>{{equip}}/<RefName></c> paths are substituted with the owning equipment's
|
||||
/// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
|
||||
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
|
||||
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
||||
{
|
||||
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
@@ -684,6 +911,8 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
|
||||
foreach (var el in alarmsArr.EnumerateArray())
|
||||
{
|
||||
@@ -710,9 +939,14 @@ public static class DeploymentArtifact
|
||||
|
||||
// Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour
|
||||
// so both sides emit the same set (byte-parity).
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source))
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource))
|
||||
continue;
|
||||
|
||||
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE the
|
||||
// dependency merge — byte-parity with AddressSpaceComposer.Compose.
|
||||
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap));
|
||||
|
||||
result.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: scriptedAlarmId!,
|
||||
EquipmentId: equipmentId ?? string.Empty,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.OpcUaServer;
|
||||
|
||||
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
|
||||
/// </param>
|
||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||
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();
|
||||
|
||||
// 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++)
|
||||
{
|
||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
|
||||
var nodeId = Combine(rootNodeId, folderPath);
|
||||
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]);
|
||||
}
|
||||
|
||||
var varFolderPath = string.Join('/', segs);
|
||||
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
|
||||
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
|
||||
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
|
||||
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
|
||||
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||
? Combine(rootNodeId, n.BrowseName)
|
||||
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||
// A folder-less variable parents directly at the root device node.
|
||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||
? equipmentId
|
||||
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
|
||||
? rootNodeId
|
||||
: Combine(rootNodeId, varFolderPath);
|
||||
variables.Add(new DiscoveredVariable(
|
||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||
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.
|
||||
private long _childSpawnGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment
|
||||
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and
|
||||
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands
|
||||
/// 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>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
|
||||
/// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
|
||||
/// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
|
||||
/// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
|
||||
private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
|
||||
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/>
|
||||
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an
|
||||
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning
|
||||
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver
|
||||
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
|
||||
/// map fans out 1:N because one ref can back several variables).
|
||||
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
|
||||
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
|
||||
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
|
||||
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
|
||||
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
|
||||
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
|
||||
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; 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<string>> _alarmNodeIdByDriverRef = new();
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
|
||||
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
|
||||
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
|
||||
/// (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>
|
||||
/// 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
|
||||
/// 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>
|
||||
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);
|
||||
|
||||
/// <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>
|
||||
/// <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>
|
||||
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
|
||||
/// (or a gate/lookup failure that never reached the driver).</summary>
|
||||
@@ -558,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -588,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
@@ -620,13 +634,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// equipment variables (identical machines sharing a register), hence the fan-out.
|
||||
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(
|
||||
nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
|
||||
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -643,6 +659,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
|
||||
@@ -717,6 +748,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -918,9 +950,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var key = (driverId, driverRef);
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
|
||||
// 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(
|
||||
equipmentId, plan.Folders, plan.Variables));
|
||||
@@ -978,6 +1012,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
|
||||
/// alarm condition the driver owns: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>. This is the ONLY signal for a comms-lost native source, because a
|
||||
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
|
||||
/// comms-lost condition would keep reporting the accidentally-Good default forever.
|
||||
/// <para>
|
||||
/// UNGATED by redundancy role (like the condition write in <see cref="ForwardNativeAlarm"/>): a
|
||||
/// Secondary keeps its address space — including condition quality — warm for failover. Quality is
|
||||
/// a pure annotation: <c>WriteAlarmQuality</c> touches ONLY the condition's Quality, never its
|
||||
/// Active/Acked/Retain, and fires a Part 9 event only on a real quality-bucket change. No cluster
|
||||
/// <c>alerts</c> row is published here — driver comms health has its own status surface
|
||||
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
|
||||
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
|
||||
var ts = DateTime.UtcNow;
|
||||
var annotated = 0;
|
||||
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
|
||||
// (DriverInstanceId, RawPath); one driver ref can back several condition NodeIds (identical machines).
|
||||
foreach (var ((driverId, _), nodeIds) in _alarmNodeIdByDriverRef)
|
||||
{
|
||||
if (driverId != msg.DriverInstanceId) continue;
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
|
||||
n.NodeId, quality, ts, n.Realm));
|
||||
annotated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (annotated > 0)
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) {Quality}",
|
||||
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, quality);
|
||||
}
|
||||
|
||||
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
@@ -1005,17 +1079,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_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);
|
||||
_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).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
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(
|
||||
AlarmId: nodeId,
|
||||
EquipmentPath: meta.EquipmentId,
|
||||
@@ -1035,7 +1111,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// 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
|
||||
// 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 +1166,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1115,6 +1200,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
.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>
|
||||
/// 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
|
||||
@@ -1264,6 +1368,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
||||
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
@@ -1456,89 +1563,89 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they
|
||||
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native
|
||||
// alarm event stream, routed by ForwardNativeAlarm).
|
||||
var refsByDriver = composition.EquipmentTags
|
||||
// v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
|
||||
// per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
|
||||
// raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
|
||||
// excluded from the value set.
|
||||
var refsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's
|
||||
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one
|
||||
// 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 in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.EquipmentTags
|
||||
// Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
|
||||
// An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
|
||||
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
|
||||
// in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
|
||||
var alarmRefsByDriver = composition.RawTags
|
||||
.Where(t => t.Alarm is not null)
|
||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
|
||||
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors
|
||||
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to
|
||||
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux
|
||||
// can land driver values on the right node. Clear-and-repopulate every apply so renames
|
||||
// (Name/FolderPath/EquipmentId changes) and removals are reflected.
|
||||
// Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
|
||||
// every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
|
||||
var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
|
||||
foreach (var v in composition.UnsReferenceVariables)
|
||||
{
|
||||
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();
|
||||
// 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();
|
||||
// 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();
|
||||
// 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();
|
||||
// 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();
|
||||
_nativeAlarmProjector.Clear();
|
||||
foreach (var t in composition.EquipmentTags)
|
||||
foreach (var t in composition.RawTags)
|
||||
{
|
||||
var key = (t.DriverInstanceId, t.FullName);
|
||||
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
|
||||
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and
|
||||
// keep them OUT of the value maps + value-subscription set (so they don't get both a value
|
||||
// variable AND a condition).
|
||||
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
|
||||
// equipment notifier NodeIds; WP3 wires the single raw condition.
|
||||
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
|
||||
aset.Add(nodeId);
|
||||
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition
|
||||
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA
|
||||
// acknowledge of this condition reaches the right driver child.
|
||||
_driverRefByAlarmNodeId[nodeId] = key;
|
||||
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
|
||||
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
|
||||
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
|
||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
||||
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
|
||||
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
|
||||
_driverRefByAlarmNodeId[t.NodeId] = key;
|
||||
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
|
||||
// equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
|
||||
// Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
|
||||
// the /alerts row lists which equipment reference this raw condition.
|
||||
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
|
||||
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))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
_driverRefByNodeId[nodeId] = key;
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
|
||||
_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
|
||||
|
||||
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
public sealed record InitializeFailed(string Reason, int Generation);
|
||||
public sealed record DisconnectObserved(string Reason);
|
||||
/// <summary>#477 — sent to the parent (<see cref="DriverHostActor"/>) on every connectivity transition:
|
||||
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
||||
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
|
||||
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
||||
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
/// <summary>
|
||||
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void Connected()
|
||||
{
|
||||
// #477 — announce connectivity to the host so it can clear any comms-lost Quality annotation on this
|
||||
// driver's native alarm conditions (Bad → Good). Fire-and-forget; the host defaults conditions to a
|
||||
// non-Good "waiting for initial data" quality at materialise, and this is what confirms them Good.
|
||||
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: true));
|
||||
|
||||
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
||||
Receive<DisconnectObserved>(msg =>
|
||||
{
|
||||
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void Reconnecting()
|
||||
{
|
||||
// #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad
|
||||
// (a comms-lost driver emits no alarm events, so this is the ONLY signal that the source is unreachable).
|
||||
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
|
||||
|
||||
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
|
||||
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
|
||||
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
|
||||
|
||||
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
|
||||
/// <summary>
|
||||
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
||||
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
||||
/// <see cref="Enabled"/> is <c>true</c>.
|
||||
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
|
||||
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
|
||||
/// expander resolves fail-closed (throwing if the secret is absent) before this options
|
||||
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public string ApiKey { get; init; } = "";
|
||||
|
||||
|
||||
@@ -39,16 +39,34 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// 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() { } }
|
||||
|
||||
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
|
||||
/// <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
|
||||
/// <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="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>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
|
||||
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
|
||||
/// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change.</summary>
|
||||
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="Quality">The source-data quality to annotate.</param>
|
||||
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>
|
||||
/// 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
|
||||
@@ -230,6 +248,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
@@ -263,7 +282,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
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);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
@@ -277,7 +296,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
@@ -287,6 +306,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
@@ -340,6 +373,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
||||
var failedNodes = outcome.FailedNodes;
|
||||
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
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
|
||||
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
|
||||
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
|
||||
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
|
||||
_upstream.Push(msg.TagId,
|
||||
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
|
||||
}
|
||||
|
||||
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Bad => 0x80000000u,
|
||||
OpcUaQuality.Uncertain => 0x40000000u,
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
|
||||
private void OnEngineEmission(EngineEmission msg)
|
||||
{
|
||||
var e = msg.Event;
|
||||
@@ -294,13 +314,30 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// #478 — QualityChanged is a source-quality annotation (worst-of-input bucket moved with no Part 9
|
||||
// state transition — e.g. an input went Bad, freezing the condition). Route it to the dedicated
|
||||
// WriteAlarmQuality node path (sets ONLY Quality, one Part 9 event on a bucket change): NO full-state
|
||||
// projection (would clobber severity/message) and NO /alerts row (it is not a state transition, so it
|
||||
// must not historize). Same out-of-band quality path native comms-loss uses (#477 Layer 2).
|
||||
if (e.Emission == EmissionKind.QualityChanged)
|
||||
{
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode),
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
State: ToSnapshot(e),
|
||||
TimestampUtc: e.TimestampUtc));
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
|
||||
// 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.
|
||||
@@ -537,7 +574,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message);
|
||||
Message: e.Message,
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
|
||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||
return;
|
||||
}
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc);
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
|
||||
foreach (var sub in subscribers)
|
||||
{
|
||||
sub.Tell(dep);
|
||||
|
||||
@@ -29,14 +29,30 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
{
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
|
||||
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
|
||||
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
|
||||
public sealed record DependencyValueChanged(
|
||||
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
||||
/// 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 _scriptId;
|
||||
@@ -105,6 +121,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_mux = mux;
|
||||
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<ReassertValue>(_ => OnReassertValue());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -122,6 +139,32 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_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)
|
||||
{
|
||||
_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.
|
||||
///
|
||||
/// <para>
|
||||
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
|
||||
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
|
||||
/// materialises against — so the value always lands on a NodeId that exists.
|
||||
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
|
||||
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
|
||||
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
|
||||
/// always lands on a NodeId that exists.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
|
||||
// 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)
|
||||
{
|
||||
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
||||
@@ -165,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
mux: _mux));
|
||||
Context.Watch(child);
|
||||
_children[p.VirtualTagId] = child;
|
||||
newlySpawned.Add(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;
|
||||
}
|
||||
|
||||
// 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})",
|
||||
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
|
||||
// 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.
|
||||
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
|
||||
_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
|
||||
// no parallel map. The historian path key is the 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 (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
|
||||
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
|
||||
// node; recording it would append an artificial historian sample that never corresponded to a real
|
||||
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
|
||||
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
|
||||
// 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 */;
|
||||
_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
|
||||
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also
|
||||
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
|
||||
/// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
|
||||
/// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
|
||||
/// <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) =>
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
public class EquipmentScriptPathsTests
|
||||
{
|
||||
// Effective-name → backing-tag RawPath map used across the substitution tests.
|
||||
private static readonly IReadOnlyDictionary<string, string> RefMap =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Speed"] = "Cell1/Modbus/Dev1/Speed",
|
||||
["Out"] = "Cell1/Modbus/Dev1/Out",
|
||||
["A"] = "Cell1/Modbus/Dev1/A",
|
||||
["B"] = "Cell1/Modbus/Dev1/B",
|
||||
// an override-named reference: the effective name differs from the backing tag leaf name.
|
||||
["MotorSpeed"] = "Cell1/Modbus/Dev1/raw_speed_01",
|
||||
};
|
||||
|
||||
// ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ----
|
||||
|
||||
[Fact]
|
||||
@@ -33,123 +45,81 @@ public class EquipmentScriptPathsTests
|
||||
.ShouldBe(["Line.Temp"]);
|
||||
}
|
||||
|
||||
// ---- DeriveEquipmentBase ----
|
||||
// ---- SubstituteEquipmentToken (v3 reference-relative, slash syntax) ----
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_shared_prefix()
|
||||
public void SubstituteEquipmentToken_resolves_ref_to_rawpath_inside_GetTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"])
|
||||
.ShouldBe("TestMachine_001");
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/Speed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_when_prefixes_diverge()
|
||||
public void SubstituteEquipmentToken_resolves_ref_inside_SetVirtualTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"])
|
||||
.ShouldBeNull();
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.SetVirtualTag(\"{{equip}}/Out\", x)", RefMap)
|
||||
.ShouldBe("ctx.SetVirtualTag(\"Cell1/Modbus/Dev1/Out\", x)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_for_empty_sequence()
|
||||
public void SubstituteEquipmentToken_override_named_ref_resolves_to_its_backing_rawpath()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase([])
|
||||
.ShouldBeNull();
|
||||
// Effective name "MotorSpeed" (a DisplayNameOverride) resolves to the backing tag's RawPath whose
|
||||
// leaf name ("raw_speed_01") differs from the effective name — the override indirection.
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/MotorSpeed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/raw_speed_01\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_whole_value_when_no_dot()
|
||||
public void SubstituteEquipmentToken_leaves_unresolved_ref_intact_without_throwing()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["NoDot"])
|
||||
.ShouldBe("NoDot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_skips_null_empty_and_whitespace_entries()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase([null, "", " ", "TestMachine_001.A"])
|
||||
.ShouldBe("TestMachine_001");
|
||||
}
|
||||
|
||||
// ---- SubstituteEquipmentToken ----
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_inside_GetTag()
|
||||
{
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}.Source\")", "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_inside_SetVirtualTag()
|
||||
{
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.SetVirtualTag(\"{{equip}}.Out\", x)", "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.SetVirtualTag(\"TestMachine_001.Out\"");
|
||||
// An unknown reference name is left un-substituted (the validator rejects it first at deploy).
|
||||
var source = "ctx.GetTag(\"{{equip}}/Missing\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_comment_unchanged()
|
||||
{
|
||||
var source = "// uses {{equip}} here";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "// uses {{equip}}/Speed here";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
|
||||
{
|
||||
var source = "ctx.Logger.Information(\"{{equip}}\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "ctx.Logger.Information(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_all_occurrences()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");";
|
||||
var source = "ctx.GetTag(\"{{equip}}/A\"); ctx.GetTag(\"{{equip}}/B\");";
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap);
|
||||
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/B\")");
|
||||
result.ShouldNotContain(EquipmentScriptPaths.EquipToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null()
|
||||
public void SubstituteEquipmentToken_is_identity_when_map_empty()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, null)
|
||||
.ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_empty()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "")
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_token_absent()
|
||||
{
|
||||
var source = "ctx.GetTag(\"TestMachine_001.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,10 +127,59 @@ public class EquipmentScriptPathsTests
|
||||
{
|
||||
// Documents the regex limitation: only "double-quote" literals are handled,
|
||||
// matching the existing seam extractors. A raw-string literal is left as-is.
|
||||
var source = "ctx.GetTag(\"\"\"{{equip}}.X\"\"\")";
|
||||
var source = "ctx.GetTag(\"\"\"{{equip}}/Speed\"\"\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
// ---- ExtractEquipReferenceNames (script path-literal scoped) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_returns_distinct_refs_first_seen()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\"); ctx.SetVirtualTag(\"{{equip}}/Out\", x); ctx.GetTag(\"{{equip}}/Speed\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Speed", "Out"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_ignores_token_in_comment_or_logger()
|
||||
{
|
||||
// Only ctx.GetTag/SetVirtualTag path literals are scanned — a token in a comment / logger string
|
||||
// is NOT reported (so it can never block a deploy).
|
||||
var source = "// {{equip}}/InComment\nctx.Logger.Information(\"{{equip}}/InLog\"); ctx.GetTag(\"{{equip}}/Real\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Real"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_supports_ref_names_with_spaces()
|
||||
{
|
||||
// The whole post-prefix literal content is the reference name — so a space-bearing effective name
|
||||
// (valid raw name) is captured intact, matching what substitution resolves.
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames("ctx.GetTag(\"{{equip}}/My Tag\")")
|
||||
.ShouldBe(["My Tag"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("ctx.GetTag(\"Absolute/Path\")")]
|
||||
public void ExtractEquipReferenceNames_empty_when_no_token(string? source)
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractEquipReferenceNamesFromText (message-template free text) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_captures_refs_bounded_by_whitespace()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("Temp {{equip}}/Speed exceeded on {{equip}}/Limit")
|
||||
.ShouldBe(["Speed", "Limit"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_empty_when_no_token()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("plain message {Foo.Bar}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractDependencyRefs ----
|
||||
@@ -197,7 +216,7 @@ public class EquipmentScriptPathsTests
|
||||
[Fact]
|
||||
public void ContainsEquipToken_true_when_token_present()
|
||||
{
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue();
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}/X\")").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +236,7 @@ public class EquipmentScriptPathsTests
|
||||
[Theory]
|
||||
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
|
||||
[InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}/Speed\").Value;", "{{equip}}/Speed")]
|
||||
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
|
||||
{
|
||||
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
// 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]
|
||||
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
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 ----------
|
||||
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
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();
|
||||
}
|
||||
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
|
||||
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 ----------
|
||||
|
||||
[Fact]
|
||||
@@ -73,7 +92,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
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]
|
||||
@@ -84,7 +103,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(surgical);
|
||||
|
||||
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();
|
||||
surgical.UpdateCalled.ShouldBeTrue();
|
||||
@@ -97,7 +116,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse();
|
||||
sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,7 +127,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South");
|
||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
surgical.FolderRenameCalled.ShouldBeTrue();
|
||||
@@ -122,9 +141,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,9 +153,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeTrue();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
surgical.RemoveVariableCalled.ShouldBeTrue();
|
||||
surgical.RemoveAlarmCalled.ShouldBeTrue();
|
||||
@@ -147,9 +166,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void Before_SetSink_remove_members_return_false()
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- SetSink(null) reverts to null sink ----------
|
||||
@@ -163,7 +182,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(null); // revert
|
||||
|
||||
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]
|
||||
@@ -174,7 +193,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(inner);
|
||||
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");
|
||||
}
|
||||
@@ -185,16 +204,22 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
public bool WriteValueCalled { 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;
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void 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
|
||||
@@ -202,21 +227,24 @@ public class DeferredAddressSpaceSinkTests
|
||||
public bool UpdateCalled { get; private set; }
|
||||
public bool FolderRenameCalled { get; private set; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void 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;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
FolderRenameCalled = true;
|
||||
return true;
|
||||
@@ -226,8 +254,8 @@ public class DeferredAddressSpaceSinkTests
|
||||
public bool RemoveAlarmCalled { get; private set; }
|
||||
public bool RemoveSubtreeCalled { get; private set; }
|
||||
|
||||
public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; }
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; }
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; }
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; }
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = 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]
|
||||
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]
|
||||
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.Reason.ShouldBe("writes unavailable");
|
||||
}
|
||||
|
||||
@@ -205,6 +205,71 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>The rename-induced case the authoring guard never sees: an authoring-clean draft where
|
||||
/// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an
|
||||
/// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop.</summary>
|
||||
[Fact]
|
||||
public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed"
|
||||
UnsTagReferences =
|
||||
[
|
||||
// No override → effective name is the backing raw tag's Name ("speed").
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
// Names both colliding sources + the equipment.
|
||||
var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message;
|
||||
msg.ShouldContain("ref-1");
|
||||
msg.ShouldContain("vtag-eq-1-speed");
|
||||
msg.ShouldContain("eq-1");
|
||||
}
|
||||
|
||||
/// <summary>A reference's DisplayNameOverride (not its backing raw name) is the effective name and
|
||||
/// must be unique against a VirtualTag in the same equipment.</summary>
|
||||
[Fact]
|
||||
public void Reference_override_collides_with_virtualtag()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's
|
||||
/// backing raw name) do NOT collide — they are distinct OPC UA browse names.</summary>
|
||||
[Fact]
|
||||
public void Effective_name_collision_is_ordinal_case_sensitive()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")],
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
|
||||
{
|
||||
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
|
||||
@@ -214,6 +279,130 @@ public sealed class DraftValidatorTests
|
||||
ScriptId = "s-1",
|
||||
};
|
||||
|
||||
private static Tag BuildTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId,
|
||||
DeviceId = "dev-1",
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName = null) => new()
|
||||
{
|
||||
UnsTagReferenceId = refId,
|
||||
EquipmentId = equipmentId,
|
||||
TagId = tagId,
|
||||
DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// ValidateEquipReferenceResolution — v3 WP4: {{equip}}/<RefName> must resolve to a reference
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
private static Script BuildScript(string id, string source) => new()
|
||||
{
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = $"h-{id}",
|
||||
};
|
||||
|
||||
private static Tag BuildRawTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
};
|
||||
|
||||
/// <summary>A VirtualTag script using <c>{{equip}}/Speed</c> with no reference "Speed" on the equipment is
|
||||
/// a deploy error naming the equipment + the missing ref.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
};
|
||||
|
||||
var err = DraftValidator.Validate(draft).First(e => e.Code == "EquipReferenceUnresolved");
|
||||
err.Message.ShouldContain("eq-1");
|
||||
err.Message.ShouldContain("Speed");
|
||||
}
|
||||
|
||||
/// <summary>The same script resolves cleanly when the equipment has a reference whose effective name is
|
||||
/// "Speed" (backing raw tag's Name).</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolved_is_clean()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A DisplayNameOverride is the effective name: <c>{{equip}}/MotorSpeed</c> resolves to a
|
||||
/// reference overridden to "MotorSpeed" even though the backing raw tag's Name is "raw_speed".</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolves_by_override_name()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/MotorSpeed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "raw_speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1", overrideName: "MotorSpeed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A reference on a DIFFERENT equipment does not satisfy the token — resolution is per owning
|
||||
/// equipment.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_does_not_resolve_across_equipment()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-2", "tag-1")], // reference is on eq-2, not eq-1
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>Alarm message-template <c>{{equip}}/Temp</c> follows the same rule — an unresolved ref in the
|
||||
/// template is a deploy error.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarm_message_template_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return true;")],
|
||||
ScriptedAlarms =
|
||||
[
|
||||
new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "overheat",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "Too hot: {{equip}}/Temp",
|
||||
PredicateScriptId = "s-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// If Dispose threw or hung, the WaitAsync would surface it.
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const uint StatusUncertain = 0x40000000u;
|
||||
private const uint StatusBad = 0x80000000u;
|
||||
|
||||
/// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
|
||||
/// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
|
||||
[Fact]
|
||||
public async Task Transition_carries_worst_input_quality()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
|
||||
up.Push("Temp", 150, StatusUncertain);
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
|
||||
|
||||
var activated = events.First(e => e.Emission == EmissionKind.Activated);
|
||||
(activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
|
||||
}
|
||||
|
||||
/// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
|
||||
/// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
|
||||
[Fact]
|
||||
public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate false → inactive
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 50, StatusBad);
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
|
||||
|
||||
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
|
||||
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
|
||||
// No phantom state transition accompanied the quality change.
|
||||
events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
|
||||
.ShouldBeFalse();
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
|
||||
[Fact]
|
||||
public async Task Restoring_good_input_emits_QualityChanged_Good()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 50, StatusBad);
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
|
||||
&& (e.WorstInputStatusCode >> 30) >= 2u));
|
||||
events.Clear();
|
||||
|
||||
up.Push("Temp", 50, 0u); // Good again, still below threshold
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
|
||||
|
||||
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
|
||||
(q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
|
||||
}
|
||||
|
||||
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
|
||||
[Fact]
|
||||
public async Task No_QualityChanged_when_bucket_unchanged()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
|
||||
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
|
||||
events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>#478 — a QualityChanged engine emission (source-quality bucket change, no state transition)
|
||||
/// must NOT surface through the IAlarmSource fan-out: quality is delivered out of band via the
|
||||
/// dedicated node path, never as an AlarmEventArgs (which would materialize / historize a condition).</summary>
|
||||
[Fact]
|
||||
public async Task QualityChanged_emission_raises_no_alarm_event()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
|
||||
// Drive HighTemp's input Bad: the predicate freezes (no transition), but the worst-quality bucket
|
||||
// moves Good→Bad → the engine emits QualityChanged. The source must swallow it.
|
||||
up.Push("Temp", 50, 0x80000000u);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
|
||||
+12
-1
@@ -1,5 +1,6 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
||||
|
||||
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyDriverBrowserTests
|
||||
{
|
||||
private readonly GalaxyDriverBrowser _sut = new();
|
||||
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
|
||||
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
|
||||
// null-returning stub resolver suffices — it is never consulted.
|
||||
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
|
||||
|
||||
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user