Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e08b6b0e69 | |||
| 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,9 @@
|
||||
<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.1.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
|
||||
<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>
|
||||
|
||||
@@ -24,6 +24,93 @@ 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) |
|
||||
|
||||
**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.)
|
||||
|
||||
## 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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -23,30 +23,41 @@ 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);
|
||||
|
||||
/// <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 +66,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 +76,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 +86,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,9 @@ 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>
|
||||
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
|
||||
@@ -41,7 +47,31 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
|
||||
/// 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 +82,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 +108,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 +125,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 +161,29 @@ 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) { }
|
||||
|
||||
/// <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);
|
||||
|
||||
|
||||
@@ -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,7 @@
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
|
||||
<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>
|
||||
|
||||
@@ -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,8 +30,13 @@ 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 /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
|
||||
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
|
||||
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
|
||||
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
|
||||
app.MapRazorComponents<TApp>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -43,12 +50,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);
|
||||
|
||||
@@ -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,11 @@ 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.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 +77,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
|
||||
@@ -323,6 +349,9 @@ if (hasAdmin)
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
|
||||
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
|
||||
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,12 +11,21 @@
|
||||
"DisableLogin": false
|
||||
}
|
||||
},
|
||||
"Secrets": {
|
||||
"SqlitePath": "otopcua-secrets.db",
|
||||
"MasterKey": {
|
||||
"Source": "Environment",
|
||||
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||
},
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30"
|
||||
},
|
||||
"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,56 @@ 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);
|
||||
|
||||
/// <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>
|
||||
@@ -620,13 +632,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 +657,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 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -918,9 +948,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));
|
||||
@@ -1005,17 +1037,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 +1069,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 +1124,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 +1158,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
|
||||
@@ -1456,89 +1518,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
|
||||
|
||||
@@ -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,25 @@ 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>
|
||||
/// 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
|
||||
@@ -263,7 +272,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 +286,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"));
|
||||
}
|
||||
@@ -340,6 +349,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
|
||||
|
||||
@@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// 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.
|
||||
|
||||
@@ -31,12 +31,24 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
|
||||
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, but an
|
||||
/// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the
|
||||
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
|
||||
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
|
||||
|
||||
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
||||
/// 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 +117,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
_mux = mux;
|
||||
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<ReassertValue>(_ => OnReassertValue());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -122,6 +135,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,20 @@ 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 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 +225,23 @@ 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 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 +251,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)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
+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>
|
||||
|
||||
+94
-27
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up #2 — pins the three resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
||||
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
|
||||
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
|
||||
/// driver and the AdminUI browser share one copy.)
|
||||
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
|
||||
/// runtime driver and the AdminUI browser share one copy.)
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverApiKeyResolverTests
|
||||
{
|
||||
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
|
||||
private const string KnownSecretName = "galaxy/inst1/apikey";
|
||||
|
||||
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
|
||||
private const string KnownSecretValue = "key-from-secret-store";
|
||||
|
||||
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
|
||||
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||
|
||||
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
||||
[Fact]
|
||||
public void Literal_string_is_returned_unchanged()
|
||||
public async Task Literal_string_is_returned_unchanged()
|
||||
{
|
||||
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
|
||||
.ShouldBe("plain-text-key");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
||||
[Fact]
|
||||
public void Env_prefix_resolves_to_environment_variable()
|
||||
public async Task Env_prefix_resolves_to_environment_variable()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||
try
|
||||
{
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
|
||||
.ShouldBe("key-from-env");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
||||
[Fact]
|
||||
public void Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||
ex.Message.ShouldContain(name);
|
||||
ex.Message.ShouldContain("unset");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||
[Fact]
|
||||
public void File_prefix_resolves_to_trimmed_file_contents()
|
||||
public async Task File_prefix_resolves_to_trimmed_file_contents()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " key-from-file \n");
|
||||
try
|
||||
{
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
|
||||
.ShouldBe("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||
[Fact]
|
||||
public void File_prefix_missing_path_throws()
|
||||
public async Task File_prefix_missing_path_throws()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain(path);
|
||||
ex.Message.ShouldContain("doesn't exist");
|
||||
}
|
||||
|
||||
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
||||
|
||||
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
|
||||
[Fact]
|
||||
public async Task Secret_prefix_resolves_through_secret_resolver()
|
||||
{
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
|
||||
.ShouldBe(KnownSecretValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
|
||||
[Fact]
|
||||
public async Task Secret_prefix_absent_secret_throws_fail_closed()
|
||||
{
|
||||
const string missingRef = "secret:galaxy/missing/apikey";
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
|
||||
|
||||
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
|
||||
// must NOT return the ref string as the key.
|
||||
ex.Message.ShouldContain("galaxy/missing/apikey");
|
||||
ex.Message.ShouldContain("absent");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
|
||||
[Fact]
|
||||
public async Task Secret_prefix_does_not_emit_literal_warning()
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
||||
|
||||
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
||||
[Fact]
|
||||
public void Literal_string_emits_warning_when_logger_supplied()
|
||||
public async Task Literal_string_emits_warning_when_logger_supplied()
|
||||
{
|
||||
// A literal API key on a production deployment means the cleartext key sits
|
||||
// in the DriverConfig JSON. The resolver must surface a warning so an
|
||||
// operator who committed one by accident sees it at startup.
|
||||
var logger = new CaptureLogger();
|
||||
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
|
||||
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
|
||||
|
||||
key.ShouldBe("plain-text-key");
|
||||
logger.Entries.ShouldContain(e =>
|
||||
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
||||
[Fact]
|
||||
public void Dev_prefix_returns_literal_without_warning()
|
||||
public async Task Dev_prefix_returns_literal_without_warning()
|
||||
{
|
||||
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||
var logger = new CaptureLogger();
|
||||
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
|
||||
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
|
||||
|
||||
key.ShouldBe("plain-text-key");
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
|
||||
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
||||
[Fact]
|
||||
public void Env_prefix_does_not_emit_literal_warning()
|
||||
public async Task Env_prefix_does_not_emit_literal_warning()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
||||
Environment.SetEnvironmentVariable(name, "v");
|
||||
try
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
||||
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
finally
|
||||
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
|
||||
[Fact]
|
||||
public async Task Null_resolver_throws()
|
||||
{
|
||||
await Should.ThrowAsync<ArgumentNullException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
|
||||
}
|
||||
|
||||
/// <summary>A test logger that captures log entries for verification.</summary>
|
||||
private sealed class CaptureLogger : ILogger
|
||||
{
|
||||
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(
|
||||
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
|
||||
? KnownSecretValue
|
||||
: null);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
||||
[Fact]
|
||||
public void File_prefix_empty_file_throws()
|
||||
public async Task File_prefix_empty_file_throws()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " \n ");
|
||||
try
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain("empty");
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
|
||||
public void Register_AddsFactoryToRegistry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyDriverFactoryExtensions.Register(registry);
|
||||
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||||
|
||||
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
|
||||
// _dataReader and _subscriber are both null. The follow-up read path can't
|
||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
||||
var driver = new GalaxyDriver("g", Opts());
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.ReadAsync(["x"], CancellationToken.None));
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_NoSubscriber_Throws()
|
||||
{
|
||||
using var driver = new GalaxyDriver("g", Opts());
|
||||
using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
|
||||
[Fact]
|
||||
public async Task WriteAsync_NoWriter_Throws()
|
||||
{
|
||||
var driver = new GalaxyDriver("g", Opts());
|
||||
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
||||
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
||||
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
||||
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
||||
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
||||
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
||||
/// Non-secret literals and null/empty pass through unchanged.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientSecretResolutionTests
|
||||
{
|
||||
private const string KnownPwName = "opcua/inst/password";
|
||||
private const string KnownPwValue = "resolved-password-plaintext";
|
||||
private const string KnownCertPwName = "opcua/inst/cert-pw";
|
||||
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||
|
||||
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
||||
[Fact]
|
||||
public async Task Secret_password_resolves_to_store_plaintext()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
|
||||
|
||||
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None);
|
||||
|
||||
resolved.Password.ShouldBe(KnownPwValue);
|
||||
}
|
||||
|
||||
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
||||
[Fact]
|
||||
public async Task Secret_cert_password_resolves_to_store_plaintext()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
|
||||
|
||||
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None);
|
||||
|
||||
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
|
||||
}
|
||||
|
||||
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task Literal_and_null_pass_through_unchanged()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions
|
||||
{
|
||||
Password = "plainpw",
|
||||
UserCertificatePassword = null,
|
||||
};
|
||||
|
||||
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None);
|
||||
|
||||
resolved.Password.ShouldBe("plainpw");
|
||||
resolved.UserCertificatePassword.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
||||
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_secret_ref_is_fail_closed()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("Password");
|
||||
ex.Message.ShouldContain("opcua/inst/absent");
|
||||
}
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(name.Value switch
|
||||
{
|
||||
KnownPwName => KnownPwValue,
|
||||
KnownCertPwName => KnownCertPwValue,
|
||||
_ => null,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ public sealed class CtxCompletionGuardTests
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" });
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null));
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? f, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
|
||||
}
|
||||
|
||||
|
||||
+59
-26
@@ -5,63 +5,96 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the
|
||||
/// <c>{{equip}}</c> token shows an "equipment-relative" note (not the "not a known configured
|
||||
/// tag path" warning), and completion after <c>{{equip}}.</c> offers the attribute leaf names.
|
||||
/// v3 {{equip}}/<RefName> editor niceties: hover on a path literal containing the token shows an
|
||||
/// "equipment-relative" note (not the "not a known configured tag path" warning); completion after
|
||||
/// <c>{{equip}}/</c> offers the owning equipment's reference effective names; and the diagnostic flags an
|
||||
/// unresolved <c>{{equip}}/<RefName></c> exactly as the deploy gate does (editor accepts ⇔ publish
|
||||
/// accepts).
|
||||
/// </summary>
|
||||
public sealed class EquipTokenEditorTests
|
||||
{
|
||||
/// <summary>A fake catalog whose configured paths all share an object prefix, so the attribute
|
||||
/// leaf names ("Source", "Other") are what {{equip}}. completion should surface.</summary>
|
||||
/// <summary>A fake catalog whose equipment has two references — "Speed" and "Temp" — so those are the
|
||||
/// resolvable {{equip}}/<RefName> names for the completion + diagnostic.</summary>
|
||||
private sealed class FakeCatalog : IScriptTagCatalog
|
||||
{
|
||||
private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(Paths);
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Cell1/Modbus/Dev1/Speed" });
|
||||
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(null);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<string> leaves = new[] { "Source", "Other" };
|
||||
IEnumerable<string> names = new[] { "Speed", "Temp" };
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult<IReadOnlyList<string>>(leaves.ToList());
|
||||
names = names.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult<IReadOnlyList<string>>(names.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
|
||||
|
||||
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col)
|
||||
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
|
||||
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col, string? equipmentId)
|
||||
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col, equipmentId))).Items;
|
||||
|
||||
[Fact] public async Task Completion_after_equip_dot_offers_attribute_leaves()
|
||||
[Fact] public async Task Completion_after_equip_slash_offers_reference_names()
|
||||
{
|
||||
// caret right after the dot of "{{equip}}." — column 30 sits between the trailing dot and
|
||||
// the closing quote of ctx.GetTag("{{equip}}.").
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}.Source");
|
||||
// caret right after the slash of "{{equip}}/" — column 30 sits between the trailing slash and the
|
||||
// closing quote of ctx.GetTag("{{equip}}/").
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}/Speed");
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}/Temp");
|
||||
items.ShouldAllBe(i => i.Detail == "tag path");
|
||||
}
|
||||
|
||||
[Fact] public async Task Completion_after_equip_dot_inserts_the_full_token_qualified_leaf()
|
||||
[Fact] public async Task Completion_after_equip_slash_inserts_the_full_token_qualified_ref()
|
||||
{
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
|
||||
var source = items.FirstOrDefault(i => i.Label == "{{equip}}.Source");
|
||||
source.ShouldNotBeNull();
|
||||
source!.InsertText.ShouldBe("{{equip}}.Source");
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
|
||||
var speed = items.FirstOrDefault(i => i.Label == "{{equip}}/Speed");
|
||||
speed.ShouldNotBeNull();
|
||||
speed!.InsertText.ShouldBe("{{equip}}/Speed");
|
||||
}
|
||||
|
||||
[Fact] public async Task Completion_after_equip_slash_is_inert_without_equipment_context()
|
||||
{
|
||||
// Shared ScriptEdit page (no EquipmentId) can't resolve references → no equip completions.
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, null);
|
||||
items.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative()
|
||||
{
|
||||
// caret inside ctx.GetTag("{{equip}}.Source")
|
||||
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}.Source").Value;""", 1, 24))).Markdown;
|
||||
// caret inside ctx.GetTag("{{equip}}/Speed")
|
||||
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", 1, 24))).Markdown;
|
||||
md.ShouldNotBeNull();
|
||||
md!.ShouldContain("Equipment-relative");
|
||||
// the {{equip}} token must render literally (non-interpolated markdown segment)
|
||||
md.ShouldContain("{{equip}}");
|
||||
md.ShouldNotContain("Not a known");
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_flags_unresolved_equip_reference()
|
||||
{
|
||||
// "Missing" is not one of the equipment's references (Speed/Temp) → a diagnostic, matching the
|
||||
// deploy gate. Uses the equipment context via the request's EquipmentId.
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;""", "EQ-1"));
|
||||
resp.Markers.ShouldContain(m => m.Code == "OTSCRIPT_EQUIPREF" && m.Message.Contains("Missing"));
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_clean_for_resolved_equip_reference()
|
||||
{
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", "EQ-1"));
|
||||
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_equipref_inert_without_equipment_context()
|
||||
{
|
||||
// No EquipmentId (shared ScriptEdit page) → the equip-ref check is inert (base markers only),
|
||||
// matching the deploy gate which only resolves per owning equipment.
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;"""));
|
||||
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class HoverSignatureTests
|
||||
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||
}
|
||||
|
||||
|
||||
+54
-70
@@ -285,82 +285,66 @@ public sealed class ScriptTagCatalogTests
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEquipmentRelativeLeavesAsync — DB-backed tests
|
||||
// GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
|
||||
// -----------------------------------------------------------------------
|
||||
// Seeded paths and their leaf derivation:
|
||||
// TAG-EQ → "Motor.Speed" → leaf "Speed"
|
||||
// TAG-SP → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath"
|
||||
// VTAG-1 → "Computed" → NO dot → excluded
|
||||
// Effective name = UnsTagReference.DisplayNameOverride else the backing raw tag's Name.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only()
|
||||
/// <summary>Seeds equipment EQ-1 with two references: one to TAG-EQ (Name "Speed", no override) and one
|
||||
/// to TAG-SP (Name "DownloadPath") under a display override "Path".</summary>
|
||||
private static void SeedReferences(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
|
||||
|
||||
// Leaves derived from the two dot-bearing paths in Seed().
|
||||
leaves.ShouldContain("Speed"); // from "Motor.Speed"
|
||||
leaves.ShouldContain("DownloadPath"); // from "DelmiaReceiver_001.DownloadPath"
|
||||
|
||||
// Virtual tag "Computed" has no dot — must be excluded.
|
||||
leaves.ShouldNotContain("Computed");
|
||||
}
|
||||
|
||||
/// <summary>A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath").</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default);
|
||||
|
||||
leaves.ShouldContain("Speed");
|
||||
leaves.ShouldNotContain("DownloadPath");
|
||||
leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed".</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default);
|
||||
|
||||
leaves.ShouldContain("Speed");
|
||||
}
|
||||
|
||||
/// <summary>Two paths sharing the same leaf after different object prefixes produce one distinct entry.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
// Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ.
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
using var db = new OtOpcUaConfigDbContext(opts);
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ2",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "Speed2",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"Conveyor.Speed\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
UnsTagReferenceId = "REF-1", EquipmentId = "EQ-1", TagId = "TAG-EQ", DisplayNameOverride = null,
|
||||
});
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "REF-2", EquipmentId = "EQ-1", TagId = "TAG-SP", DisplayNameOverride = "Path",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
|
||||
/// <summary>Returns the equipment's reference effective names — backing tag Name when no override, the
|
||||
/// override otherwise.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_returns_effective_names()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
// "Speed" must appear exactly once despite being produced by two different paths.
|
||||
leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1);
|
||||
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", null, default);
|
||||
|
||||
names.ShouldContain("Speed"); // TAG-EQ Name, no override
|
||||
names.ShouldContain("Path"); // TAG-SP under the "Path" override
|
||||
names.ShouldNotContain("DownloadPath"); // the raw Name is superseded by the override
|
||||
}
|
||||
|
||||
/// <summary>A prefix filter narrows (case-insensitively) to reference names that start with the prefix.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", "sp", default);
|
||||
|
||||
names.ShouldContain("Speed");
|
||||
names.ShouldNotContain("Path");
|
||||
}
|
||||
|
||||
/// <summary>An unknown equipment (or one with no references) yields an empty set; a blank id yields empty.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_empty_for_unknown_or_blank_equipment()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
(await catalog.GetEquipmentReferenceNamesAsync("EQ-UNKNOWN", null, default)).ShouldBeEmpty();
|
||||
(await catalog.GetEquipmentReferenceNamesAsync("", null, default)).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@ public sealed class TagPathCompletionTests
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(null);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
// canned leaves derived from the canned paths above (substring after the first dot)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EffectiveNameGuard"/> — the authoring-time UNS effective-name
|
||||
/// uniqueness guard. Backed by an InMemory EF database seeded with one equipment carrying a
|
||||
/// reference (override + backing raw tag), a VirtualTag, and a ScriptedAlarm.
|
||||
/// </summary>
|
||||
public sealed class EffectiveNameGuardTests
|
||||
{
|
||||
private const string EquipmentId = "EQ-000000000001";
|
||||
|
||||
// Reference REF-1 → raw tag TAG-1 (Name "speed"), NO override → effective "speed".
|
||||
// Reference REF-2 → raw tag TAG-2 (Name "raw-temp"), override "temperature" → effective "temperature".
|
||||
// VirtualTag VT-1 (Name "computed"). ScriptedAlarm AL-1 (Name "highpress").
|
||||
private const string RefNoOverrideId = "REF-1";
|
||||
private const string RefWithOverrideId = "REF-2";
|
||||
private const string VirtualTagId = "VT-1";
|
||||
private const string ScriptedAlarmId = "AL-1";
|
||||
|
||||
private static IDbContextFactory<OtOpcUaConfigDbContext> SeedFactory()
|
||||
{
|
||||
var name = $"engname-{Guid.NewGuid():N}";
|
||||
var factory = new NamedFactory(name);
|
||||
using var db = factory.CreateDbContext();
|
||||
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-2",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "raw-temp",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = RefNoOverrideId,
|
||||
EquipmentId = EquipmentId,
|
||||
TagId = "TAG-1",
|
||||
DisplayNameOverride = null, // effective = raw tag Name "speed"
|
||||
});
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = RefWithOverrideId,
|
||||
EquipmentId = EquipmentId,
|
||||
TagId = "TAG-2",
|
||||
DisplayNameOverride = "temperature", // effective = "temperature"
|
||||
});
|
||||
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = VirtualTagId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "computed",
|
||||
DataType = "Float",
|
||||
ScriptId = "SCR-1",
|
||||
});
|
||||
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = ScriptedAlarmId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "highpress",
|
||||
AlarmType = "AlarmCondition",
|
||||
MessageTemplate = "{TagPath} high",
|
||||
PredicateScriptId = "SCR-2",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static EffectiveNameGuard Guard(IDbContextFactory<OtOpcUaConfigDbContext> factory) => new(factory);
|
||||
|
||||
[Fact]
|
||||
public async Task Free_name_returns_null()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "brand-new-name", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reference_vs_new_virtualtag_clash_returns_error_naming_the_reference()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// A new VirtualTag proposing "speed" collides with reference REF-1's effective name.
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_effective_name_vs_new_reference_clash_returns_error()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// A new reference proposing "temperature" collides with REF-2's OVERRIDE effective name.
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: null);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBe($"effective name 'temperature' already used by reference '{RefWithOverrideId}' in equipment '{EquipmentId}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_virtualtag_vs_scripted_alarm_clash_returns_error_naming_the_alarm()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "highpress", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBe($"effective name 'highpress' already used by ScriptedAlarm '{ScriptedAlarmId}' in equipment '{EquipmentId}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_exclude_on_override_change_returns_null()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// Editing REF-2 to change its override to "temperature" (its own current effective name)
|
||||
// must NOT collide with itself.
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: RefWithOverrideId);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_exclude_only_applies_to_same_kind()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// Excluding a VirtualTag whose id happens to equal a reference id must NOT excuse the
|
||||
// reference collision — the self-row is identified by (kind, id) together.
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: RefNoOverrideId);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ordinal_case_sensitivity_Speed_does_not_collide_with_speed()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// "Speed" (capital S) must NOT collide with the existing "speed" (ordinal comparison).
|
||||
var result = await guard.CheckAsync(
|
||||
EquipmentId, "Speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Collision_is_scoped_to_the_equipment()
|
||||
{
|
||||
var guard = Guard(SeedFactory());
|
||||
|
||||
// Same name, different equipment → no collision (per-equipment NodeId space).
|
||||
var result = await guard.CheckAsync(
|
||||
"EQ-000000000002", "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(name)
|
||||
.Options);
|
||||
|
||||
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CreateDbContext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the B3-WP3 rename-warning extension of <see cref="RawTreeService"/>: the three warning kinds a
|
||||
/// tag/ancestor rename can raise — (a) historized WITHOUT a <c>historianTagname</c> override (history
|
||||
/// forks), (b) UNS-referenced (names the equipment), and (c) matched by a substring scan of a
|
||||
/// <c>Script</c> body for the affected tag's OLD RawPath.
|
||||
/// <para>
|
||||
/// Seeds a bespoke InMemory slice (independent of the shared <c>RawTreeTestDb</c>) so each device holds
|
||||
/// exactly one tag with a controlled property mix. The EF InMemory provider does not enforce
|
||||
/// <c>RowVersion</c> tokens, so renames commit against the seeded row versions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RawTreeServiceRenameWarningTests
|
||||
{
|
||||
private const string ClusterId = "MAIN";
|
||||
private const string FolderId = "RF-plant";
|
||||
private const string DriverId = "DRV-modbus";
|
||||
|
||||
// Dev-all → the "everything" tag: historized (no override) + referenced + named in a script literal.
|
||||
private const string DevAllId = "DEV-all";
|
||||
private const string FlowTagId = "TAG-flow";
|
||||
private const string FlowRawPath = "Plant/modbus-1/Dev-all/flow";
|
||||
|
||||
// Dev-pinned → a historized tag WITH a historianTagname override (pinned; must NOT warn).
|
||||
private const string DevPinnedId = "DEV-pinned";
|
||||
private const string TempTagId = "TAG-temp";
|
||||
|
||||
// Dev-plain → a plain tag, unreferenced + absent from every script (must warn nothing).
|
||||
private const string DevPlainId = "DEV-plain";
|
||||
private const string IdleTagId = "TAG-idle";
|
||||
|
||||
private const string EquipmentId = "EQ-000000000001";
|
||||
private const string EquipmentName = "packer-1";
|
||||
private const string ScriptName = "AreaCalc";
|
||||
|
||||
private static (RawTreeService Service, string DbName) Seeded()
|
||||
{
|
||||
var name = $"rawwarn-{Guid.NewGuid():N}";
|
||||
using var db = CreateNamed(name);
|
||||
Seed(db);
|
||||
return (new RawTreeService(new NamedFactory(name)), name);
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext CreateNamed(string name) =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
||||
|
||||
private static byte[] DeviceRowVersion(string dbName, string deviceId)
|
||||
{
|
||||
using var db = CreateNamed(dbName);
|
||||
return db.Devices.Single(d => d.DeviceId == deviceId).RowVersion;
|
||||
}
|
||||
|
||||
private static byte[] DriverRowVersion(string dbName, string driverId)
|
||||
{
|
||||
using var db = CreateNamed(dbName);
|
||||
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- Scenarios
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_of_ancestor_of_a_historized_referenced_scripted_tag_raises_all_three_warnings()
|
||||
{
|
||||
var (service, dbName) = Seeded();
|
||||
var rv = DeviceRowVersion(dbName, DevAllId);
|
||||
|
||||
var result = await service.RenameDeviceAsync(DevAllId, "Dev-renamed", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Warnings.ShouldContain(w => w.Contains("historian")); // (a)
|
||||
result.Warnings.ShouldContain(w => w.Contains(EquipmentName)); // (b)
|
||||
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName)); // (c)
|
||||
result.Warnings.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_of_a_pinned_historized_tag_raises_no_historized_warning()
|
||||
{
|
||||
var (service, dbName) = Seeded();
|
||||
var rv = DeviceRowVersion(dbName, DevPinnedId);
|
||||
|
||||
var result = await service.RenameDeviceAsync(DevPinnedId, "Dev-pinned2", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
// A historianTagname override pins the tagname, so its history does NOT fork — no (a) warning; and
|
||||
// the pinned tag is neither referenced nor scripted, so nothing else fires either.
|
||||
result.Warnings.ShouldNotContain(w => w.Contains("historian"));
|
||||
result.Warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_of_an_unrelated_tags_ancestor_raises_no_warnings()
|
||||
{
|
||||
var (service, dbName) = Seeded();
|
||||
var rv = DeviceRowVersion(dbName, DevPlainId);
|
||||
|
||||
var result = await service.RenameDeviceAsync(DevPlainId, "Dev-plain2", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_scan_matches_when_an_ancestor_is_renamed_and_the_old_path_is_in_a_script_body()
|
||||
{
|
||||
var (service, dbName) = Seeded();
|
||||
var rv = DriverRowVersion(dbName, DriverId);
|
||||
|
||||
// Renaming the DRIVER (an ancestor of the scripted tag) still computes the tag's unchanged OLD
|
||||
// RawPath (rename not yet saved) and finds it as a substring of the script body.
|
||||
var result = await service.RenameDriverAsync(DriverId, "modbus-renamed", rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Seed
|
||||
|
||||
private static void Seed(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
Name = "Main",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
db.RawFolders.Add(new RawFolder
|
||||
{
|
||||
RawFolderId = FolderId,
|
||||
ClusterId = ClusterId,
|
||||
ParentRawFolderId = null,
|
||||
Name = "Plant",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = DriverId,
|
||||
ClusterId = ClusterId,
|
||||
RawFolderId = FolderId,
|
||||
Name = "modbus-1",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
|
||||
db.Devices.Add(new Device { DeviceId = DevAllId, DriverInstanceId = DriverId, Name = "Dev-all", DeviceConfig = "{}" });
|
||||
db.Devices.Add(new Device { DeviceId = DevPinnedId, DriverInstanceId = DriverId, Name = "Dev-pinned", DeviceConfig = "{}" });
|
||||
db.Devices.Add(new Device { DeviceId = DevPlainId, DriverInstanceId = DriverId, Name = "Dev-plain", DeviceConfig = "{}" });
|
||||
|
||||
// Historized, no historianTagname override → its default tagname is FlowRawPath, which moves.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = FlowTagId,
|
||||
DeviceId = DevAllId,
|
||||
TagGroupId = null,
|
||||
Name = "flow",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"isHistorized\":true}",
|
||||
});
|
||||
// Historized WITH a historianTagname override → pinned, must not fork/warn.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = TempTagId,
|
||||
DeviceId = DevPinnedId,
|
||||
TagGroupId = null,
|
||||
Name = "temp",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"Plant.Pinned.Temp\"}",
|
||||
});
|
||||
// Plain, unreferenced, absent from scripts.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = IdleTagId,
|
||||
DeviceId = DevPlainId,
|
||||
TagGroupId = null,
|
||||
Name = "idle",
|
||||
DataType = "Boolean",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = EquipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-1",
|
||||
Name = EquipmentName,
|
||||
MachineCode = "packer_001",
|
||||
});
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "REF-flow",
|
||||
EquipmentId = EquipmentId,
|
||||
TagId = FlowTagId,
|
||||
});
|
||||
|
||||
// A script whose body names the flow tag by its absolute RawPath literal.
|
||||
db.Scripts.Add(new Script
|
||||
{
|
||||
ScriptId = "SC-areacalc",
|
||||
Name = ScriptName,
|
||||
SourceCode = $"var v = ctx.GetTag(\"{FlowRawPath}\"); return v * 2;",
|
||||
SourceHash = "hash-areacalc",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
||||
|
||||
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CreateDbContext());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user